From 74e2aa50fdbf16177348cdfb4e2c98c0940d1a12 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 15 May 2026 01:39:29 +0200 Subject: [PATCH] feat: improve video seeking and range request handling --- internal/playback/handler/handler.go | 6 +++ static/player/main.ts | 32 ++++++++---- static/player/timeline.ts | 63 ++++++++++++++++++------ templates/components/video_player.gohtml | 4 +- 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index e9f1387..c5b9376 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -259,6 +259,12 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) { if referer != "" { req.Header.Set("Referer", referer) } + if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" { + req.Header.Set("If-Range", ifRangeHeader) + } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0") resp, err := h.streamingClient.Do(req) diff --git a/static/player/main.ts b/static/player/main.ts index aac57ed..ca3eac0 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -17,14 +17,16 @@ import { formatTime } from './controls'; let initialized = false; // prevent double init on htmx swaps const hidePreviewPopover = (): void => { - state.previewPopover?.classList.remove('block'); - state.previewPopover?.classList.add('hidden'); - state.previewPopover!.style.left = '0px'; + if (!state.previewPopover) return; + state.previewPopover.classList.add('opacity-0'); + state.previewPopover.classList.remove('opacity-100'); + state.previewPopover.style.left = '0px'; }; const showPreviewPopover = (): void => { - state.previewPopover?.classList.remove('hidden'); - state.previewPopover?.classList.add('block'); + if (!state.previewPopover) return; + state.previewPopover.classList.remove('opacity-0'); + state.previewPopover.classList.add('opacity-100'); }; // updates time preview on progress bar hover @@ -141,9 +143,14 @@ const initPlayer = (): void => { goToNextEpisode(); }); - // click to seek - progressWrap?.addEventListener('mousedown', e => { + // click/drag to seek (pointer events are more consistent across fullscreen/mobile) + progressWrap?.addEventListener('pointerdown', e => { + // ignore right/middle click + if ('button' in e && e.button !== 0) return; state.isScrubbing = true; + try { + (e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId); + } catch {} const rect = progressWrap.getBoundingClientRect(); state.video.currentTime = absoluteTimeFromRatio( Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) @@ -154,15 +161,20 @@ const initPlayer = (): void => { }); // hover to preview time - progressWrap?.addEventListener('mousemove', e => { + progressWrap?.addEventListener('pointermove', e => { const rect = progressWrap.getBoundingClientRect(); updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); }); - progressWrap?.addEventListener('mouseleave', hidePreviewPopover); + progressWrap?.addEventListener('pointerleave', hidePreviewPopover); + progressWrap?.addEventListener('pointerup', () => { + // ensure we finish the seek even if no window mousemove fired + if (!progressWrap) return; + state.isScrubbing = false; + }); // dragging outside progress bar while scrubbing - window.addEventListener('mousemove', e => { + window.addEventListener('pointermove', e => { if (!state.isScrubbing || !progressWrap) return; const rect = progressWrap.getBoundingClientRect(); state.video.currentTime = absoluteTimeFromRatio( diff --git a/static/player/timeline.ts b/static/player/timeline.ts index 0cf57ab..29002c8 100644 --- a/static/player/timeline.ts +++ b/static/player/timeline.ts @@ -11,38 +11,71 @@ const formatTime = (seconds: number): string => { // cached to avoid recalc on every timeupdate let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }; +let cachedDuration = 0; +let cachedSeekableEnd = 0; + +const getDuration = (): number => + Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0; + +const getSeekableEnd = (): number => { + const ranges = state.video.seekable; + if (!ranges || ranges.length <= 0) return 0; + const end = ranges.end(ranges.length - 1); + return Number.isFinite(end) && end > 0 ? end : 0; +}; /** * Computes timeline bounds from video. - * Handles seekable ranges (live streams) and regular duration. + * Uses the full duration for VOD, and seekable ranges only when duration is unavailable. */ export const timelineBounds = (): TimelineBounds => { - const duration = - Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0; - let start = 0; - // check seekable range for live streams + const duration = getDuration(); + if (duration > 0) { + return { start: 0, end: duration, duration }; + } + 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 }; - } - // fallback to full seekable range - 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 }; + if ( + Number.isFinite(seekableStart) && + Number.isFinite(seekableEnd) && + seekableEnd > seekableStart + ) { + return { + start: seekableStart, + end: seekableEnd, + duration: seekableEnd - seekableStart, + }; } } + return { start: 0, end: duration, duration }; }; export const invalidateBounds = (): void => { cachedBounds = timelineBounds(); + cachedDuration = getDuration(); + cachedSeekableEnd = getSeekableEnd(); }; -export const getBounds = (): TimelineBounds => cachedBounds; +export const getBounds = (): TimelineBounds => { + // lazy init + refresh: some streams expand seekable range over time (often tracks buffered end). + // If we keep stale bounds, seeking past "watched/buffered" can map to only a fraction of the real timeline. + const duration = getDuration(); + const seekableEnd = getSeekableEnd(); + if ( + !cachedBounds || + cachedBounds.duration <= 0 || + duration !== cachedDuration || + seekableEnd !== cachedSeekableEnd + ) { + cachedBounds = timelineBounds(); + cachedDuration = duration; + cachedSeekableEnd = seekableEnd; + } + return cachedBounds; +}; // converts video.currentTime to timeline-relative time (0-based for UI display) export const displayTimeFromAbsolute = (absoluteTime: number): number => { diff --git a/templates/components/video_player.gohtml b/templates/components/video_player.gohtml index 7f792e2..128c230 100644 --- a/templates/components/video_player.gohtml +++ b/templates/components/video_player.gohtml @@ -40,7 +40,7 @@
-
+
@@ -142,4 +142,4 @@
-{{end}} \ No newline at end of file +{{end}}