feat: improve video seeking and range request handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div data-video-overlay class="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/90 via-black/40 to-transparent p-4 pt-16 transition-opacity duration-300 opacity-0 group-hover:opacity-100 z-40">
|
||||
<div data-video-overlay class="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/90 via-black/40 to-transparent p-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100 z-40">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -142,4 +142,4 @@
|
||||
</div>
|
||||
<div data-subtitle-text class="absolute bottom-20 left-0 right-0 text-center pointer-events-none drop-shadow-md z-30" style="text-shadow: 0px 0px 4px black, 0px 0px 8px black; font-size: clamp(1rem, 2.5vw, 2rem); font-weight: 600; color: white;"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user