feat(faq): wire Dr. Flask intro video into the phone-frame lightbox
The 85s v2 intro plays click-to-play in the phone mockup with fully custom controls (play/pause, green seek bar, live timecode, mute, fullscreen) — no loop, pause on close, Esc/backdrop/X to dismiss. Opens with sound on the cover click (user gesture); falls back to muted autoplay if the browser blocks it. - Transcoded the 163 MB / ~15 Mbps export -> 10.8 MB (720x1280, H.264 CRF 28, +faststart) so it only downloads when a visitor opts in (preload=metadata). - Poster = a v2 frame grabbed from the video (drflask-poster.jpg, ~60 KB). - Source 163 MB master stays untracked in docs/character/. Verified live via Playwright: video loads (readyState 4, 85s), autoplays on open, timecode/seek-fill track, play/pause + mute buttons both toggle state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
Binary file not shown.
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
// Dr. Flask, Ph.D. — the chemistry glossary's mascot/cover (placeholder render).
|
// Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
|
||||||
// Web-optimized from docs/character/drflask-final.png.
|
// plus the v2 intro video + a poster frame grabbed from it for the lightbox.
|
||||||
import drFlask from '@/assets/mascots/drflask.png'
|
import drFlask from '@/assets/mascots/drflask.png'
|
||||||
|
import drFlaskVideo from '@/assets/mascots/drflask-intro.mp4'
|
||||||
|
import drFlaskPoster from '@/assets/mascots/drflask-poster.jpg'
|
||||||
|
|
||||||
interface FaqItem {
|
interface FaqItem {
|
||||||
question: string
|
question: string
|
||||||
@@ -222,6 +224,83 @@ function toggle(key: string): void {
|
|||||||
openKey.value = openKey.value === key ? null : key
|
openKey.value = openKey.value === key ? null : key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dr. Flask intro video — plays in a phone-frame lightbox with custom controls.
|
||||||
|
const videoOpen = ref<boolean>(false)
|
||||||
|
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||||
|
const playing = ref(false)
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const curTime = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const progressPct = computed(() =>
|
||||||
|
duration.value > 0 ? (curTime.value / duration.value) * 100 : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function openVideo(): void {
|
||||||
|
videoOpen.value = true
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
// The cover click is a user gesture, so try to play with sound; if the
|
||||||
|
// browser's autoplay policy blocks it, fall back to muted playback (always
|
||||||
|
// allowed) and surface the unmute control.
|
||||||
|
nextTick(() => {
|
||||||
|
const v = videoEl.value
|
||||||
|
if (!v) return
|
||||||
|
v.currentTime = 0
|
||||||
|
v.muted = false
|
||||||
|
isMuted.value = false
|
||||||
|
v.play().catch(() => {
|
||||||
|
v.muted = true
|
||||||
|
isMuted.value = true
|
||||||
|
v.play().catch(() => {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function closeVideo(): void {
|
||||||
|
videoOpen.value = false
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
videoEl.value?.pause()
|
||||||
|
}
|
||||||
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape' && videoOpen.value) closeVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(): void {
|
||||||
|
const v = videoEl.value
|
||||||
|
if (!v) return
|
||||||
|
if (v.paused) v.play().catch(() => {})
|
||||||
|
else v.pause()
|
||||||
|
}
|
||||||
|
function toggleMute(): void {
|
||||||
|
const v = videoEl.value
|
||||||
|
if (!v) return
|
||||||
|
v.muted = !v.muted
|
||||||
|
isMuted.value = v.muted
|
||||||
|
}
|
||||||
|
function seek(e: MouseEvent): void {
|
||||||
|
const v = videoEl.value
|
||||||
|
if (!v || !duration.value) return
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||||
|
v.currentTime = pct * duration.value
|
||||||
|
}
|
||||||
|
function toggleFullscreen(): void {
|
||||||
|
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
|
||||||
|
if (!v) return
|
||||||
|
const frame = v.closest('.phone') as (HTMLElement & { requestFullscreen?: () => Promise<void> }) | null
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen?.()
|
||||||
|
} else if (frame?.requestFullscreen) {
|
||||||
|
void frame.requestFullscreen()
|
||||||
|
} else if (v.webkitEnterFullscreen) {
|
||||||
|
v.webkitEnterFullscreen() // iOS Safari: only the <video> can go fullscreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fmtTime(s: number): string {
|
||||||
|
if (!Number.isFinite(s)) return '0:00'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function itemKey(groupLabel: string, idx: number): string {
|
function itemKey(groupLabel: string, idx: number): string {
|
||||||
return `${groupLabel}-${idx}`
|
return `${groupLabel}-${idx}`
|
||||||
}
|
}
|
||||||
@@ -245,8 +324,12 @@ function initReveal(): void {
|
|||||||
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { initReveal() })
|
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
|
||||||
onUnmounted(() => { io?.disconnect() })
|
onUnmounted(() => {
|
||||||
|
io?.disconnect()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -322,7 +405,11 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
<section class="sec sec--lab" id="chemistry">
|
<section class="sec sec--lab" id="chemistry">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="lab-intro reveal">
|
<div class="lab-intro reveal">
|
||||||
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
|
<button class="lab-intro__cover" type="button" @click="openVideo" aria-label="Play Dr. Flask intro video">
|
||||||
|
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
|
||||||
|
<span class="lab-intro__play"><Icon name="play" :size="24" /></span>
|
||||||
|
<span class="lab-intro__watch">Watch the intro</span>
|
||||||
|
</button>
|
||||||
<div class="lab-intro__copy">
|
<div class="lab-intro__copy">
|
||||||
<span class="eyebrow">Glossary</span>
|
<span class="eyebrow">Glossary</span>
|
||||||
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
|
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
|
||||||
@@ -402,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- DR. FLASK INTRO — phone-frame lightbox -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="videoOpen" class="vmodal" @click.self="closeVideo">
|
||||||
|
<button class="vmodal__close" type="button" @click="closeVideo" aria-label="Close">
|
||||||
|
<Icon name="x" :size="20" />
|
||||||
|
</button>
|
||||||
|
<div class="phone" role="dialog" aria-label="Dr. Flask intro video">
|
||||||
|
<span class="phone__island" />
|
||||||
|
<div class="phone__screen">
|
||||||
|
<video
|
||||||
|
ref="videoEl"
|
||||||
|
class="phone__media"
|
||||||
|
:src="drFlaskVideo"
|
||||||
|
:poster="drFlaskPoster"
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
@click="togglePlay"
|
||||||
|
@play="playing = true"
|
||||||
|
@pause="playing = false"
|
||||||
|
@timeupdate="curTime = videoEl?.currentTime ?? 0"
|
||||||
|
@loadedmetadata="duration = videoEl?.duration ?? 0"
|
||||||
|
/>
|
||||||
|
<div class="phone__controls">
|
||||||
|
<button class="pc-btn" type="button" @click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'">
|
||||||
|
<Icon :name="playing ? 'pause' : 'play'" :size="18" />
|
||||||
|
</button>
|
||||||
|
<div class="pc-track" @click="seek">
|
||||||
|
<span class="pc-fill" :style="{ width: progressPct + '%' }" />
|
||||||
|
</div>
|
||||||
|
<span class="pc-time">{{ fmtTime(curTime) }} / {{ fmtTime(duration) }}</span>
|
||||||
|
<button class="pc-btn" type="button" @click="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
|
||||||
|
<Icon :name="isMuted ? 'volume-x' : 'volume-2'" :size="17" />
|
||||||
|
</button>
|
||||||
|
<button class="pc-btn" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
|
||||||
|
<Icon name="maximize" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -495,12 +624,56 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
margin: 0 auto 40px;
|
margin: 0 auto 40px;
|
||||||
}
|
}
|
||||||
|
.lab-intro__cover {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
.lab-intro__card {
|
.lab-intro__card {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
|
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
.lab-intro__play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 8px 22px rgba(0, 0, 0, 0.45);
|
||||||
|
transition: transform var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.lab-intro__play :deep(svg) { margin-left: 3px; } /* optical-center the play triangle */
|
||||||
|
.lab-intro__watch {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.lab-intro__cover:hover .lab-intro__play {
|
||||||
|
transform: scale(1.08);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #06170d;
|
||||||
|
}
|
||||||
.lab-intro__copy { text-align: left; }
|
.lab-intro__copy { text-align: left; }
|
||||||
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
|
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
.lab-intro__copy .title { margin: 0 0 14px; }
|
.lab-intro__copy .title { margin: 0 0 14px; }
|
||||||
@@ -584,8 +757,106 @@ onUnmounted(() => { io?.disconnect() })
|
|||||||
}
|
}
|
||||||
.drflask-card__quips strong { color: var(--accent-text); }
|
.drflask-card__quips strong { color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* Dr. Flask intro — phone-frame lightbox */
|
||||||
|
.vmodal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(6, 8, 10, 0.8);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.vmodal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
right: 22px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.vmodal__close:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
position: relative;
|
||||||
|
height: min(78vh, 620px);
|
||||||
|
aspect-ratio: 9 / 19.5;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 42px;
|
||||||
|
background: linear-gradient(155deg, #1c1f24, #0c0d10);
|
||||||
|
box-shadow: 0 0 0 2px #2b2e34, 0 32px 70px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.phone__island {
|
||||||
|
position: absolute;
|
||||||
|
top: 19px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 88px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #000;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.phone__screen {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 33px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.phone__media { width: 100%; height: 100%; object-fit: cover; background: #06070a; cursor: pointer; }
|
||||||
|
.phone__controls {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 14px 14px 18px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
|
||||||
|
}
|
||||||
|
.pc-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.95;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--dur-fast), color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.pc-btn:hover { opacity: 1; color: #5bd183; }
|
||||||
|
.pc-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pc-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: #5bd183; }
|
||||||
|
.pc-time { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; flex: none; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
|
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
|
||||||
|
.lab-intro__cover { max-width: 280px; }
|
||||||
.lab-intro__card { max-width: 280px; }
|
.lab-intro__card { max-width: 280px; }
|
||||||
.lab-intro__copy { text-align: center; }
|
.lab-intro__copy { text-align: center; }
|
||||||
.term-grid { grid-template-columns: 1fr; }
|
.term-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
Reference in New Issue
Block a user