feat: extract player controls timeline keyboard
This commit is contained in:
169
static/player/controls.ts
Normal file
169
static/player/controls.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { state } from './state'
|
||||||
|
|
||||||
|
export const formatTime = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showControls = (): void => {
|
||||||
|
state.container.classList.add('show-controls')
|
||||||
|
window.clearTimeout(state.playerControlsTimeout)
|
||||||
|
state.playerControlsTimeout = window.setTimeout(() => {
|
||||||
|
if (!state.isScrubbing && !state.video.paused) {
|
||||||
|
state.container.classList.remove('show-controls')
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seekBy = (delta: number): void => {
|
||||||
|
if (state.video.duration <= 0) return
|
||||||
|
state.video.currentTime = Math.max(0, Math.min(state.video.duration, state.video.currentTime + delta))
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const togglePlayPause = (): void => {
|
||||||
|
if (state.video.paused) {
|
||||||
|
state.video.play()
|
||||||
|
} else {
|
||||||
|
state.video.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleMute = (): void => {
|
||||||
|
if (state.video.muted || state.video.volume === 0) {
|
||||||
|
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1
|
||||||
|
state.video.muted = false
|
||||||
|
state.video.volume = restored
|
||||||
|
} else {
|
||||||
|
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume
|
||||||
|
state.video.muted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setVolume = (value: number): void => {
|
||||||
|
state.video.volume = Math.max(0, Math.min(1, value))
|
||||||
|
state.video.muted = value === 0
|
||||||
|
if (value > 0) state.lastKnownVolume = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleFullscreen = (): void => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.container.requestFullscreen?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncVolumeUI = (): void => {
|
||||||
|
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls()
|
||||||
|
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100)
|
||||||
|
if (volumeRange) {
|
||||||
|
volumeRange.value = String(value)
|
||||||
|
volumeRange.style.setProperty('--volume-percent', `${value}%`)
|
||||||
|
}
|
||||||
|
if (volumeUnderline) volumeUnderline.style.height = `${value}%`
|
||||||
|
updateMuteIcons(state.video.muted || state.video.volume === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Controls {
|
||||||
|
playPause: HTMLButtonElement | null
|
||||||
|
muteBtn: HTMLButtonElement | null
|
||||||
|
volumePanel: HTMLElement | null
|
||||||
|
volumeRange: HTMLInputElement | null
|
||||||
|
volumeUnderline: HTMLElement | null
|
||||||
|
backwardBtn: HTMLButtonElement | null
|
||||||
|
forwardBtn: HTMLButtonElement | null
|
||||||
|
fullscreenBtn: HTMLButtonElement | null
|
||||||
|
iconPlay: SVGElement | null
|
||||||
|
iconPause: SVGElement | null
|
||||||
|
iconVolume: SVGElement | null
|
||||||
|
iconMuted: SVGElement | null
|
||||||
|
skipSegmentBtn: HTMLButtonElement | null
|
||||||
|
subtitleText: HTMLElement | null
|
||||||
|
autoplayBtn: HTMLInputElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let controlsCache: Controls | null = null
|
||||||
|
|
||||||
|
const getControls = (): Controls => {
|
||||||
|
if (controlsCache) return controlsCache
|
||||||
|
const c = state.container
|
||||||
|
controlsCache = {
|
||||||
|
playPause: c.querySelector('[data-play-pause]'),
|
||||||
|
muteBtn: c.querySelector('[data-mute]'),
|
||||||
|
volumePanel: c.querySelector('[data-volume-panel]'),
|
||||||
|
volumeRange: c.querySelector('[data-volume-range]'),
|
||||||
|
volumeUnderline: c.querySelector('[data-volume-underline]'),
|
||||||
|
backwardBtn: c.querySelector('[data-backward]'),
|
||||||
|
forwardBtn: c.querySelector('[data-forward]'),
|
||||||
|
fullscreenBtn: c.querySelector('[data-fullscreen]'),
|
||||||
|
iconPlay: c.querySelector('[data-icon-play]'),
|
||||||
|
iconPause: c.querySelector('[data-icon-pause]'),
|
||||||
|
iconVolume: c.querySelector('[data-icon-volume]'),
|
||||||
|
iconMuted: c.querySelector('[data-icon-muted]'),
|
||||||
|
skipSegmentBtn: c.querySelector('[data-skip]'),
|
||||||
|
subtitleText: c.querySelector('[data-subtitle-text]'),
|
||||||
|
autoplayBtn: document.querySelector('[data-autoplay]'),
|
||||||
|
}
|
||||||
|
return controlsCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlayPauseIcons = (isPlaying: boolean): void => {
|
||||||
|
const { iconPlay, iconPause } = getControls()
|
||||||
|
iconPlay?.classList.toggle('hidden', isPlaying)
|
||||||
|
iconPause?.classList.toggle('hidden', !isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMuteIcons = (isMuted: boolean): void => {
|
||||||
|
const { iconVolume, iconMuted } = getControls()
|
||||||
|
iconVolume?.classList.toggle('hidden', isMuted)
|
||||||
|
iconMuted?.classList.toggle('hidden', !isMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setupControls = (): void => {
|
||||||
|
const {
|
||||||
|
playPause, muteBtn, volumePanel, volumeRange,
|
||||||
|
backwardBtn, forwardBtn, fullscreenBtn, skipSegmentBtn,
|
||||||
|
} = getControls()
|
||||||
|
|
||||||
|
playPause?.addEventListener('click', () => { togglePlayPause(); showControls() })
|
||||||
|
state.video.addEventListener('click', () => { togglePlayPause(); showControls() })
|
||||||
|
|
||||||
|
muteBtn?.addEventListener('click', () => { toggleMute(); showControls() })
|
||||||
|
|
||||||
|
volumeRange?.addEventListener('input', () => {
|
||||||
|
const value = Number(volumeRange.value) / 100
|
||||||
|
setVolume(value)
|
||||||
|
showControls()
|
||||||
|
})
|
||||||
|
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'))
|
||||||
|
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'))
|
||||||
|
|
||||||
|
backwardBtn?.addEventListener('click', () => seekBy(-10))
|
||||||
|
forwardBtn?.addEventListener('click', () => seekBy(10))
|
||||||
|
|
||||||
|
fullscreenBtn?.addEventListener('click', () => { toggleFullscreen(); showControls() })
|
||||||
|
|
||||||
|
skipSegmentBtn?.addEventListener('click', () => {
|
||||||
|
if (!state.activeSkipSegment) return
|
||||||
|
state.video.currentTime = state.activeSkipSegment.end + 0.01
|
||||||
|
showControls()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
state.isFullscreen = !!document.fullscreenElement
|
||||||
|
state.container.classList.toggle('fullscreen', state.isFullscreen)
|
||||||
|
if (state.isFullscreen) showControls()
|
||||||
|
})
|
||||||
|
|
||||||
|
state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls() })
|
||||||
|
state.video.addEventListener('pause', () => { updatePlayPauseIcons(false); showControls() })
|
||||||
|
state.video.addEventListener('volumechange', syncVolumeUI)
|
||||||
|
|
||||||
|
state.container.addEventListener('mousemove', showControls)
|
||||||
|
|
||||||
|
updatePlayPauseIcons(false)
|
||||||
|
syncVolumeUI()
|
||||||
|
}
|
||||||
58
static/player/keyboard.ts
Normal file
58
static/player/keyboard.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { state } from './state'
|
||||||
|
import { displayTimeFromAbsolute, absoluteTimeFromDisplay, absoluteTimeFromRatio, getBounds } from './timeline'
|
||||||
|
import { showControls, toggleMute, togglePlayPause, toggleFullscreen, seekBy, setVolume, formatTime } from './controls'
|
||||||
|
|
||||||
|
export const setupKeyboard = (): void => {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||||
|
|
||||||
|
switch (e.code) {
|
||||||
|
case 'Space':
|
||||||
|
case 'KeyK':
|
||||||
|
e.preventDefault()
|
||||||
|
togglePlayPause()
|
||||||
|
showControls()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'KeyJ':
|
||||||
|
e.preventDefault()
|
||||||
|
seekBy(-10)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'KeyL':
|
||||||
|
e.preventDefault()
|
||||||
|
seekBy(10)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setVolume(state.video.volume + 0.05)
|
||||||
|
showControls()
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setVolume(state.video.volume - 0.05)
|
||||||
|
showControls()
|
||||||
|
break
|
||||||
|
case 'KeyM':
|
||||||
|
e.preventDefault()
|
||||||
|
toggleMute()
|
||||||
|
showControls()
|
||||||
|
break
|
||||||
|
case 'KeyF':
|
||||||
|
e.preventDefault()
|
||||||
|
toggleFullscreen()
|
||||||
|
showControls()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if (/^\d$/.test(e.key)) {
|
||||||
|
const b = getBounds()
|
||||||
|
if (b.duration > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10)
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
97
static/player/timeline.ts
Normal file
97
static/player/timeline.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { TimelineBounds } from './types'
|
||||||
|
import { state } from './state'
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }
|
||||||
|
|
||||||
|
export const timelineBounds = (): TimelineBounds => {
|
||||||
|
const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0
|
||||||
|
let start = 0
|
||||||
|
if (state.video.seekable.length > 0) {
|
||||||
|
const seekableStart = state.video.seekable.start(0)
|
||||||
|
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart
|
||||||
|
}
|
||||||
|
if (duration > start) {
|
||||||
|
return { start, end: duration, duration: duration - start }
|
||||||
|
}
|
||||||
|
if (state.video.seekable.length > 0) {
|
||||||
|
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1)
|
||||||
|
if (Number.isFinite(seekableEnd) && seekableEnd > start) {
|
||||||
|
return { start, end: seekableEnd, duration: seekableEnd - start }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: 0, end: duration, duration }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invalidateBounds = (): void => {
|
||||||
|
cachedBounds = timelineBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBounds = (): TimelineBounds => cachedBounds
|
||||||
|
|
||||||
|
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
|
||||||
|
const b = getBounds()
|
||||||
|
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0
|
||||||
|
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start
|
||||||
|
}
|
||||||
|
|
||||||
|
export const absoluteTimeFromDisplay = (displayTime: number): number => {
|
||||||
|
const b = getBounds()
|
||||||
|
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0
|
||||||
|
return b.start + Math.max(0, Math.min(b.duration, displayTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const absoluteTimeFromRatio = (ratio: number): number => {
|
||||||
|
const b = getBounds()
|
||||||
|
if (!Number.isFinite(ratio) || b.duration <= 0) return 0
|
||||||
|
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBufferedEnd = (): number => {
|
||||||
|
const currentTime = state.video.currentTime
|
||||||
|
let end = 0
|
||||||
|
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||||
|
if (state.video.buffered.start(i) <= currentTime && state.video.buffered.end(i) >= currentTime) {
|
||||||
|
end = state.video.buffered.end(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end === 0) {
|
||||||
|
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||||
|
if (state.video.buffered.end(i) > currentTime) {
|
||||||
|
end = Math.max(end, state.video.buffered.end(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateTimeline = (currentTime: number): void => {
|
||||||
|
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = getTimelineEls()
|
||||||
|
const b = getBounds()
|
||||||
|
|
||||||
|
if (b.duration <= 0) {
|
||||||
|
progress.style.width = '0%'
|
||||||
|
buffered.style.width = '0%'
|
||||||
|
scrubber.style.left = '0%'
|
||||||
|
timeDisplay.textContent = '00:00'
|
||||||
|
durationDisplay.textContent = '00:00'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100
|
||||||
|
progress.style.width = `${pct}%`
|
||||||
|
scrubber.style.left = `${pct}%`
|
||||||
|
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime))
|
||||||
|
durationDisplay.textContent = formatTime(b.duration)
|
||||||
|
|
||||||
|
const bufferedEnd = getBufferedEnd()
|
||||||
|
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100
|
||||||
|
buffered.style.width = `${bufferedPct}%`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user