From ae189fcd3e6e522b1a923f55a1113ca8533a3479 Mon Sep 17 00:00:00 2001 From: Swopper050 Date: Tue, 25 Nov 2025 07:04:50 +0100 Subject: [PATCH 1/2] WIP on media components --- ui/src/classes.ts | 12 + ui/src/components/Button.tsx | 1 + ui/src/components/media/AudioPlayer.tsx | 254 +++++++++++++++ ui/src/components/media/VideoPlayer.tsx | 409 ++++++++++++++++++++++++ ui/src/types.ts | 11 + 5 files changed, 687 insertions(+) create mode 100644 ui/src/classes.ts create mode 100644 ui/src/components/media/AudioPlayer.tsx create mode 100644 ui/src/components/media/VideoPlayer.tsx create mode 100644 ui/src/types.ts diff --git a/ui/src/classes.ts b/ui/src/classes.ts new file mode 100644 index 0000000..47ef377 --- /dev/null +++ b/ui/src/classes.ts @@ -0,0 +1,12 @@ +import { DaisyUIColor } from './types' + +export const rangeColorMap: Record = { + primary: 'range-primary', + secondary: 'range-secondary', + neutral: 'range-neutral', + accent: 'range-accent', + info: 'range-info', + success: 'range-success', + warning: 'range-warning', + error: 'range-error', +} diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx index 0012a20..47b6f89 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -18,6 +18,7 @@ type DaisyUIButtonStyle = | 'ghost' | 'link' | 'outline' + | 'circle' type DaisyUIButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' diff --git a/ui/src/components/media/AudioPlayer.tsx b/ui/src/components/media/AudioPlayer.tsx new file mode 100644 index 0000000..534f5d7 --- /dev/null +++ b/ui/src/components/media/AudioPlayer.tsx @@ -0,0 +1,254 @@ +import { + JSXElement, + createSignal, + onMount, + onCleanup, + For, + Show, +} from 'solid-js' +import { clsx } from 'clsx' +import { IconButton } from '../../components/Button' +import { DaisyUIColor } from '../../types' +import { rangeColorMap } from '../../classes' + +type AudioPlayerProps = { + src: string + color?: DaisyUIColor + minimal?: boolean + showRate?: boolean + withShortcuts?: boolean +} + +export function AudioPlayer(props: AudioPlayerProps): JSXElement { + let audioEl!: HTMLAudioElement + + const [ready, setReady] = createSignal(false) + const [playing, setPlaying] = createSignal(false) + const [dur, setDur] = createSignal(0) + const [t, setT] = createSignal(0) + const [rate, setRate] = createSignal(1) + + const getFiniteDuration = () => { + const d = audioEl?.duration + if (Number.isFinite(d) && d! > 0) return d! + + // Fallback: use seekable range end (often available on iOS PWA) + const s = audioEl?.seekable + if (s && s.length > 0) { + return s.end(s.length - 1) + } + + return 0 + } + + const refreshDuration = () => { + const d = getFiniteDuration() + if (d > 0) setDur(d) + } + + const formatSeconds = (s: number) => { + if (!Number.isFinite(s)) return '0:00' + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + .toString() + .padStart(2, '0') + return `${m}:${sec}` + } + + const toggle = async () => { + if (!ready()) return + + if (playing()) { + audioEl.pause() + return + } + + await audioEl.play() + } + + const seek = (val: number) => { + if (!ready()) return + const d = dur() || 0 + const clamped = Math.max(0, Math.min(d, val)) + audioEl.currentTime = clamped + setT(clamped) + } + + onMount(() => { + const onLoaded = () => { + refreshDuration() + setReady(true) + } + const onDuration = () => refreshDuration() + const onTime = () => { + setT(audioEl.currentTime || 0) + refreshDuration() + } + const onPlay = () => setPlaying(true) + const onPause = () => setPlaying(false) + const onRate = () => setRate(audioEl.playbackRate || 1) + const onKey = (e: KeyboardEvent) => { + if (!props.withShortcuts) return + + if (e.code === 'Space') { + e.preventDefault() + toggle() + } + if (e.code === 'ArrowRight') { + e.preventDefault() + seek(t() + 5) + } + if (e.code === 'ArrowLeft') { + e.preventDefault() + seek(t() - 5) + } + } + + audioEl.addEventListener('loadedmetadata', onLoaded) + audioEl.addEventListener('durationchange', onDuration) + audioEl.addEventListener('loadeddata', onDuration) + audioEl.addEventListener('canplay', onDuration) + audioEl.addEventListener('timeupdate', onTime) + audioEl.addEventListener('play', onPlay) + audioEl.addEventListener('pause', onPause) + audioEl.addEventListener('ratechange', onRate) + window.addEventListener('keydown', onKey) + + onCleanup(() => { + audioEl.removeEventListener('loadedmetadata', onLoaded) + audioEl.removeEventListener('durationchange', onDuration) + audioEl.removeEventListener('loadeddata', onDuration) + audioEl.removeEventListener('canplay', onDuration) + audioEl.removeEventListener('timeupdate', onTime) + audioEl.removeEventListener('play', onPlay) + audioEl.removeEventListener('pause', onPause) + audioEl.removeEventListener('ratechange', onRate) + window.removeEventListener('keydown', onKey) + }) + }) + + const safeDur = () => dur() || 0 + const remaining = () => Math.max(safeDur() - t(), 0) + + return ( +
+
+ ) +} + +function RateDropdown(props: { + rate: number + setRate: (r: number) => void +}): JSXElement { + return ( + + ) +} + diff --git a/ui/src/components/media/VideoPlayer.tsx b/ui/src/components/media/VideoPlayer.tsx new file mode 100644 index 0000000..f961d99 --- /dev/null +++ b/ui/src/components/media/VideoPlayer.tsx @@ -0,0 +1,409 @@ +import { JSXElement, createSignal, onMount, onCleanup } from 'solid-js' +import { clsx } from 'clsx' +import Hls from 'hls.js' +import { IconButton } from '../../components/Button' +import { DaisyUIColor } from '../../types' +import { rangeColorMap } from '../../classes' + +type VideoPlayerProps = { + src: string + color?: DaisyUIColor + poster?: string + showControls?: boolean + withShortcuts?: boolean +} + +export function VideoPlayer(props: VideoPlayerProps): JSXElement { + let videoEl!: HTMLVideoElement + let containerRef!: HTMLDivElement + + const [ready, setReady] = createSignal(false) + const [playing, setPlaying] = createSignal(false) + const [dur, setDur] = createSignal(0) + const [t, setT] = createSignal(0) + const [volume, setVolume] = createSignal(1) + const [muted, setMuted] = createSignal(false) + const [fullscreen, setFullscreen] = createSignal(false) + const [showControls, setShowControls] = createSignal(true) + const [buffered, setBuffered] = createSignal(0) + + let hideControlsTimeout: number | null = null + + const getFiniteDuration = () => { + const d = videoEl?.duration + if (Number.isFinite(d) && d! > 0) return d! + + const s = videoEl?.seekable + if (s && s.length > 0) { + return s.end(s.length - 1) + } + + return 0 + } + + const refreshDuration = () => { + const d = getFiniteDuration() + if (d > 0) setDur(d) + } + + const updateBuffered = () => { + if (!videoEl) return + const buf = videoEl.buffered + if (buf.length > 0) { + const end = buf.end(buf.length - 1) + setBuffered(end) + } + } + + const formatSeconds = (s: number) => { + if (!Number.isFinite(s)) return '0:00' + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = Math.floor(s % 60) + .toString() + .padStart(2, '0') + + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${sec}` + } + return `${m}:${sec}` + } + + const toggle = async () => { + if (!ready()) return + + if (playing()) { + videoEl.pause() + return + } + + await videoEl.play() + } + + const seek = (val: number) => { + if (!ready()) return + const d = dur() || 0 + const clamped = Math.max(0, Math.min(d, val)) + videoEl.currentTime = clamped + setT(clamped) + } + + const toggleMute = () => { + if (!videoEl) return + videoEl.muted = !videoEl.muted + setMuted(videoEl.muted) + } + + const changeVolume = (val: number) => { + if (!videoEl) return + const clamped = Math.max(0, Math.min(1, val)) + videoEl.volume = clamped + setVolume(clamped) + if (clamped > 0 && muted()) { + videoEl.muted = false + setMuted(false) + } + } + + const toggleFullscreen = async () => { + if (!containerRef) return + + if (!document.fullscreenElement) { + await containerRef.requestFullscreen() + setFullscreen(true) + } else { + await document.exitFullscreen() + setFullscreen(false) + } + } + + const scheduleHideControls = () => { + if (hideControlsTimeout) { + clearTimeout(hideControlsTimeout) + } + setShowControls(true) + if (playing()) { + hideControlsTimeout = window.setTimeout(() => { + setShowControls(false) + }, 1500) + } + } + + onMount(() => { + // Initialize HLS if supported + if (Hls.isSupported() && props.src.endsWith('.m3u8')) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + xhrSetup: (xhr, url) => { + // Intercept segment requests and route through auth endpoint + if (url.includes('/stream/') && url.includes('.ts')) { + // Extract the segment filename from the URL + const match = url.match(/\/stream\/([^?]+)/) + if (match) { + const segmentPath = decodeURIComponent(match[1]) + // Extract content_id from the playlist URL (props.src) + const contentIdMatch = props.src.match(/\/content\/(\d+)\/stream/) + if (contentIdMatch) { + const contentId = contentIdMatch[1] + // Rebuild URL to go through auth endpoint + xhr.open( + 'GET', + `/api/content/${contentId}/stream?path=${encodeURIComponent(segmentPath)}`, + true + ) + return + } + } + } + }, + }) + + hls.loadSource(props.src) + hls.attachMedia(videoEl) + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + refreshDuration() + setReady(true) + }) + + hls.on(Hls.Events.ERROR, (event, data) => { + if (data.fatal) { + console.error('Fatal HLS error:', data) + } + }) + + onCleanup(() => { + hls.destroy() + }) + } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { + // Native HLS support (Safari) + videoEl.src = props.src + } + + const onLoaded = () => { + refreshDuration() + setReady(true) + } + const onDuration = () => refreshDuration() + const onTime = () => { + setT(videoEl.currentTime || 0) + refreshDuration() + updateBuffered() + } + const onPlay = () => { + setPlaying(true) + scheduleHideControls() + } + const onPause = () => { + setPlaying(false) + setShowControls(true) + if (hideControlsTimeout) { + clearTimeout(hideControlsTimeout) + } + } + const onVolumeChange = () => { + setVolume(videoEl.volume) + setMuted(videoEl.muted) + } + const onProgress = () => updateBuffered() + + const onFullscreenChange = () => { + setFullscreen(!!document.fullscreenElement) + } + + const onKey = (e: KeyboardEvent) => { + if (!props.withShortcuts) return + + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return + } + + if (e.code === 'Space') { + e.preventDefault() + toggle() + } + if (e.code === 'ArrowRight') { + e.preventDefault() + seek(t() + 5) + } + if (e.code === 'ArrowLeft') { + e.preventDefault() + seek(t() - 5) + } + if (e.code === 'KeyF') { + e.preventDefault() + toggleFullscreen() + } + if (e.code === 'KeyM') { + e.preventDefault() + toggleMute() + } + if (e.code === 'ArrowUp') { + e.preventDefault() + changeVolume(volume() + 0.1) + } + if (e.code === 'ArrowDown') { + e.preventDefault() + changeVolume(volume() - 0.1) + } + } + + const onMouseMove = () => { + scheduleHideControls() + } + + videoEl.addEventListener('loadedmetadata', onLoaded) + videoEl.addEventListener('durationchange', onDuration) + videoEl.addEventListener('loadeddata', onDuration) + videoEl.addEventListener('canplay', onDuration) + videoEl.addEventListener('timeupdate', onTime) + videoEl.addEventListener('play', onPlay) + videoEl.addEventListener('pause', onPause) + videoEl.addEventListener('volumechange', onVolumeChange) + videoEl.addEventListener('progress', onProgress) + document.addEventListener('fullscreenchange', onFullscreenChange) + window.addEventListener('keydown', onKey) + containerRef.addEventListener('mousemove', onMouseMove) + + onCleanup(() => { + if (hideControlsTimeout) { + clearTimeout(hideControlsTimeout) + } + videoEl.removeEventListener('loadedmetadata', onLoaded) + videoEl.removeEventListener('durationchange', onDuration) + videoEl.removeEventListener('loadeddata', onDuration) + videoEl.removeEventListener('canplay', onDuration) + videoEl.removeEventListener('timeupdate', onTime) + videoEl.removeEventListener('play', onPlay) + videoEl.removeEventListener('pause', onPause) + videoEl.removeEventListener('volumechange', onVolumeChange) + videoEl.removeEventListener('progress', onProgress) + document.removeEventListener('fullscreenchange', onFullscreenChange) + window.removeEventListener('keydown', onKey) + containerRef.removeEventListener('mousemove', onMouseMove) + }) + }) + + const safeDur = () => dur() || 0 + const bufferPercentage = () => { + const d = safeDur() + return d > 0 ? (buffered() / d) * 100 : 0 + } + + const getVolumeIcon = () => { + if (muted() || volume() === 0) return 'fa-solid fa-volume-xmark' + if (volume() < 0.5) return 'fa-solid fa-volume-low' + return 'fa-solid fa-volume-high' + } + + return ( +
+