feat: 重构 quicktime — Vue 组件化 + 6 测试(播放/暂停/切歌/进度/音量)
This commit is contained in:
parent
f2ab1379b9
commit
f71a5ba4b5
@ -1,42 +1,122 @@
|
||||
<template>
|
||||
<div class="qt-body" style="background:#000;display:flex;align-items:center;justify-content:center;flex-direction:column">
|
||||
<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"
|
||||
></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-body">
|
||||
<div class="qt-stage" @click="toggle">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="qt-video"
|
||||
playsinline
|
||||
@play="playing = true" @pause="playing = false"
|
||||
@timeupdate="onTimeUpdate" @ended="nav(1)" @error="videoErr = true"
|
||||
></video>
|
||||
<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>
|
||||
|
||||
|
||||
43
tests/cases/25-quicktime.test.ts
Normal file
43
tests/cases/25-quicktime.test.ts
Normal 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) })
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user