From 691591d31ce8ae877ad5a5f19c20db06695e97c2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 21 Jul 2026 17:21:01 +0200 Subject: [PATCH 1/9] fix: support progressive subscription feed pagination --- apps/web/src/hooks/use-subscription-feed.ts | 21 ++++++-- apps/web/src/lib/api-user.ts | 53 +++++++++++++++++-- apps/web/src/routes/subscriptions.tsx | 11 ++-- .../src/routes/subscriptions_.channels.tsx | 11 ++-- apps/web/src/types/api.ts | 3 ++ 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/apps/web/src/hooks/use-subscription-feed.ts b/apps/web/src/hooks/use-subscription-feed.ts index 9c7d288..c4e1cc5 100644 --- a/apps/web/src/hooks/use-subscription-feed.ts +++ b/apps/web/src/hooks/use-subscription-feed.ts @@ -1,5 +1,6 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { ApiError } from "../lib/api"; import { fetchSubscriptionFeed } from "../lib/api-user"; import { mapVideoItem } from "../lib/mappers"; import { proxyImage } from "../lib/proxy"; @@ -20,6 +21,7 @@ type Result = { export function useSubscriptionFeed(): Result { const { authReady, isAuthed } = useAuth(); const { query: subsQuery } = useSubscriptions(); + const queryClient = useQueryClient(); const avatarMap = useMemo( () => new Map((subsQuery.data ?? []).map((s) => [s.channelUrl, proxyImage(s.avatarUrl)])), [subsQuery.data], @@ -27,13 +29,22 @@ export function useSubscriptionFeed(): Result { const query = useInfiniteQuery({ queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number), - initialPageParam: 0, - getNextPageParam: (last, pages) => (last.nextpage !== null ? pages.length : undefined), + queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextpage ?? undefined, staleTime: 5 * 60 * 1000, enabled: authReady && isAuthed, }); + useEffect(() => { + if ( + query.error instanceof ApiError && + query.error.code === "subscription_feed_stale_generation" + ) { + void queryClient.resetQueries({ queryKey: SUBSCRIPTION_FEED_KEY, exact: true }); + } + }, [query.error, queryClient]); + const streams = useMemo( () => (query.data?.pages ?? []) diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts index b8254d7..afb3cc9 100644 --- a/apps/web/src/lib/api-user.ts +++ b/apps/web/src/lib/api-user.ts @@ -133,9 +133,56 @@ export async function clearSearchHistory(): Promise { await authed(`${BASE}/search-history`, { method: "DELETE" }); } -export async function fetchSubscriptionFeed(page: number): Promise { - const search = new URLSearchParams({ page: String(page), limit: "30" }); - return authedJson(`${BASE}/subscriptions/feed?${search.toString()}`); +export async function fetchSubscriptionFeed( + cursor: string | null = null, + signal?: AbortSignal, +): Promise { + const search = new URLSearchParams({ limit: "30" }); + if (cursor !== null) search.set("cursor", cursor); + const url = `${BASE}/subscriptions/feed?${search.toString()}`; + while (true) { + const res = await authed(url, { signal }); + const body = normalizeApiPayload(await res.json()); + if (res.status === 202 && isSubscriptionFeedPreparing(body)) { + await waitForSubscriptionFeed(body.retryAfterMs, signal); + continue; + } + if (!res.ok) { + const error = body as { code?: string; error?: string }; + throw new ApiError( + error.error ?? "Subscription feed request failed", + res.status, + error.code ?? null, + ); + } + return body as SubscriptionFeedPage; + } +} + +function isSubscriptionFeedPreparing( + body: unknown, +): body is { code: string; retryAfterMs: number } { + if (typeof body !== "object" || body === null) return false; + const preparing = body as { code?: unknown; retryAfterMs?: unknown }; + return ( + preparing.code === "subscription_feed_preparing" && typeof preparing.retryAfterMs === "number" + ); +} + +function waitForSubscriptionFeed(retryAfterMs: number, signal?: AbortSignal): Promise { + const delayMs = Math.min(Math.max(retryAfterMs, 100), 5_000); + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); } export async function fetchSubscriptionShorts( diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index 81cf2dc..6c6c89a 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -14,11 +14,8 @@ import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; -function nextSubscriptionPage( - last: Awaited>, - pages: unknown[], -) { - return last.nextpage !== null ? pages.length : undefined; +function nextSubscriptionPage(last: Awaited>) { + return last.nextpage ?? undefined; } function SubscriptionsPage() { @@ -42,8 +39,8 @@ function SubscriptionsPage() { function prefetchVideos() { void queryClient.prefetchInfiniteQuery({ queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number), - initialPageParam: 0, + queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal), + initialPageParam: null as string | null, getNextPageParam: nextSubscriptionPage, staleTime: SUBSCRIPTION_STALE_MS, }); diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx index 9679b7f..7432092 100644 --- a/apps/web/src/routes/subscriptions_.channels.tsx +++ b/apps/web/src/routes/subscriptions_.channels.tsx @@ -9,11 +9,8 @@ import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; -function nextSubscriptionPage( - last: Awaited>, - pages: unknown[], -) { - return last.nextpage !== null ? pages.length : undefined; +function nextSubscriptionPage(last: Awaited>) { + return last.nextpage ?? undefined; } function SubscriptionChannelsPage() { @@ -32,8 +29,8 @@ function SubscriptionChannelsPage() { function prefetchVideos() { void queryClient.prefetchInfiniteQuery({ queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number), - initialPageParam: 0, + queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal), + initialPageParam: null as string | null, getNextPageParam: nextSubscriptionPage, staleTime: SUBSCRIPTION_STALE_MS, }); diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 538bbf3..3199853 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -122,6 +122,9 @@ export type HomeRecommendationsResponse = { export type SubscriptionFeedPage = { videos: VideoItem[]; nextpage: string | null; + generation?: number | null; + generatedAt?: number | null; + refreshing?: boolean; }; export type CommentItem = { From 937646998831eea7c67b64319541b8b53e293893 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 21 Jul 2026 19:01:56 +0200 Subject: [PATCH 2/9] chore: update MSE player to 0.1.36 --- apps/web/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index d80cba6..dd6508e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.35", + "@typetype/mse": "0.1.36", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index bc9bf53..32e3652 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.35", + "@typetype/mse": "0.1.36", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", @@ -311,7 +311,7 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typetype/mse": ["@typetype/mse@0.1.35", "", {}, "sha512-MR8JMaBTOQeF2Ci5/Hvp0yTQ3Tqyjo7u0w00hyOe+RJn5RuPnpY7+8IkgM07u0QExziYXnFvdcGOtBpk0NFFwA=="], + "@typetype/mse": ["@typetype/mse@0.1.36", "", {}, "sha512-fNRIxkF0li63O8NrN/HwUPvUWqZhLG7w799zARbOfFCAmEKZznNjqWqptiKEWIBhJiuH3c7fC9UicS82NjoOOA=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 081f749a6ec1e6feae30e8e802f84ff81f7ebf88 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 09:21:57 +0200 Subject: [PATCH 3/9] fix: ignore temporary player volume changes --- apps/web/src/components/sabr-mse-player.tsx | 5 +++- apps/web/src/hooks/use-volume-sync.ts | 30 ++++++++++++++++----- apps/web/tests/volume-sync.test.ts | 24 +++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 apps/web/tests/volume-sync.test.ts diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 1ad036d..bb9769b 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -98,7 +98,10 @@ export function SabrMsePlayer({ const offError = engine.on("error", (event) => { if (event.type === "error") reportError(event.error, event.recoveryPositionMs); }); - const volumeChange = () => latestHandlers().onVolumeChange?.(video.volume, video.muted); + const volumeChange = () => { + if (engine.isApplyingTransientMediaState()) return; + latestHandlers().onVolumeChange?.(video.volume, video.muted); + }; video.addEventListener("volumechange", volumeChange); let autoplayStartTime = 0; let engineLoaded = false; diff --git a/apps/web/src/hooks/use-volume-sync.ts b/apps/web/src/hooks/use-volume-sync.ts index 7f57c58..dd0eff0 100644 --- a/apps/web/src/hooks/use-volume-sync.ts +++ b/apps/web/src/hooks/use-volume-sync.ts @@ -1,17 +1,35 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { SettingsItem } from "../types/user"; type MutateFn = (patch: Partial) => void; +export function createDebouncedVolumeSync(mutate: MutateFn, delayMs = 1000) { + let timer: ReturnType | null = null; + return { + schedule(volume: number, muted: boolean) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + mutate({ volume, muted }); + }, delayMs); + }, + cancel() { + if (timer) clearTimeout(timer); + timer = null; + }, + }; +} + export function useVolumeSync(mutate: MutateFn): (volume: number, muted: boolean) => void { const mutateRef = useRef(mutate); mutateRef.current = mutate; - const timerRef = useRef | null>(null); + const syncRef = useRef | null>(null); + if (!syncRef.current) { + syncRef.current = createDebouncedVolumeSync((patch) => mutateRef.current(patch)); + } + useEffect(() => () => syncRef.current?.cancel(), []); return useCallback((volume: number, muted: boolean) => { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - mutateRef.current({ volume, muted }); - }, 1000); + syncRef.current?.schedule(volume, muted); }, []); } diff --git a/apps/web/tests/volume-sync.test.ts b/apps/web/tests/volume-sync.test.ts new file mode 100644 index 0000000..5f41d99 --- /dev/null +++ b/apps/web/tests/volume-sync.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { createDebouncedVolumeSync } from "../src/hooks/use-volume-sync"; + +test("coalesces volume changes to the latest values", async () => { + const updates: unknown[] = []; + const sync = createDebouncedVolumeSync((patch) => updates.push(patch), 5); + + sync.schedule(0.8, false); + sync.schedule(0.2, true); + await Bun.sleep(15); + + expect(updates).toEqual([{ volume: 0.2, muted: true }]); +}); + +test("cancels pending persistence during player teardown", async () => { + const updates: unknown[] = []; + const sync = createDebouncedVolumeSync((patch) => updates.push(patch), 5); + + sync.schedule(1, true); + sync.cancel(); + await Bun.sleep(15); + + expect(updates).toEqual([]); +}); From a553b338ebed7d56b209addead428d525caee34d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 09:21:57 +0200 Subject: [PATCH 4/9] fix: use active MSE runtime capabilities --- apps/web/src/lib/sabr-source.ts | 11 +++++--- apps/web/tests/sabr-audio-selection.test.ts | 27 ++++++++++++++++--- apps/web/tests/sabr-mse-support.test.ts | 29 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 apps/web/tests/sabr-mse-support.test.ts diff --git a/apps/web/src/lib/sabr-source.ts b/apps/web/src/lib/sabr-source.ts index 43211e2..b8fbdeb 100644 --- a/apps/web/src/lib/sabr-source.ts +++ b/apps/web/src/lib/sabr-source.ts @@ -1,3 +1,4 @@ +import { isMseTypeSupported } from "@typetype/mse"; import type { SabrQualityOption } from "../stores/sabr-quality-store"; import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import type { VideoStream } from "../types/stream"; @@ -27,13 +28,15 @@ function isSabrCandidate(item: SabrCandidate): boolean { function playableVideos(stream: VideoStream): VideoStreamItem[] { const videos = [...(stream.videoOnlyStreams ?? []), ...(stream.videoStreams ?? [])]; - return videos.filter((video) => isSabrCandidate(video) && supportedVideo(video)); + return videos.filter((video) => isSabrCandidate(video) && isSabrVideoSupported(video)); } -function supportedVideo(video: VideoStreamItem): boolean { +export function isSabrVideoSupported( + video: VideoStreamItem, + supportsType = isMseTypeSupported, +): boolean { if (!video.codec || !codecFamily(video.codec)) return false; - if (typeof MediaSource === "undefined") return true; - return MediaSource.isTypeSupported(`${video.mimeType}; codecs="${video.codec}"`); + return supportsType(`${video.mimeType}; codecs="${video.codec}"`); } function qualityLabel(video: VideoStreamItem): string { diff --git a/apps/web/tests/sabr-audio-selection.test.ts b/apps/web/tests/sabr-audio-selection.test.ts index 78b3ecb..ff5bc0b 100644 --- a/apps/web/tests/sabr-audio-selection.test.ts +++ b/apps/web/tests/sabr-audio-selection.test.ts @@ -56,16 +56,37 @@ test("exposes backend audio names without generic duplicate labels", () => { test("matches the preferred language and switches the sabr track id", () => { expect(defaultSabrAudioTrackId(stream, "fr-FR")).toBe("fr-FR.1"); - expect(resolveSabrPlaybackConfig(stream, 137, "fr-FR.1")?.audioTrackId).toBe("fr-FR.1"); - const audioOnly = resolveSabrPlaybackConfig(stream, 137, "fr-FR.1", true); + const config = withManagedMediaSource(() => resolveSabrPlaybackConfig(stream, 137, "fr-FR.1")); + expect(config?.audioTrackId).toBe("fr-FR.1"); + const audioOnly = withManagedMediaSource(() => + resolveSabrPlaybackConfig(stream, 137, "fr-FR.1", true), + ); expect(audioOnly?.audioOnly).toBe(true); expect(audioOnly?.key.endsWith(":audio")).toBe(true); }); test("marks live playback sessions for the MSE engine", () => { const live = { ...stream, isLive: true } as VideoStream; - const config = resolveSabrPlaybackConfig(live, 137, "en-US.4"); + const config = withManagedMediaSource(() => resolveSabrPlaybackConfig(live, 137, "en-US.4")); expect(config?.isLive).toBe(true); expect(config?.key).toContain(":live:"); }); + +function withManagedMediaSource(work: () => T): T { + const scope = globalThis as typeof globalThis & { ManagedMediaSource?: unknown }; + const original = Object.getOwnPropertyDescriptor(scope, "ManagedMediaSource"); + const FakeManagedMediaSource = Object.assign(function FakeManagedMediaSource() {}, { + isTypeSupported: () => true, + }); + Object.defineProperty(scope, "ManagedMediaSource", { + configurable: true, + value: FakeManagedMediaSource, + }); + try { + return work(); + } finally { + if (original) Object.defineProperty(scope, "ManagedMediaSource", original); + else delete scope.ManagedMediaSource; + } +} diff --git a/apps/web/tests/sabr-mse-support.test.ts b/apps/web/tests/sabr-mse-support.test.ts new file mode 100644 index 0000000..8c492e6 --- /dev/null +++ b/apps/web/tests/sabr-mse-support.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { isSabrVideoSupported } from "../src/lib/sabr-source"; +import type { VideoStreamItem } from "../src/types/api"; + +const video = { + codec: "avc1.640028", + mimeType: "video/mp4", +} as VideoStreamItem; + +test("accepts a SABR codec supported by the active MSE runtime", () => { + expect(isSabrVideoSupported(video, (mime) => mime.includes("avc1.640028"))).toBe(true); +}); + +test("rejects a SABR codec unsupported by the active MSE runtime", () => { + expect(isSabrVideoSupported(video, () => false)).toBe(false); +}); + +test("rejects unknown codec families before probing MSE", () => { + let probes = 0; + const unknown = { ...video, codec: "unknown" }; + + expect( + isSabrVideoSupported(unknown, () => { + probes += 1; + return true; + }), + ).toBe(false); + expect(probes).toBe(0); +}); From b23b44558c9e8eb253cf1e3528c0f365015b937e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 09:21:57 +0200 Subject: [PATCH 5/9] chore: update MSE player to 0.1.37 --- apps/web/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index dd6508e..1fbba82 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.36", + "@typetype/mse": "0.1.37", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index 32e3652..f007437 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.36", + "@typetype/mse": "0.1.37", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", @@ -311,7 +311,7 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typetype/mse": ["@typetype/mse@0.1.36", "", {}, "sha512-fNRIxkF0li63O8NrN/HwUPvUWqZhLG7w799zARbOfFCAmEKZznNjqWqptiKEWIBhJiuH3c7fC9UicS82NjoOOA=="], + "@typetype/mse": ["@typetype/mse@0.1.37", "", {}, "sha512-pcVfS434Vs2Fjvuyn2dBUXUc0X/Zf3tsHB0ygJ1Nlqo2DuDaOcXwaB+XVVPFV5tZ7orUNBiyYl/YDY9cv810Eg=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From d9c4f8aca487d4550bb2791419bda28469164ba5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 11:46:32 +0200 Subject: [PATCH 6/9] fix: wait for saved progress before playback --- apps/web/src/lib/watch-resume.ts | 17 ++++++++++++ apps/web/src/routes/watch.tsx | 20 +++++++++----- apps/web/tests/watch-resume.test.ts | 43 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/watch-resume.ts create mode 100644 apps/web/tests/watch-resume.test.ts diff --git a/apps/web/src/lib/watch-resume.ts b/apps/web/src/lib/watch-resume.ts new file mode 100644 index 0000000..08b7579 --- /dev/null +++ b/apps/web/src/lib/watch-resume.ts @@ -0,0 +1,17 @@ +type WatchResumeInput = { + authenticated: boolean; + progressPending: boolean; + savedPositionMs?: number; + serverPositionSeconds?: number; + durationSeconds: number; +}; + +export function resolveWatchStartTime(input: WatchResumeInput): number | null { + if (input.authenticated && input.progressPending) return null; + + const savedPositionMs = input.savedPositionMs ?? 0; + const serverPositionMs = (input.serverPositionSeconds ?? 0) * 1000; + const resumeMs = savedPositionMs > 0 ? savedPositionMs : serverPositionMs; + const durationMs = input.durationSeconds * 1000; + return resumeMs >= 5000 && resumeMs < durationMs * 0.95 ? resumeMs : 0; +} diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 8c0bfc2..2208ee6 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -14,6 +14,7 @@ import { useSabrBootstrap, useStream } from "../hooks/use-stream"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; import { videoAvailabilityCopy } from "../lib/video-availability"; +import { resolveWatchStartTime } from "../lib/watch-resume"; import { toPublicWatchParam, toWatchSourceUrl, youtubeThumbnailUrl } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; @@ -69,6 +70,7 @@ function WatchPage() { const addToHistoryRef = useRef(add.mutate); addToHistoryRef.current = add.mutate; const historyAddedForRef = useRef(null); + const resumePending = isAuthed && progressFetch.isPending; useEffect(() => { if (v.trim() && publicParam !== v.trim()) { @@ -77,7 +79,7 @@ function WatchPage() { }, [navigate, publicParam, v]); useEffect(() => { - if (!activeStream) return; + if (!activeStream || resumePending) return; if (historyAddedForRef.current === activeStream.id) return; const historyPositionMs = progressFetch.data?.position ?? (activeStream.startPosition ?? 0) * 1000; @@ -95,9 +97,10 @@ function WatchPage() { viewCount: activeStream.views, progress, }); - }, [activeStream, progressFetch.data?.position]); + }, [activeStream, progressFetch.data?.position, resumePending]); const pending = streamQuery.isLoading || bootstrap.isLoading; + if (resumePending) return loadingPage; if (!activeStream && (!streamEnabled || pending)) return loadingPage; if (!activeStream) { @@ -127,11 +130,14 @@ function WatchPage() { ); } - const savedPosition = progressFetch.data?.position ?? 0; - const serverPositionMs = (activeStream.startPosition ?? 0) * 1000; - const resumeMs = savedPosition > 0 ? savedPosition : serverPositionMs; - const durationMs = activeStream.duration * 1000; - const startTime = resumeMs >= 5000 && resumeMs < durationMs * 0.95 ? resumeMs : 0; + const startTime = + resolveWatchStartTime({ + authenticated: isAuthed, + progressPending: progressFetch.isPending, + savedPositionMs: progressFetch.data?.position, + serverPositionSeconds: activeStream.startPosition, + durationSeconds: activeStream.duration, + }) ?? 0; const navigating = toPublicWatchParam(activeStream.id) !== publicParam; return ( diff --git a/apps/web/tests/watch-resume.test.ts b/apps/web/tests/watch-resume.test.ts new file mode 100644 index 0000000..5ed49ee --- /dev/null +++ b/apps/web/tests/watch-resume.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { resolveWatchStartTime } from "../src/lib/watch-resume"; + +test("waits for authenticated progress before choosing the initial position", () => { + expect( + resolveWatchStartTime({ + authenticated: true, + progressPending: true, + durationSeconds: 600, + }), + ).toBeNull(); +}); + +test("uses saved progress once it is available", () => { + expect( + resolveWatchStartTime({ + authenticated: true, + progressPending: false, + savedPositionMs: 125_000, + serverPositionSeconds: 40, + durationSeconds: 600, + }), + ).toBe(125_000); +}); + +test("does not delay guests and rejects positions too close to the end", () => { + expect( + resolveWatchStartTime({ + authenticated: false, + progressPending: true, + serverPositionSeconds: 40, + durationSeconds: 600, + }), + ).toBe(40_000); + expect( + resolveWatchStartTime({ + authenticated: true, + progressPending: false, + savedPositionMs: 580_000, + durationSeconds: 600, + }), + ).toBe(0); +}); From d334cbfbec6406e85f1399bccc9489282f77a5b2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 11:46:32 +0200 Subject: [PATCH 7/9] fix: forward repeated SABR seeks immediately --- apps/web/src/lib/sabr-player-seek.ts | 57 ++++--------------------- apps/web/tests/sabr-player-seek.test.ts | 9 ++-- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/sabr-player-seek.ts b/apps/web/src/lib/sabr-player-seek.ts index 0c8220a..2b3026c 100644 --- a/apps/web/src/lib/sabr-player-seek.ts +++ b/apps/web/src/lib/sabr-player-seek.ts @@ -3,16 +3,7 @@ import { isAbortError } from "./sabr-playback-retry"; type SeekFlag = { current: boolean }; -type PendingSeek = { - player: TypeTypeMsePlayer; - position: number; - onError: (error: unknown) => void; - onSeekingChange?: (seeking: boolean) => void; - timer: ReturnType; -}; - -const pendingSeeks = new WeakMap(); -const SEEK_RETRY_MS = 100; +const seekRevisions = new WeakMap(); export function positionMs(video: HTMLVideoElement): number { return Math.max(0, Math.round(video.currentTime * 1000)); @@ -47,54 +38,24 @@ export function runSabrSeek( onSeekingChange?: (seeking: boolean) => void, ) { if (!player) return; - if (flag.current) { - queueSabrSeek(player, position, flag, onError, onSeekingChange); - return; + const revision = (seekRevisions.get(flag) ?? 0) + 1; + seekRevisions.set(flag, revision); + if (!flag.current) { + flag.current = true; + onSeekingChange?.(true); } - cancelPendingSabrSeek(flag); - flag.current = true; - onSeekingChange?.(true); void player .seek(position) .catch((error: unknown) => { - if (!isAbortError(error)) onError(error); + if (seekRevisions.get(flag) === revision && !isAbortError(error)) onError(error); }) .finally(() => { + if (seekRevisions.get(flag) !== revision) return; flag.current = false; onSeekingChange?.(false); }); } -function queueSabrSeek( - player: TypeTypeMsePlayer, - position: number, - flag: SeekFlag, - onError: (error: unknown) => void, - onSeekingChange?: (seeking: boolean) => void, -): void { - cancelPendingSabrSeek(flag); - const retry = () => { - const pending = pendingSeeks.get(flag); - if (!pending) return; - if (flag.current) { - pending.timer = setTimeout(retry, SEEK_RETRY_MS); - return; - } - pendingSeeks.delete(flag); - runSabrSeek(pending.player, pending.position, flag, pending.onError, pending.onSeekingChange); - }; - pendingSeeks.set(flag, { - player, - position, - onError, - onSeekingChange, - timer: setTimeout(retry, SEEK_RETRY_MS), - }); -} - export function cancelPendingSabrSeek(flag: SeekFlag): void { - const pending = pendingSeeks.get(flag); - if (!pending) return; - clearTimeout(pending.timer); - pendingSeeks.delete(flag); + seekRevisions.set(flag, (seekRevisions.get(flag) ?? 0) + 1); } diff --git a/apps/web/tests/sabr-player-seek.test.ts b/apps/web/tests/sabr-player-seek.test.ts index 3724514..8169f25 100644 --- a/apps/web/tests/sabr-player-seek.test.ts +++ b/apps/web/tests/sabr-player-seek.test.ts @@ -41,7 +41,7 @@ test("falls back to duration when the media has no native seekable window", () = expect(secondsFromMediaSliderPercent(media, 25)).toBe(150); }); -test("queues the latest sabr seek until the active seek completes", async () => { +test("forwards repeated SABR seeks immediately and tracks only the latest", async () => { const finishSeeks: Array<() => void> = []; const positions: number[] = []; const states: boolean[] = []; @@ -70,23 +70,22 @@ test("queues the latest sabr seek until the active seek completes", async () => (state) => states.push(state), ); - expect(positions).toEqual([60_000]); + expect(positions).toEqual([60_000, 120_000]); expect(flag.current).toBe(true); expect(states).toEqual([true]); finishSeeks.shift()?.(); - await Bun.sleep(110); await Promise.resolve(); await Promise.resolve(); expect(positions).toEqual([60_000, 120_000]); expect(flag.current).toBe(true); - expect(states).toEqual([true, false, true]); + expect(states).toEqual([true]); finishSeeks.shift()?.(); await Promise.resolve(); await Promise.resolve(); expect(flag.current).toBe(false); - expect(states).toEqual([true, false, true, false]); + expect(states).toEqual([true, false]); }); From ad892f530d3676905f4b40efc80b23b2d4a6aa74 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 12:08:16 +0200 Subject: [PATCH 8/9] feat: offer playback speeds up to 4x --- apps/web/src/components/shorts-video-player.tsx | 2 ++ apps/web/src/components/video-player-layout.tsx | 4 ++++ apps/web/src/lib/playback-rates.ts | 5 +++++ apps/web/tests/playback-rates.test.ts | 10 ++++++++++ 4 files changed, 21 insertions(+) create mode 100644 apps/web/src/lib/playback-rates.ts create mode 100644 apps/web/tests/playback-rates.test.ts diff --git a/apps/web/src/components/shorts-video-player.tsx b/apps/web/src/components/shorts-video-player.tsx index e6c2fc5..119b749 100644 --- a/apps/web/src/components/shorts-video-player.tsx +++ b/apps/web/src/components/shorts-video-player.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { isIosDevice } from "../lib/ios-device"; +import { PLAYBACK_RATES } from "../lib/playback-rates"; import type { MediaSrc } from "../lib/vidstack"; import { DefaultVideoLayout, @@ -121,6 +122,7 @@ export function ShortsVideoPlayer({ { + expect(PLAYBACK_RATES).toEqual({ + min: 0, + max: 4, + step: 0.25, + }); +}); From 0fc5311d5196e0474cc05115fdfdba155a7e965b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 23 Jul 2026 12:32:22 +0200 Subject: [PATCH 9/9] chore: update MSE player to 0.1.38 --- apps/web/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1fbba82..290cd71 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.37", + "@typetype/mse": "0.1.38", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index f007437..fb51d6f 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.37", + "@typetype/mse": "0.1.38", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", @@ -311,7 +311,7 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typetype/mse": ["@typetype/mse@0.1.37", "", {}, "sha512-pcVfS434Vs2Fjvuyn2dBUXUc0X/Zf3tsHB0ygJ1Nlqo2DuDaOcXwaB+XVVPFV5tZ7orUNBiyYl/YDY9cv810Eg=="], + "@typetype/mse": ["@typetype/mse@0.1.38", "", {}, "sha512-PAQPUGTqgxrtx+yZHzmJpBpQyyNzpF7A/Qba363CKAvkX7l8TOgopNzxljKS3haCmJv5zqXFn0TQ5dao/pHXlQ=="], "@typetype/web": ["@typetype/web@workspace:apps/web"],