diff --git a/apps/web/package.json b/apps/web/package.json
index 8ee6ec52..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",
@@ -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.42",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
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 (
-
-
-
-

- {item.duration > 0 && (
-
- {formatDuration(item.duration)}
-
- )}
- {watched && (
-
-
-
- )}
-
-
-
-
+
+
+
+
+

+ {item.duration > 0 && (
+
+ {formatDuration(item.duration)}
+
+ )}
+ {watched && (
+
+
+
+ )}
+
+
+
+
+
{item.channelUrl ? (
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-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/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 && (
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/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/lib/sabr-playback-rate-preference.ts b/apps/web/src/lib/sabr-playback-rate-preference.ts
new file mode 100644
index 00000000..bfbad425
--- /dev/null
+++ b/apps/web/src/lib/sabr-playback-rate-preference.ts
@@ -0,0 +1,28 @@
+const MIN_PLAYBACK_RATE = 0.25;
+const MAX_PLAYBACK_RATE = 16;
+
+function isValidPlaybackRate(rate: number): boolean {
+ return Number.isFinite(rate) && rate >= 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/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/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/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)} />
))}
.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/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);
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);
+ });
+});
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");
+});
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),
+ );
+ });
});
diff --git a/bun.lock b/bun.lock
index f3b94d52..9a584506 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,11 +12,11 @@
},
"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",
- "@typetype/mse": "0.1.38",
+ "@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.38", "", {}, "sha512-PAQPUGTqgxrtx+yZHzmJpBpQyyNzpF7A/Qba363CKAvkX7l8TOgopNzxljKS3haCmJv5zqXFn0TQ5dao/pHXlQ=="],
+ "@typetype/mse": ["@typetype/mse@0.1.42", "", {}, "sha512-ijuI89G6xpUdzdhY4QYgZGaXvYlNxXeu9ySCNWSlMuQtyaXCHh5TIMnHaBU43GSIwU+Mv+OYHSiY+74E1aXb6Q=="],
"@typetype/web": ["@typetype/web@workspace:apps/web"],
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",