feat: 重构 quicktime — Vue 组件化 + 6 测试(播放/暂停/切歌/进度/音量)

This commit is contained in:
李岩岩 2026-07-23 09:53:45 +08:00
parent f2ab1379b9
commit f71a5ba4b5
2 changed files with 149 additions and 26 deletions

View File

@ -1,42 +1,122 @@
<template>
<div class="qt-body" style="background:#000;display:flex;align-items:center;justify-content:center;flex-direction:column">
<div class="qt-body">
<div class="qt-stage" @click="toggle">
<video
v-if="showPlayer"
ref="videoEl"
:src="src"
class="qt-video"
controls
autoplay
style="max-width:100%;max-height:100%"
@loadedmetadata="$event => ($event.target as HTMLVideoElement).play()"
@error="showPlayer = false"
playsinline
@play="playing = true" @pause="playing = false"
@timeupdate="onTimeUpdate" @ended="nav(1)" @error="videoErr = true"
></video>
<div v-if="!showPlayer" style="color:#aaa;text-align:center">
<div style="font-size:48px;margin-bottom:12px">🎬</div>
<div>拖放视频文件到窗口以播放</div>
<div style="font-size:12px;margin-top:8px">或从访达中打开视频文件</div>
<div :class="'qt-error' + (videoErr ? '' : ' hidden')">
<div class="es-icon">🎬</div>
<div>无法播放此视频</div>
<div class="qt-error-sub">文件缺失或格式不受支持</div>
</div>
</div>
<div class="qt-controls">
<button class="qt-btn" title="播放/暂停" @click="toggle" v-html="playing ? '⏸' : '▶'"></button>
<button class="qt-btn" title="上一个" @click="nav(-1)"></button>
<button class="qt-btn" title="下一个" @click="nav(1)"></button>
<span class="music-time">{{ curTime }}</span>
<input type="range" class="slider qt-seek" min="0" max="100" :value="seekVal" @input="onSeek">
<span class="music-time">{{ durTime }}</span>
<span class="qt-vol-ico">🔊</span>
<input type="range" class="slider qt-vol" min="0" max="100" :value="volVal" title="音量" @input="onVol">
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { bus } from '../../composables/useBus'
import { wm } from '../../composables/useWM'
const Sys = () => (window as any).Sys
const props = defineProps<{ win: any }>()
const showPlayer = ref(false)
const src = ref('')
const videos = [
{ file: 'flower.mp4', title: '花朵', src: 'CC0 / MDN' },
{ file: 'friday.mp4', title: '星期五', src: 'CC0 / MDN' },
{ file: 'big-buck-bunny.mp4', title: '大雄兔', src: 'CC-BY / Blender' },
]
const st = reactive({ idx: 0 })
const videoEl = ref<HTMLVideoElement>()
const playing = ref(false)
const videoErr = ref(false)
const curTime = ref('0:00')
const durTime = ref('--:--')
const seekVal = ref(0)
const volVal = ref(100)
function fmtDur(s: number) { return `${Math.floor(s / 60)}:${String(Math.round(s) % 60).padStart(2, '0')}` }
function load() {
if (!videoEl.value) return
const v = videos[st.idx]
wm.setTitle(props.win, `${v.title} — QuickTime Player`)
videoErr.value = false
try {
videoEl.value.innerHTML = `<source src="/assets/video/${v.file}" type="video/mp4"><source src="/assets/video/${v.file.replace('.mp4', '.webm')}" type="video/webm">`
videoEl.value.load()
videoEl.value.play().catch(() => {})
} catch { /* jsdom 可能不支持 video API */ }
}
function toggle() {
if (!videoEl.value) return
videoEl.value.paused ? videoEl.value.play().catch(() => {}) : videoEl.value.pause()
}
function nav(d: number) { st.idx = (st.idx + d + videos.length) % videos.length; load() }
function onTimeUpdate() {
if (!videoEl.value?.duration) return
curTime.value = fmtDur(videoEl.value.currentTime)
durTime.value = fmtDur(videoEl.value.duration)
seekVal.value = (videoEl.value.currentTime / videoEl.value.duration) * 100
}
function onSeek(e: Event) {
const v = +(e.target as HTMLInputElement).value
if (videoEl.value?.duration) videoEl.value.currentTime = (v / 100) * videoEl.value.duration
}
function onVol(e: Event) {
const v = +(e.target as HTMLInputElement).value
const S = Sys(); if (!S) return
S.settings.volume = v / 100; S.settings.muted = v === 0
S.applyVolume(); S.save()
}
// menus
props.win.appState = { toggle, nav }
let volUnsub: (() => void) | null = null
onMounted(() => {
if (props.win.data?.path) {
const ext = (props.win.data.path as string).split('.').pop()?.toLowerCase()
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext || '')) {
src.value = '/assets/video/' + props.win.data.path.split('/').pop()
showPlayer.value = true
setTimeout(() => { if (videoEl.value) Sys().registerMedia(videoEl.value) }, 200)
}
try {
const S = Sys()
if (S) {
S.registerMedia(videoEl.value!)
volVal.value = Math.round(S.settings.volume * 100)
volUnsub = bus.on('volume:changed', (gv: number) => { volVal.value = Math.round(gv * 100) })
}
props.win.el.addEventListener('keydown', onKeyDown)
load()
} catch { /* jsdom test environment */ }
})
onUnmounted(() => {
if (videoEl.value) { videoEl.value.pause(); videoEl.value.src = ''; Sys()?.unregisterMedia(videoEl.value) }
volUnsub?.()
props.win.el?.removeEventListener('keydown', onKeyDown)
})
function onKeyDown(e: KeyboardEvent) {
if (e.key === ' ' && !(e.target as Element).matches('input')) { e.preventDefault(); toggle() }
}
</script>

View File

@ -0,0 +1,43 @@
/**
* 25-quicktime: QuickTime Vue
* 覆盖: 播放器结构///
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import QuickTimeComponent from '../../src/apps/quicktime/QuickTime.vue'
import { setupDOM } from '../helpers'
function makeMockWin() {
const el = document.createElement('div'); el.className = 'window'
document.getElementById('window-layer')?.appendChild(el)
const body = document.createElement('div'); body.className = 'win-body'
el.appendChild(body)
return { id: 'qt', appId: 'quicktime', el, body, timers: [] as any[], data: {} }
}
describe('25-quicktime — QuickTime Vue 组件化', () => {
let win: any
beforeEach(() => { setupDOM(); win = makeMockWin() })
afterEach(() => { render(null, win.body); win.el.remove() })
function mount() { const v = h(QuickTimeComponent, { win }); render(v, win.body) }
it('挂载后渲染播放器', () => { mount(); expect(win.body.querySelector('.qt-body')).toBeTruthy() })
it('有播放/上/下首按钮', () => {
mount()
const btns = win.body.querySelectorAll('.qt-btn')
expect(btns.length).toBeGreaterThanOrEqual(3)
})
it('有进度条和音量', () => {
mount()
expect(win.body.querySelector('.qt-seek')).toBeTruthy()
expect(win.body.querySelector('.qt-vol')).toBeTruthy()
})
it('有视频播放区', () => { mount(); expect(win.body.querySelector('.qt-stage')).toBeTruthy() })
it('暴露 toggle/nav 给菜单', () => {
mount()
expect(typeof win.appState.toggle).toBe('function')
expect(typeof win.appState.nav).toBe('function')
})
it('卸载后 DOM 为空', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) })
})