From ff1579345be8f0f1d1635edd0e4e9348504dd096 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 10 May 2026 18:34:21 +0200 Subject: [PATCH] feat: extract player controls timeline keyboard --- static/player/controls.ts | 169 ++++++++++++++++++++++++++++++++++++++ static/player/keyboard.ts | 58 +++++++++++++ static/player/timeline.ts | 97 ++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 static/player/controls.ts create mode 100644 static/player/keyboard.ts create mode 100644 static/player/timeline.ts diff --git a/static/player/controls.ts b/static/player/controls.ts new file mode 100644 index 0000000..03ba4ab --- /dev/null +++ b/static/player/controls.ts @@ -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() +} diff --git a/static/player/keyboard.ts b/static/player/keyboard.ts new file mode 100644 index 0000000..ebdcf71 --- /dev/null +++ b/static/player/keyboard.ts @@ -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() + } + } + } + }) +} diff --git a/static/player/timeline.ts b/static/player/timeline.ts new file mode 100644 index 0000000..55ad9c1 --- /dev/null +++ b/static/player/timeline.ts @@ -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}%` +}