From 523a3be1b6e9079ae9cf23d06578cc952a5aafd6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 00:32:23 +0200 Subject: [PATCH 01/10] fix: stabilize mobile watch layout --- .../src/components/video-player-layout.tsx | 2 +- .../src/components/watch-layout-classes.ts | 16 +++++--- apps/web/src/index.css | 1 + apps/web/src/routes/__root.tsx | 21 +++++++--- .../web/src/styles/watch-mobile-landscape.css | 41 +++++++++++++++++++ apps/web/tests/watch-layout-classes.test.ts | 19 +++++++++ 6 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/styles/watch-mobile-landscape.css create mode 100644 apps/web/tests/watch-layout-classes.test.ts diff --git a/apps/web/src/components/video-player-layout.tsx b/apps/web/src/components/video-player-layout.tsx index 6d1a95c4..15d494a6 100644 --- a/apps/web/src/components/video-player-layout.tsx +++ b/apps/web/src/components/video-player-layout.tsx @@ -107,7 +107,7 @@ export function VideoPlayerLayout({ icons={defaultLayoutIcons} playbackRates={PLAYBACK_RATES} thumbnails={thumbnailVtt} - smallLayoutWhen={false} + smallLayoutWhen={({ height }) => height < 380} translations={{ Captions: "Subtitles" }} slots={{ timeSlider: sabr ? ( diff --git a/apps/web/src/components/watch-layout-classes.ts b/apps/web/src/components/watch-layout-classes.ts index bae6e4cd..3af42c93 100644 --- a/apps/web/src/components/watch-layout-classes.ts +++ b/apps/web/src/components/watch-layout-classes.ts @@ -6,16 +6,20 @@ export function getWatchLayoutClasses(cinemaMode: boolean, hasSecondaryContent: ? "pt-2 sm:pt-3 lg:flex-row lg:items-start" : "pt-2 sm:pt-3 lg:items-center"; return { - containerClass: `flex flex-col gap-6 ${cinemaMode ? "" : standardLayout} ${anim}`, + containerClass: `watch-layout-container flex flex-col gap-6 ${ + cinemaMode ? "" : standardLayout + } ${anim}`, playerWrapClass: cinemaMode - ? "overflow-hidden bg-black" - : `min-w-0 flex flex-col gap-4 ${ + ? "watch-player-wrap overflow-hidden bg-black" + : `watch-player-wrap min-w-0 flex flex-col gap-4 ${ hasSecondaryContent ? "flex-[2] max-w-[133.333vh]" : "mx-auto w-full max-w-[1600px]" }`, playerBoxClass: cinemaMode - ? "relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]" - : "relative overflow-hidden rounded-lg", - playerClassName: cinemaMode ? "w-full h-full dark [--video-aspect-ratio:16/9]" : undefined, + ? "watch-player-box relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]" + : "watch-player-box relative overflow-hidden rounded-lg", + playerClassName: cinemaMode + ? "watch-player-surface w-full h-full dark [--video-aspect-ratio:16/9]" + : "watch-player-surface", mediaClassName: cinemaMode ? "object-cover" : undefined, }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 84d715d6..8c38b5f6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -13,6 +13,7 @@ @import "./styles/player-volume-controls.css"; @import "./styles/sabr-seek-state.css"; @import "./styles/video-player-mobile.css"; +@import "./styles/watch-mobile-landscape.css"; @import "./styles/player-hotkeys.css"; @import "./styles/autoplay-overlay.css"; @import "./styles/pixel-scene-enter.css"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c0f69712..2489d973 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -47,6 +47,7 @@ function RootLayout() { const embedPage = pathname.startsWith("/embed/"); const framedEmbedPage = embedPage && isEmbeddedFrame(); const registerStatus = useRegisterStatus(status !== "loading" && !framedEmbedPage); + const watchPage = pathname === "/watch"; const watchCinemaPage = pathname === "/watch" && cinemaMode; const wasWatchCinemaPage = useRef(watchCinemaPage); useSessionActivityReporting(!framedEmbedPage); @@ -160,27 +161,35 @@ function RootLayout() { ); } - const topPadding = { paddingTop: "calc(3.5rem + env(safe-area-inset-top, 0px))" }; + const topPadding = watchPage + ? undefined + : { paddingTop: "calc(3.5rem + env(safe-area-inset-top, 0px))" }; const showTabBar = isMobile && !shortsPage && !watchCinemaPage && !embedPage; const mainBottomPad = showTabBar ? "pb-[calc(env(safe-area-inset-bottom)+4.5rem)]" : "pb-5 sm:pb-6"; const mainClasses = watchCinemaPage - ? "transition-all duration-200 ml-0" - : `px-3 sm:px-4 ${mainBottomPad} transition-all duration-200 ${ + ? "watch-page-main transition-all duration-200 ml-0" + : `watch-page-main px-3 sm:px-4 ${mainBottomPad} transition-all duration-200 ${ isMobile ? "ml-0" : collapsed ? "ml-14" : "ml-48" }`; return ( -
- +
+
+ +
{watchCinemaPage ? !isMobile && : }
- {showTabBar && } + {showTabBar && ( +
+ +
+ )}
); } diff --git a/apps/web/src/styles/watch-mobile-landscape.css b/apps/web/src/styles/watch-mobile-landscape.css new file mode 100644 index 00000000..4b127d9d --- /dev/null +++ b/apps/web/src/styles/watch-mobile-landscape.css @@ -0,0 +1,41 @@ +.watch-page-shell .watch-page-main { + padding-top: calc(3.5rem + env(safe-area-inset-top, 0px)); +} + +@media (orientation: landscape) and (max-height: 500px) and (hover: none) and (pointer: coarse) { + .watch-page-shell .watch-page-chrome { + display: none; + } + + .watch-page-shell .watch-page-main { + margin-left: 0; + padding: 0; + } + + .watch-page-shell .watch-layout-container { + gap: 1rem; + padding-top: 0; + } + + .watch-page-shell .watch-player-wrap { + width: 100%; + max-width: none; + gap: 1rem; + } + + .watch-page-shell .watch-player-box { + width: 100%; + height: 100svh; + border-radius: 0; + } + + .watch-page-shell .watch-player-box > .aspect-video, + .watch-page-shell .watch-player-surface { + width: 100%; + height: 100%; + } + + .watch-page-shell .watch-player-box > .aspect-video { + aspect-ratio: auto; + } +} diff --git a/apps/web/tests/watch-layout-classes.test.ts b/apps/web/tests/watch-layout-classes.test.ts new file mode 100644 index 00000000..1b70bfdd --- /dev/null +++ b/apps/web/tests/watch-layout-classes.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { getWatchLayoutClasses } from "../src/components/watch-layout-classes"; + +test("exposes stable watch hooks without changing the player identity", () => { + const classes = getWatchLayoutClasses(false, false); + + expect(classes.containerClass).toContain("watch-layout-container"); + expect(classes.playerWrapClass).toContain("watch-player-wrap"); + expect(classes.playerBoxClass).toContain("watch-player-box"); + expect(classes.playerClassName).toBe("watch-player-surface"); +}); + +test("keeps cinema sizing alongside the mobile landscape hooks", () => { + const classes = getWatchLayoutClasses(true, false); + + expect(classes.playerBoxClass).toContain("aspect-video"); + expect(classes.playerClassName).toContain("[--video-aspect-ratio:16/9]"); + expect(classes.playerClassName).toContain("watch-player-surface"); +}); From c778d77c5af546c950c75c1d0d7b087659d25375 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 00:32:31 +0200 Subject: [PATCH 02/10] fix: make history removal touch friendly --- apps/web/src/components/history-card.tsx | 98 +++++++++--------------- apps/web/src/routes/history.tsx | 9 +-- 2 files changed, 38 insertions(+), 69 deletions(-) diff --git a/apps/web/src/components/history-card.tsx b/apps/web/src/components/history-card.tsx index ee085149..c0df08c4 100644 --- a/apps/web/src/components/history-card.tsx +++ b/apps/web/src/components/history-card.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { X } from "lucide-react"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { formatDuration } from "../lib/format"; import { proxyImage } from "../lib/proxy"; @@ -10,28 +11,7 @@ import { HistoryChannelAvatar } from "./history-channel-avatar"; import { VideoProgressBar } from "./video-progress-bar"; import { WatchedBadge } from "./watched-badge"; -function XIcon() { - return ( - - - - - ); -} - -type HistoryCardProps = { item: HistoryItem; onRemove: () => void; index: number }; +type HistoryCardProps = { item: HistoryItem; onRemove: () => void }; function formatWatchedAt(timestamp: number): string { return new Date(timestamp).toLocaleString(undefined, { @@ -43,8 +23,7 @@ function formatWatchedAt(timestamp: number): string { }); } -export function HistoryCard({ item, onRemove, index }: HistoryCardProps) { - const delay = Math.min(index * 45, 270); +export function HistoryCard({ item, onRemove }: HistoryCardProps) { const watched = isVideoWatched(item.progress, item.duration); const branding = useDeArrowBranding( item.url, @@ -54,44 +33,39 @@ export function HistoryCard({ item, onRemove, index }: HistoryCardProps) { ); return ( -
- -
- {branding.title} - {item.duration > 0 && ( - - {formatDuration(item.duration)} - - )} - {watched && ( - - - - )} - - -
- +
+
+ +
+ {branding.title} + {item.duration > 0 && ( + + {formatDuration(item.duration)} + + )} + {watched && ( + + + + )} + +
+ + +
{item.channelUrl ? ( diff --git a/apps/web/src/routes/history.tsx b/apps/web/src/routes/history.tsx index d40b834f..b46e9582 100644 --- a/apps/web/src/routes/history.tsx +++ b/apps/web/src/routes/history.tsx @@ -88,13 +88,8 @@ function HistoryPage() {
- {filtered.map((item: HistoryItem, index: number) => ( - setPendingRemoveItem(item)} - /> + {filtered.map((item: HistoryItem) => ( + setPendingRemoveItem(item)} /> ))}
Date: Sun, 26 Jul 2026 08:26:29 +0200 Subject: [PATCH 03/10] fix: reset automatic SABR recovery episodes --- apps/web/src/hooks/use-player-error.ts | 5 ++++- apps/web/tests/sabr-error-recovery.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts index beed1ef4..f057dacc 100644 --- a/apps/web/src/hooks/use-player-error.ts +++ b/apps/web/src/hooks/use-player-error.ts @@ -143,7 +143,10 @@ export function usePlayerError( setRetryKey((k) => k + 1); }, []); - const clearFailed = useCallback(() => setPlayerFailed(false), []); + const clearFailed = useCallback(() => { + setPlayerFailed(false); + resetAutomaticSabrRecovery(sabrRecoveryRef); + }, []); useEffect(() => { if (playbackSourceId.length === 0) return; setHlsFailed(false); diff --git a/apps/web/tests/sabr-error-recovery.test.ts b/apps/web/tests/sabr-error-recovery.test.ts index 16c2fb50..2b491dfe 100644 --- a/apps/web/tests/sabr-error-recovery.test.ts +++ b/apps/web/tests/sabr-error-recovery.test.ts @@ -16,6 +16,15 @@ test("allows one automatic SABR recovery until reset", () => { expect(claimAutomaticSabrRecovery(recoveryRef)).toBe(true); }); +test("allows a later recovery after successful playback resets the episode", () => { + const recoveryRef = { current: false }; + + expect(claimAutomaticSabrRecovery(recoveryRef)).toBe(true); + resetAutomaticSabrRecovery(recoveryRef); + expect(claimAutomaticSabrRecovery(recoveryRef)).toBe(true); + expect(claimAutomaticSabrRecovery(recoveryRef)).toBe(false); +}); + test("prefers the captured SABR position during player recovery", () => { expect(playerErrorResumePosition(0, 1_795_023, 1_892_543)).toBe(1_795_023); expect(playerErrorResumePosition(420_000, undefined, 1_892_543)).toBe(420_000); From 53063828021448970d41c4678229a7154010fbf5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 08:26:38 +0200 Subject: [PATCH 04/10] fix: preserve SABR player across enrichment --- apps/web/src/lib/watch-player-key.ts | 3 ++- apps/web/tests/watch-player-key.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/watch-player-key.ts b/apps/web/src/lib/watch-player-key.ts index 9d96fc54..86832320 100644 --- a/apps/web/src/lib/watch-player-key.ts +++ b/apps/web/src/lib/watch-player-key.ts @@ -9,8 +9,9 @@ type Args = { export function buildWatchPlayerKey(args: Args) { const sabr = args.sourceKey.startsWith("sabr:"); + const source = sabr ? "sabr" : args.sourceKey; const quality = !sabr && args.highQuality ? "hq" : "std"; const thumbnails = !sabr && args.hasThumbnails ? "thumbs" : "no-thumbs"; const chapters = !sabr && args.hasChapters ? "chapters" : "no-chapters"; - return [args.streamId, args.retryKey, args.sourceKey, quality, thumbnails, chapters].join(":"); + return [args.streamId, args.retryKey, source, quality, thumbnails, chapters].join(":"); } diff --git a/apps/web/tests/watch-player-key.test.ts b/apps/web/tests/watch-player-key.test.ts index a20b21a4..1c062ade 100644 --- a/apps/web/tests/watch-player-key.test.ts +++ b/apps/web/tests/watch-player-key.test.ts @@ -36,4 +36,19 @@ describe("buildWatchPlayerKey", () => { }), ).toBe(buildWatchPlayerKey(base)); }); + + test("ignores the unused classic manifest identity for sabr", () => { + const base = { + streamId: "video", + retryKey: 0, + sourceKey: "sabr:manifest-a", + highQuality: true, + hasThumbnails: true, + hasChapters: true, + }; + + expect(buildWatchPlayerKey({ ...base, sourceKey: "sabr:manifest-b" })).toBe( + buildWatchPlayerKey(base), + ); + }); }); From 1eab6be3bd019b8b374870d7504781a0b5713c79 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 08:34:19 +0200 Subject: [PATCH 05/10] chore: update MSE player to 0.1.39 --- 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 8ee6ec52..b9aac745 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.38", + "@typetype/mse": "0.1.39", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index f3b94d52..dd5b51d1 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.38", + "@typetype/mse": "0.1.39", "@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.38", "", {}, "sha512-PAQPUGTqgxrtx+yZHzmJpBpQyyNzpF7A/Qba363CKAvkX7l8TOgopNzxljKS3haCmJv5zqXFn0TQ5dao/pHXlQ=="], + "@typetype/mse": ["@typetype/mse@0.1.39", "", {}, "sha512-pCMRcD2K67Yjzg4zVEkceP63Rj0K+GybTzhjay08KhvjTZfwtFSBP5kOJ1fLjgnINsqY0DAImBL9AjA4xFfXTA=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From da6f2fdff0dc927aef4a78b5f04a1eb34dc1ef3f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 10:35:58 +0200 Subject: [PATCH 06/10] fix: preserve SABR player state --- .../src/components/sabr-mse-player-types.ts | 2 + apps/web/src/components/sabr-mse-player.tsx | 38 +++++++++++-- apps/web/src/components/video-player-types.ts | 2 + apps/web/src/components/video-player.tsx | 4 +- .../web/src/components/watch-stage-player.tsx | 5 +- .../src/lib/sabr-playback-rate-preference.ts | 28 ++++++++++ .../sabr-playback-rate-preference.test.ts | 53 +++++++++++++++++++ 7 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/sabr-playback-rate-preference.ts create mode 100644 apps/web/tests/sabr-playback-rate-preference.test.ts diff --git a/apps/web/src/components/sabr-mse-player-types.ts b/apps/web/src/components/sabr-mse-player-types.ts index 50a4546a..e8a610ae 100644 --- a/apps/web/src/components/sabr-mse-player-types.ts +++ b/apps/web/src/components/sabr-mse-player-types.ts @@ -1,7 +1,9 @@ +import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import type { SabrPlaybackConfig } from "../lib/sabr-source"; export type SabrMsePlayerProps = { config: SabrPlaybackConfig; + playbackRatePreference?: SabrPlaybackRatePreference; video: HTMLVideoElement | null; startTime: number; autoplay: boolean; diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index bb9769b5..fb23aafe 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -5,6 +5,7 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { recordClientEvent } from "../lib/client-debug-log"; import { toAbsoluteApiUrl } from "../lib/env"; +import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { isAbortError } from "../lib/sabr-playback-retry"; import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek"; import { registerSabrVidstackControls } from "../lib/sabr-vidstack-bridge"; @@ -13,6 +14,7 @@ import type { SabrMsePlayerProps } from "./sabr-mse-player-types"; export function SabrMsePlayer({ config, + playbackRatePreference, video, startTime, autoplay, @@ -36,6 +38,9 @@ export function SabrMsePlayer({ const autoplayConfirmedRef = useRef(false); const seekingRef = useRef(false); const errorReportedRef = useRef(false); + const attachedVideoRef = useRef(false); + const fallbackPlaybackRateRef = useRef(new SabrPlaybackRatePreference()); + const playbackRate = playbackRatePreference ?? fallbackPlaybackRateRef.current; const [engineReady, setEngineReady] = useState(false); const latestConfig = useLatestValue(config); const latestStartTime = useLatestValue(startTime); @@ -77,6 +82,8 @@ export function SabrMsePlayer({ useEffect(() => { if (!video) return; errorReportedRef.current = false; + const replacingVideo = attachedVideoRef.current; + attachedVideoRef.current = true; const initialConfig = latestConfig(); const engine = new TypeTypeMsePlayer(video, { endpoint: toAbsoluteApiUrl(""), @@ -102,7 +109,22 @@ export function SabrMsePlayer({ if (engine.isApplyingTransientMediaState()) return; latestHandlers().onVolumeChange?.(video.volume, video.muted); }; + let playbackRateSettled = false; + const playbackRateChange = () => { + playbackRate.capture(video, !playbackRateSettled || engine.isApplyingTransientMediaState()); + }; + const settlePlaybackRate = () => { + if (engine.isApplyingTransientMediaState()) return; + playbackRate.apply(video, false); + playbackRateSettled = true; + }; + const playEngine = () => + engine.play().then(() => { + settlePlaybackRate(); + }); + playbackRate.initialize(video); video.addEventListener("volumechange", volumeChange); + video.addEventListener("ratechange", playbackRateChange); let autoplayStartTime = 0; let engineLoaded = false; const startAutoplay = () => { @@ -118,7 +140,7 @@ export function SabrMsePlayer({ if (!latestHandlers().autoplay && !pendingPlayRef.current) return; autoplayStartTime = video.currentTime; autoplayStartedRef.current = true; - void engine.play().catch(() => { + void playEngine().catch(() => { autoplayStartedRef.current = false; }); }; @@ -128,7 +150,7 @@ export function SabrMsePlayer({ play: () => { pendingPlayRef.current = true; video.autoplay = true; - return engine.play(); + return playEngine(); }, pause: (userInitiated = false) => { if (!userInitiated && pendingPlayRef.current && !autoplayConfirmedRef.current) return; @@ -148,6 +170,7 @@ export function SabrMsePlayer({ .then(() => { engineLoaded = true; setEngineReady(true); + if (!replacingVideo) settlePlaybackRate(); startAutoplay(); }) .catch((error: unknown) => { @@ -162,6 +185,7 @@ export function SabrMsePlayer({ offError(); unregisterControls(); video.removeEventListener("volumechange", volumeChange); + video.removeEventListener("ratechange", playbackRateChange); video.removeEventListener("canplay", startAutoplay); window.clearInterval(autoplayTimer); engine.destroy(); @@ -176,6 +200,14 @@ export function SabrMsePlayer({ video.autoplay = false; latestHandlers().onPositionReaderChange(null); }; - }, [config.videoId, latestConfig, latestHandlers, latestStartTime, reportError, video]); + }, [ + config.videoId, + latestConfig, + latestHandlers, + latestStartTime, + playbackRate, + reportError, + video, + ]); return null; } diff --git a/apps/web/src/components/video-player-types.ts b/apps/web/src/components/video-player-types.ts index 2175e089..8b4c0ebf 100644 --- a/apps/web/src/components/video-player-types.ts +++ b/apps/web/src/components/video-player-types.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import type { SabrPlaybackConfig } from "../lib/sabr-source"; import type { MediaSrc } from "../lib/vidstack"; import type { SponsorBlockSegmentItem, SubtitleItem } from "../types/api"; @@ -7,6 +8,7 @@ import type { CaptionStyles } from "../types/user"; export type VideoPlayerProps = { src: MediaSrc; sabrConfig?: SabrPlaybackConfig | null; + sabrPlaybackRatePreference?: SabrPlaybackRatePreference; title?: string; poster?: string; streamType?: "on-demand" | "live"; diff --git a/apps/web/src/components/video-player.tsx b/apps/web/src/components/video-player.tsx index 84191c6b..12136da1 100644 --- a/apps/web/src/components/video-player.tsx +++ b/apps/web/src/components/video-player.tsx @@ -27,6 +27,7 @@ patchVidstackProviderLoaders(); export function VideoPlayer({ src, sabrConfig, + sabrPlaybackRatePreference, title, poster, streamType = "on-demand", @@ -97,7 +98,7 @@ export function VideoPlayer({ title={title} poster={poster} onProviderChange={sabrState.handleProviderChange} - onError={handleError} + onError={sabrConfig ? undefined : handleError} aria-busy={sabrState.seeking} data-sabr-seeking={sabrState.seeking ? "true" : undefined} > @@ -111,6 +112,7 @@ export function VideoPlayer({ {sabrConfig && ( = MIN_PLAYBACK_RATE && rate <= MAX_PLAYBACK_RATE; +} + +export class SabrPlaybackRatePreference { + private preferredRate: number | null = null; + + initialize(video: HTMLVideoElement): void { + if (this.preferredRate === null && isValidPlaybackRate(video.playbackRate)) { + this.preferredRate = video.playbackRate; + } + } + + capture(video: HTMLVideoElement, transient: boolean): void { + if (!transient && isValidPlaybackRate(video.playbackRate)) { + this.preferredRate = video.playbackRate; + } + } + + apply(video: HTMLVideoElement, transient: boolean): void { + if (transient || this.preferredRate === null) return; + video.defaultPlaybackRate = this.preferredRate; + video.playbackRate = this.preferredRate; + } +} diff --git a/apps/web/tests/sabr-playback-rate-preference.test.ts b/apps/web/tests/sabr-playback-rate-preference.test.ts new file mode 100644 index 00000000..9c104e6e --- /dev/null +++ b/apps/web/tests/sabr-playback-rate-preference.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { SabrPlaybackRatePreference } from "../src/lib/sabr-playback-rate-preference"; + +function video(rate = 1): HTMLVideoElement { + return { + defaultPlaybackRate: rate, + playbackRate: rate, + } as HTMLVideoElement; +} + +describe("SabrPlaybackRatePreference", () => { + test("restores the user rate after a media element replacement", () => { + const preference = new SabrPlaybackRatePreference(); + const first = video(); + preference.initialize(first); + first.playbackRate = 4; + preference.capture(first, false); + + const replacement = video(); + preference.initialize(replacement); + preference.apply(replacement, false); + + expect(replacement.defaultPlaybackRate).toBe(4); + expect(replacement.playbackRate).toBe(4); + }); + + test("ignores transient preroll and provider rate changes", () => { + const preference = new SabrPlaybackRatePreference(); + const first = video(4); + preference.initialize(first); + const replacement = video(16); + preference.capture(replacement, true); + replacement.playbackRate = 1; + preference.capture(replacement, true); + + preference.apply(replacement, false); + + expect(replacement.defaultPlaybackRate).toBe(4); + expect(replacement.playbackRate).toBe(4); + }); + + test("does not write while the MSE transition is active", () => { + const preference = new SabrPlaybackRatePreference(); + const element = video(2); + preference.initialize(element); + element.playbackRate = 1; + + preference.apply(element, true); + + expect(element.defaultPlaybackRate).toBe(2); + expect(element.playbackRate).toBe(1); + }); +}); From ce203e17f80b3d47a69f0bc0ee4bd083e41ac21c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 10:36:12 +0200 Subject: [PATCH 07/10] chore: update MSE player to 0.1.40 --- 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 b9aac745..1f616f83 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.39", + "@typetype/mse": "0.1.40", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index dd5b51d1..ca8b16a3 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.39", + "@typetype/mse": "0.1.40", "@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.39", "", {}, "sha512-pCMRcD2K67Yjzg4zVEkceP63Rj0K+GybTzhjay08KhvjTZfwtFSBP5kOJ1fLjgnINsqY0DAImBL9AjA4xFfXTA=="], + "@typetype/mse": ["@typetype/mse@0.1.40", "", {}, "sha512-Rcxop7ZXUw96mBACfAn+MedcIccsPCqP0UKCB+HbpXhku7dmaMbV0Xyy/QKfHHBJAjGKlD/yrA2ShKbKj+Un+g=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 863e4bc64a015d119d0c73c43f849011926999c0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 13:01:51 +0200 Subject: [PATCH 08/10] chore: update MSE player to 0.1.41 --- 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 1f616f83..d2014dd5 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.40", + "@typetype/mse": "0.1.41", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index ca8b16a3..443a3ef2 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.40", + "@typetype/mse": "0.1.41", "@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.40", "", {}, "sha512-Rcxop7ZXUw96mBACfAn+MedcIccsPCqP0UKCB+HbpXhku7dmaMbV0Xyy/QKfHHBJAjGKlD/yrA2ShKbKj+Un+g=="], + "@typetype/mse": ["@typetype/mse@0.1.41", "", {}, "sha512-lrKTlzjqr/kc6EPoqEE/eQwMA1+UVmFBiWuODSJgfvMVJtzi771otJtq8aW/fHT/NFSDizN15JmgwmDNJ7/jOg=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 9c82a4c15ced3ac880be0f7dd4b1bb29d53d081f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 14:04:31 +0200 Subject: [PATCH 09/10] fix: update mse seek lifecycle recovery --- 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 d2014dd5..b6cf247d 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.41", + "@typetype/mse": "0.1.42", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index 443a3ef2..31fffec0 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.41", + "@typetype/mse": "0.1.42", "@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.41", "", {}, "sha512-lrKTlzjqr/kc6EPoqEE/eQwMA1+UVmFBiWuODSJgfvMVJtzi771otJtq8aW/fHT/NFSDizN15JmgwmDNJ7/jOg=="], + "@typetype/mse": ["@typetype/mse@0.1.42", "", {}, "sha512-ijuI89G6xpUdzdhY4QYgZGaXvYlNxXeu9ySCNWSlMuQtyaXCHh5TIMnHaBU43GSIwU+Mv+OYHSiY+74E1aXb6Q=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 3658b7de2217bcdf85cde54c4a81f0a20a7e041c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 26 Jul 2026 17:22:06 +0200 Subject: [PATCH 10/10] chore: prepare frontend 1.2.1 version --- apps/web/package.json | 2 +- bun.lock | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index b6cf247d..e68a33ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/bun.lock b/bun.lock index 31fffec0..9a584506 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "apps/web": { "name": "@typetype/web", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", diff --git a/package.json b/package.json index e321668f..6ac73eec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/frontend", - "version": "1.2.0", + "version": "1.2.1", "devDependencies": { "@biomejs/biome": "^2.5.3", "knip": "^6.25.0",