refactor(core): CORE-10 type playback properties and add compatible controls view

This commit is contained in:
Harvey Zhao committed 2026-09-11 02:51:55 +08:00
1 parent 44f4e083a9
commit 5e31274f1d
48 files changed
+3846 -248

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
export interface Position {
readonly duration: number
get currentTime(): number
set currentTime(time: number | string)
}
export interface SeekMethods {
get seek(): undefined
set seek(time: number | string)
get forward(): undefined
set forward(time: number)
get backward(): undefined
set backward(time: number)
}
export interface SeekHost extends Position {
notice: { set show(message: string) }
emit: (name: 'seek', currentTime: number, requestedTime: number | string) => unknown
}
export interface PlaybackNotice {
i18n: { get: (key: string) => string }
notice: { set show(message: string) }
}
export interface VolumeHost extends PlaybackNotice {
template: { $video: Pick<HTMLMediaElement, 'volume' | 'muted'> }
storage: { set: (key: 'volume', value: number) => unknown }
emit: (name: 'muted', value: boolean) => unknown
}
export type DisplayState = 'mini' | 'pip' | 'fullscreen' | 'fullscreenWeb'
export type DisplayStates = Record<DisplayState, boolean>
export interface StateProperty {
get state(): DisplayState | 'standard'
set state(name: string)
}
@@ -1,15 +0,0 @@
import { clamp, def } from '../utils'
export default function currentTimeMix(art) {
const { $video } = art.template
def(art, 'currentTime', {
get: () => $video.currentTime || 0,
set: (time) => {
time = Number.parseFloat(time)
if (Number.isNaN(time))
return
$video.currentTime = clamp(time, 0, art.duration)
},
})
}
@@ -0,0 +1,17 @@
import type { MediaHost } from '../media/hosts'
import type { Position } from '../media/playback'
import { clamp, def } from '../utils'
export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'currentTime'>> & Pick<Position, 'duration'>): asserts art is typeof art & Position {
const { $video } = art.template
def(art, 'currentTime', {
get: () => $video.currentTime || 0,
set: (time: number | string) => {
// parseFloat performs the historical JS coercion; a cast must not change it.
const parsed = Number.parseFloat(time as string)
if (Number.isNaN(parsed))
return
$video.currentTime = clamp(parsed, 0, art.duration)
},
})
}
@@ -1,15 +0,0 @@
import { def } from '../utils'
export default function loadedMix(art) {
const { $video } = art.template
def(art, 'loaded', {
get: () => art.loadedTime / $video.duration,
})
def(art, 'loadedTime', {
get: () => {
return $video.buffered.length ? $video.buffered.end($video.buffered.length - 1) : 0
},
})
}
@@ -0,0 +1,17 @@
import type { MediaHost } from '../media/hosts'
import { def } from '../utils'
type LoadedHost = MediaHost<Pick<HTMLMediaElement, 'duration' | 'buffered'>>
interface LoadedState {
readonly loaded: number
readonly loadedTime: number
}
export default function loadedMix(art: LoadedHost): asserts art is LoadedHost & LoadedState {
const { $video } = art.template
const target = art as LoadedHost & LoadedState
def(art, 'loaded', { get: () => target.loadedTime / $video.duration })
def(art, 'loadedTime', {
get: () => $video.buffered.length ? $video.buffered.end($video.buffered.length - 1) : 0,
})
}
@@ -1,26 +0,0 @@
import { def } from '../utils'
export default function playbackRateMix(art) {
const {
template: { $video },
i18n,
notice,
} = art
def(art, 'playbackRate', {
get() {
return $video.playbackRate
},
set(rate) {
if (rate) {
if (rate === $video.playbackRate)
return
$video.playbackRate = rate
notice.show = `${i18n.get('Rate')}: ${rate === 1.0 ? i18n.get('Normal') : `${rate}x`}`
}
else {
art.playbackRate = 1
}
},
})
}
@@ -0,0 +1,26 @@
import type { MediaHost } from '../media/hosts'
import type { PlaybackNotice } from '../media/playback'
import { def } from '../utils'
type RateHost = MediaHost<Pick<HTMLMediaElement, 'playbackRate'>> & PlaybackNotice
export default function playbackRateMix(art: RateHost): asserts art is RateHost & { playbackRate: number } {
const { template: { $video }, i18n, notice } = art
const target = art as RateHost & { playbackRate: number }
def(art, 'playbackRate', {
get() {
return $video.playbackRate
},
set(rate: number) {
if (rate) {
if (rate === $video.playbackRate)
return
$video.playbackRate = rate
notice.show = `${i18n.get('Rate')}: ${rate === 1.0 ? i18n.get('Normal') : `${rate}x`}`
}
else {
target.playbackRate = 1
}
},
})
}
@@ -1,7 +0,0 @@
import { def } from '../utils'
export default function playedMix(art) {
def(art, 'played', {
get: () => art.currentTime / art.duration,
})
}
@@ -0,0 +1,6 @@
import type { Position } from '../media/playback'
import { def } from '../utils'
export default function playedMix(art: Position): asserts art is Position & { readonly played: number } {
def(art, 'played', { get: () => art.currentTime / art.duration })
}
-27
View File
@@ -1,27 +0,0 @@
import { def, secondToTime } from '../utils'
export default function seekMix(art) {
const { notice } = art
def(art, 'seek', {
set(time) {
art.currentTime = time
if (art.duration) {
notice.show = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
}
art.emit('seek', art.currentTime, time)
},
})
def(art, 'forward', {
set(time) {
art.seek = art.currentTime + time
},
})
def(art, 'backward', {
set(time) {
art.seek = art.currentTime - time
},
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { SeekHost, SeekMethods } from '../media/playback'
import { def, secondToTime } from '../utils'
export default function seekMix(art: SeekHost): asserts art is SeekHost & SeekMethods {
const { notice } = art
def(art, 'seek', {
set(time: number | string) {
art.currentTime = time
if (art.duration)
notice.show = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
art.emit('seek', art.currentTime, time)
},
})
// The seek descriptor is installed above; preserve forwarding through it.
const target = art as SeekHost & SeekMethods
def(art, 'forward', {
set(time: number) {
target.seek = art.currentTime + time
},
})
def(art, 'backward', {
set(time: number) {
target.seek = art.currentTime - time
},
})
}
-16
View File
@@ -1,16 +0,0 @@
import { def } from '../utils'
export default function stateMix(art) {
const states = ['mini', 'pip', 'fullscreen', 'fullscreenWeb']
def(art, 'state', {
get: () => states.find(name => art[name]) || 'standard',
set(name) {
for (let index = 0; index < states.length; index++) {
const prop = states[index]
if (prop !== name && art[prop]) {
art[prop] = false
}
}
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import type { DisplayStates, StateProperty } from '../media/playback'
import { def } from '../utils'
export default function stateMix(art: DisplayStates): asserts art is DisplayStates & StateProperty {
const states = ['mini', 'pip', 'fullscreen', 'fullscreenWeb'] as const
def(art, 'state', {
get: () => states.find(name => art[name]) || 'standard',
set(name: string) {
for (let index = 0; index < states.length; index++) {
const prop = states[index]!
if (prop !== name && art[prop])
art[prop] = false
}
},
})
}
@@ -1,14 +0,0 @@
import { def } from '../utils'
export default function toggleMix(art) {
def(art, 'toggle', {
value() {
if (art.playing) {
return art.pause()
}
else {
return art.play()
}
},
})
}
@@ -0,0 +1,13 @@
import type { PlaybackMethods } from '../media/types'
import { def } from '../utils'
export default function toggleMix<PlayResult, PauseResult>(art: PlaybackMethods<PlayResult, PauseResult> & { readonly playing: boolean }): asserts art is typeof art & { toggle: () => PlayResult | PauseResult } {
def(art, 'toggle', {
value() {
if (art.playing)
return art.pause()
else
return art.play()
},
})
}
@@ -1,29 +0,0 @@
import { clamp, def } from '../utils'
export default function volumeMix(art) {
const {
template: { $video },
i18n,
notice,
storage,
} = art
def(art, 'volume', {
get: () => $video.volume || 0,
set: (percentage) => {
$video.volume = clamp(percentage, 0, 1)
notice.show = `${i18n.get('Volume')}: ${Number.parseInt($video.volume * 100, 10)}`
if ($video.volume !== 0) {
storage.set('volume', $video.volume)
}
},
})
def(art, 'muted', {
get: () => $video.muted,
set: (muted) => {
$video.muted = muted
art.emit('muted', muted)
},
})
}
@@ -0,0 +1,22 @@
import type { VolumeHost } from '../media/playback'
import { clamp, def } from '../utils'
export default function volumeMix(art: VolumeHost): asserts art is VolumeHost & Pick<HTMLMediaElement, 'volume' | 'muted'> {
const { template: { $video }, i18n, notice, storage } = art
def(art, 'volume', {
get: () => $video.volume || 0,
set: (percentage: number) => {
$video.volume = clamp(percentage, 0, 1)
notice.show = `${i18n.get('Volume')}: ${Number.parseInt(String($video.volume * 100), 10)}`
if ($video.volume !== 0)
storage.set('volume', $video.volume)
},
})
def(art, 'muted', {
get: () => $video.muted,
set: (muted: boolean) => {
$video.muted = muted
art.emit('muted', muted)
},
})
}
+1
View File
@@ -40,5 +40,6 @@ export interface SwitchHost extends UrlTarget, SourceEvents {
export interface SwitchMethods {
switchUrl: (url: string) => Promise<void>
switchQuality: (url: string) => Promise<void>
get switch(): undefined
set switch(url: string)
}