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">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||
// Dr. Flask, Ph.D. — the chemistry glossary's mascot/cover (placeholder render).
|
||||
// Web-optimized from docs/character/drflask-final.png.
|
||||
// Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
|
||||
// plus the v2 intro video + a poster frame grabbed from it for the lightbox.
|
||||
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 {
|
||||
question: string
|
||||
@@ -222,6 +224,83 @@ function toggle(key: string): void {
|
||||
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 {
|
||||
return `${groupLabel}-${idx}`
|
||||
}
|
||||
@@ -245,8 +324,12 @@ function initReveal(): void {
|
||||
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||
}
|
||||
|
||||
onMounted(() => { initReveal() })
|
||||
onUnmounted(() => { io?.disconnect() })
|
||||
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
|
||||
onUnmounted(() => {
|
||||
io?.disconnect()
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -322,7 +405,11 @@ onUnmounted(() => { io?.disconnect() })
|
||||
<section class="sec sec--lab" id="chemistry">
|
||||
<div class="wrap">
|
||||
<div class="lab-intro reveal">
|
||||
<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">
|
||||
<span class="eyebrow">Glossary</span>
|
||||
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
|
||||
@@ -402,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -495,12 +624,56 @@ onUnmounted(() => { io?.disconnect() })
|
||||
max-width: 920px;
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
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 .eyebrow { display: block; margin-bottom: 12px; }
|
||||
.lab-intro__copy .title { margin: 0 0 14px; }
|
||||
@@ -584,8 +757,106 @@ onUnmounted(() => { io?.disconnect() })
|
||||
}
|
||||
.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) {
|
||||
.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__copy { text-align: center; }
|
||||
.term-grid { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user