From a23277ae61140fb841905c22c9d659b0ea964aaf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 27 Jul 2026 12:35:38 +0200 Subject: [PATCH 01/17] chore: update MSE player to 0.1.43 --- 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 e68a33e..773ef0f 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.42", + "@typetype/mse": "0.1.43", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index 9a58450..243d6f8 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.42", + "@typetype/mse": "0.1.43", "@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.42", "", {}, "sha512-ijuI89G6xpUdzdhY4QYgZGaXvYlNxXeu9ySCNWSlMuQtyaXCHh5TIMnHaBU43GSIwU+Mv+OYHSiY+74E1aXb6Q=="], + "@typetype/mse": ["@typetype/mse@0.1.43", "", {}, "sha512-7SbMz7RKW8rQ3d6JBrQl74UQOdhFrYrI3RwC5zh6ZNxR8tpGrYkXhDoLWxfRRJdLQU4UpOnPgMvyT/LqjT9z5g=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 4e27ab0d2d93e77acfe41194865018a7f6981916 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 27 Jul 2026 12:35:51 +0200 Subject: [PATCH 02/17] chore: prepare frontend 1.2.3 version --- apps/web/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 773ef0f..2df5f0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.2.1", + "version": "1.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/package.json b/package.json index 6ac73ee..c0fa96b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/frontend", - "version": "1.2.1", + "version": "1.2.3", "devDependencies": { "@biomejs/biome": "^2.5.3", "knip": "^6.25.0", From 36c3d47e2987455ea46ef9175043da78473e9374 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 28 Jul 2026 13:19:48 +0200 Subject: [PATCH 03/17] refactor: make YouTube playback SABR-only --- .../web/src/components/embed-player-shell.tsx | 5 +- .../src/components/navbar-playback-mode.tsx | 26 --- apps/web/src/components/navbar.tsx | 2 - .../components/playback-transition-notice.tsx | 165 ------------------ apps/web/src/components/video-preview.tsx | 4 +- apps/web/src/hooks/use-playback-mode.ts | 23 --- apps/web/src/hooks/use-player-error.ts | 38 ++-- apps/web/src/hooks/use-stream.ts | 30 +--- .../src/hooks/use-watch-autoplay-preload.ts | 10 +- apps/web/src/lib/api-stream.ts | 4 +- apps/web/src/lib/bilibili-manifest.ts | 6 +- apps/web/src/lib/dash-manifest.ts | 6 +- apps/web/src/lib/embed-playback.ts | 9 - apps/web/src/lib/playback-mode.ts | 62 ------- apps/web/src/lib/stream-compatibility.ts | 8 +- apps/web/src/lib/stream-delivery.ts | 18 +- ...m-fallback-src.ts => stream-direct-src.ts} | 16 +- apps/web/src/lib/stream-request.ts | 23 +-- apps/web/src/lib/stream-src.ts | 25 ++- apps/web/src/routes/__root.tsx | 3 - apps/web/src/routes/embed_.$videoId.tsx | 21 +-- apps/web/src/routes/watch.tsx | 13 +- apps/web/src/settings/settings-playback.tsx | 9 - apps/web/tests/embed-playback.test.ts | 8 +- apps/web/tests/stream-hls-transport.test.ts | 14 +- apps/web/tests/stream-playback-mode.test.ts | 29 +-- apps/web/tests/watch-player-key.test.ts | 2 +- 27 files changed, 96 insertions(+), 483 deletions(-) delete mode 100644 apps/web/src/components/navbar-playback-mode.tsx delete mode 100644 apps/web/src/components/playback-transition-notice.tsx delete mode 100644 apps/web/src/hooks/use-playback-mode.ts delete mode 100644 apps/web/src/lib/playback-mode.ts rename apps/web/src/lib/{stream-fallback-src.ts => stream-direct-src.ts} (71%) diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index 9716ecf..fed8f05 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -8,7 +8,6 @@ import { useWatchVttAssets } from "../hooks/use-watch-layout-assets"; import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { getOriginalAudioLocale } from "../lib/audio-track"; import { resolveEmbedAutoplay } from "../lib/embed-playback"; -import type { PlaybackMode } from "../lib/playback-mode"; import { toPublicWatchParam } from "../lib/watch-url"; import type { VideoStream } from "../types/stream"; import { EmbedError, PLAYBACK_FAILED_MESSAGE } from "./embed-error"; @@ -20,7 +19,6 @@ type Props = { startTime: number; autoplay: boolean; sessionEnabled: boolean; - playbackMode: PlaybackMode; }; export function EmbedPlayerShell({ @@ -29,13 +27,12 @@ export function EmbedPlayerShell({ startTime, autoplay, sessionEnabled, - playbackMode, }: Props) { const { settings, settingsReady, update } = useSettings({ forceAnonymous: !sessionEnabled, }); const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream"; - const player = usePlayerError(stream, isLive, playbackMode); + const player = usePlayerError(stream, isLive); const handleVolumeChange = useVolumeSync(update.mutate); const positionRef = useRef(0); diff --git a/apps/web/src/components/navbar-playback-mode.tsx b/apps/web/src/components/navbar-playback-mode.tsx deleted file mode 100644 index 9516748..0000000 --- a/apps/web/src/components/navbar-playback-mode.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AudioWaveform } from "lucide-react"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; - -export function NavbarPlaybackMode() { - const { playbackMode, setMode } = usePlaybackMode(); - const sabrEnabled = playbackMode === "sabr"; - const label = sabrEnabled ? "Use classic playback" : "Use SABR playback"; - - return ( - - ); -} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 97c4542..05a8488 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -10,7 +10,6 @@ import { useUiStore } from "../stores/ui-store"; import { NavbarAccountControls } from "./navbar-account-controls"; import { NavbarLeadingControl } from "./navbar-leading-control"; import { NavbarNotifications } from "./navbar-notifications"; -import { NavbarPlaybackMode } from "./navbar-playback-mode"; import { NavbarSearch } from "./navbar-search"; import { Toast } from "./toast"; @@ -90,7 +89,6 @@ export function Navbar() { {canOpenSearch && !isMobile && }
- { - if (readPlaybackMode() === "legacy") setOpen(true); - return subscribeClassicPlaybackRequest(() => setOpen(true)); - }, []); - - useEffect(() => { - if (!open) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(false); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - if (!open) return null; - - function useSabr() { - setPlaybackMode("sabr"); - setOpen(false); - } - - function continueWithClassic() { - setPlaybackMode("legacy"); - setOpen(false); - } - - return createPortal( - <> - - - -
-

- Alternative clients have relied on classic DASH and HLS extraction for years. YouTube is - restricting that path more often, so a healthy self-hosted instance can still fail in - ways that look like a server problem. -

- -
    - {CLASSIC_FAILURES.map((failure) => ( -
  • - - {failure} -
  • - ))} -
- -
-

- SABR is TypeType's supported and recommended mode. -

-

- It follows YouTube's current delivery path and is the playback path actively - maintained and optimized by TypeType. Classic remains available while it still works, - but it may stop working for individual videos or entire instances. -

-
- -

- SABR should work across supported YouTube videos. If you find an unexpected playback - issue, report the video URL and what happened on GitHub so it can be fixed. -

-
- -
- - - -
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/video-preview.tsx b/apps/web/src/components/video-preview.tsx index 89185f7..9326e33 100644 --- a/apps/web/src/components/video-preview.tsx +++ b/apps/web/src/components/video-preview.tsx @@ -100,6 +100,6 @@ async function loadHls(video: HTMLVideoElement, url: string): Promise void; -} { - const playbackMode = useSyncExternalStore( - subscribePlaybackMode, - readPlaybackMode, - readPlaybackMode, - ); - - return { - playbackMode, - setMode: requestPlaybackMode, - }; -} diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts index f057dac..c943de9 100644 --- a/apps/web/src/hooks/use-player-error.ts +++ b/apps/web/src/hooks/use-player-error.ts @@ -3,19 +3,17 @@ import { bilibiliVariantCount } from "../lib/bilibili-manifest"; import { recordClientEvent } from "../lib/client-debug-log"; import { sanitizeVideoContext } from "../lib/debug-sanitize"; import { isIosDevice } from "../lib/ios-device"; -import type { PlaybackMode } from "../lib/playback-mode"; import { detectProvider } from "../lib/provider"; import { claimAutomaticSabrRecovery, resetAutomaticSabrRecovery } from "../lib/sabr-error-recovery"; import { - hasLegacyDashPair, + directProgressiveStreams, + hasDirectDashPair, hasSabrPlayback, - legacyProgressiveStreams, } from "../lib/stream-delivery"; -import { resolveManifestSrc, shouldUseClassicHls } from "../lib/stream-src"; +import { resolveManifestSrc, shouldUseHls } from "../lib/stream-src"; import type { MediaSrc } from "../lib/vidstack"; import type { VideoStream } from "../types/stream"; import { useInstance } from "./use-instance"; -import { usePlaybackMode } from "./use-playback-mode"; type UsePlayerErrorReturn = { manifestSrc: MediaSrc; @@ -31,29 +29,23 @@ type UsePlayerErrorReturn = { seekStartTime: number | null; }; -export function usePlayerError( - stream: VideoStream, - isLive: boolean, - playbackModeOverride?: PlaybackMode, -): UsePlayerErrorReturn { +export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerErrorReturn { const debugVideo = sanitizeVideoContext(stream.id) ?? "unknown"; const provider = detectProvider(stream.id); const iosDevice = isIosDevice(); const { data: instance } = useInstance(); - const { playbackMode: storedPlaybackMode } = usePlaybackMode(); - const playbackMode = playbackModeOverride ?? storedPlaybackMode; - const playbackSourceId = stream.id.length === 0 ? "" : `${stream.id}:${playbackMode}`; + const playbackSourceId = stream.id; const preferServerManifests = instance?.guestAllowed !== false; - const legacyDashPair = hasLegacyDashPair(stream); - const hasLegacyPlaybackFallback = legacyDashPair || legacyProgressiveStreams(stream).length > 0; + const directDashPair = hasDirectDashPair(stream); + const hasDirectPlaybackFallback = directDashPair || directProgressiveStreams(stream).length > 0; const highQualityEnabled = !isLive && !iosDevice && preferServerManifests && !stream.hlsUrl && - legacyDashPair && + directDashPair && provider === "youtube"; - const hlsEnabled = shouldUseClassicHls(stream.hlsUrl, isLive, false, legacyDashPair); + const hlsEnabled = shouldUseHls(stream.hlsUrl, isLive, false, directDashPair); const [hlsFailed, setHlsFailed] = useState(false); const [highQualityFailed, setHighQualityFailed] = useState(false); const [qualityFailed, setQualityFailed] = useState(false); @@ -66,7 +58,7 @@ export function usePlayerError( provider === "bilibili" ? bilibiliVariantCount(stream.videoOnlyStreams ?? [], stream.audioStreams ?? []) : 0; - const sabrSelected = provider === "youtube" && playbackMode === "sabr"; + const sabrSelected = provider === "youtube"; const sabrEnabled = sabrSelected && hasSabrPlayback(stream); const fallbackSrc = resolveManifestSrc(stream, isLive, qualityFailed, { @@ -89,7 +81,7 @@ export function usePlayerError( setPlayerFailed(true); } else if (hlsEnabled && !hlsFailed) { recordClientEvent("player.hls_failed", { video: debugVideo }); - if (!hasLegacyPlaybackFallback) { + if (!hasDirectPlaybackFallback) { setPlayerFailed(true); return; } @@ -103,11 +95,11 @@ export function usePlayerError( recordClientEvent("player.high_quality_failed", { video: debugVideo }); setHighQualityFailed(true); setRetryKey((k) => k + 1); - } else if (legacyDashPair && !qualityFailed) { + } else if (directDashPair && !qualityFailed) { recordClientEvent("player.quality_failed", { video: debugVideo }); setQualityFailed(true); setRetryKey((k) => k + 1); - } else if (!isLive && hasLegacyPlaybackFallback && !compatibilityFallback) { + } else if (!isLive && hasDirectPlaybackFallback && !compatibilityFallback) { recordClientEvent("player.compatibility_fallback", { video: debugVideo }); setCompatibilityFallback(true); setRetryKey((k) => k + 1); @@ -120,13 +112,13 @@ export function usePlayerError( hlsEnabled, hlsFailed, sabrSelected, - hasLegacyPlaybackFallback, + hasDirectPlaybackFallback, provider, bilibiliVariant, bilibiliVariants, highQualityEnabled, highQualityFailed, - legacyDashPair, + directDashPair, qualityFailed, compatibilityFallback, isLive, diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts index a1029c8..f30572e 100644 --- a/apps/web/src/hooks/use-stream.ts +++ b/apps/web/src/hooks/use-stream.ts @@ -6,27 +6,20 @@ import { isMemberOnlyApiError as isMemberOnlyApiResponse, MEMBER_ONLY_MESSAGE, } from "../lib/member-only"; -import { type PlaybackMode, readPlaybackMode } from "../lib/playback-mode"; import { sabrBootstrapEndpoint, sabrBootstrapQueryKey, streamQueryKey, } from "../lib/stream-request"; -export function streamQueryOptions( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function streamQueryOptions(url: string, useAuthenticatedStream = false, enabled = true) { return queryOptions({ - queryKey: streamQueryKey(url, useAuthenticatedStream, playbackMode), + queryKey: streamQueryKey(url, useAuthenticatedStream), queryFn: ({ signal }) => fetchStream( url, useAuthenticatedStream ? "authenticated_first" : "anonymous_first", signal, - playbackMode, ).then((r) => mapStreamResponse(r, url)), enabled: enabled && url.startsWith("http"), staleTime: 3 * 60 * 1000, @@ -61,24 +54,14 @@ export function isMemberOnlyApiError(error: unknown): boolean { return isMemberOnlyApiResponse(error); } -export function useStream( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function useStream(url: string, useAuthenticatedStream = false, enabled = true) { return useQuery({ - ...streamQueryOptions(url, useAuthenticatedStream, enabled, playbackMode), + ...streamQueryOptions(url, useAuthenticatedStream, enabled), placeholderData: keepPreviousData, }); } -export function useSabrBootstrap( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function useSabrBootstrap(url: string, useAuthenticatedStream = false, enabled = true) { return useQuery({ queryKey: sabrBootstrapQueryKey(url, useAuthenticatedStream), queryFn: ({ signal }) => @@ -87,8 +70,7 @@ export function useSabrBootstrap( useAuthenticatedStream ? "authenticated_first" : "anonymous_first", signal, ).then((response) => mapStreamResponse(response, url)), - enabled: - enabled && playbackMode === "sabr" && url.startsWith("http") && !!sabrBootstrapEndpoint(url), + enabled: enabled && url.startsWith("http") && !!sabrBootstrapEndpoint(url), staleTime: 3 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: (count, error) => { diff --git a/apps/web/src/hooks/use-watch-autoplay-preload.ts b/apps/web/src/hooks/use-watch-autoplay-preload.ts index 105a83b..f05e07d 100644 --- a/apps/web/src/hooks/use-watch-autoplay-preload.ts +++ b/apps/web/src/hooks/use-watch-autoplay-preload.ts @@ -7,7 +7,6 @@ import { toWatchSourceUrl } from "../lib/watch-url"; import { useAuthStore } from "../stores/auth-store"; import { useAuth } from "./use-auth"; import { useInstance } from "./use-instance"; -import { usePlaybackMode } from "./use-playback-mode"; import { useSettings } from "./use-settings"; import { streamQueryOptions } from "./use-stream"; import type { AutoplayTarget } from "./use-watch-ended-navigation"; @@ -23,7 +22,6 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { const token = useAuthStore((state) => state.token); const { authReady, isAuthed } = useAuth(); const { data: instance, isPending: instancePending } = useInstance(); - const { playbackMode } = usePlaybackMode(); const { settings, settingsReady } = useSettings(); const preloadedRef = useRef(new Set()); const activeRef = useRef(null); @@ -52,16 +50,16 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { if (!shouldPreloadAutoplayTarget(positionMs, durationMs, enabled, Boolean(target))) return; if (!target || !ready) return; const sourceUrl = toWatchSourceUrl(target.search.v); - const key = `${sourceUrl}:${useAuthenticatedStream ? "auth" : "anon"}:${playbackMode}`; + const key = `${sourceUrl}:${useAuthenticatedStream ? "auth" : "anon"}`; if (preloadedRef.current.has(key)) return; preloadedRef.current.add(key); const controller = new AbortController(); activeRef.current?.abort(); activeRef.current = controller; void queryClient - .fetchQuery(streamQueryOptions(sourceUrl, useAuthenticatedStream, true, playbackMode)) + .fetchQuery(streamQueryOptions(sourceUrl, useAuthenticatedStream, true)) .then((stream) => { - if (controller.signal.aborted || playbackMode !== "sabr") return; + if (controller.signal.aborted) return; const itag = defaultSabrItag(sabrQualityOptions(stream), "720p"); const config = resolveSabrPlaybackConfig(stream, itag); if (config) return prewarmSabrPlayback(config, token, controller.signal); @@ -73,6 +71,6 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { if (activeRef.current === controller) activeRef.current = null; }); }, - [durationMs, enabled, playbackMode, queryClient, ready, target, token, useAuthenticatedStream], + [durationMs, enabled, queryClient, ready, target, token, useAuthenticatedStream], ); } diff --git a/apps/web/src/lib/api-stream.ts b/apps/web/src/lib/api-stream.ts index 68454b9..e7cbc46 100644 --- a/apps/web/src/lib/api-stream.ts +++ b/apps/web/src/lib/api-stream.ts @@ -3,7 +3,6 @@ import type { StreamResponse } from "../types/api"; import { ApiError, request } from "./api"; import { recordClientEvent } from "./client-debug-log"; import { sanitizeVideoContext } from "./debug-sanitize"; -import { type PlaybackMode, readPlaybackMode } from "./playback-mode"; import { sabrBootstrapEndpoint, streamEndpoint } from "./stream-request"; type StreamFetchMode = "anonymous_first" | "authenticated_first"; @@ -21,9 +20,8 @@ export async function fetchStream( url: string, mode: StreamFetchMode = "anonymous_first", signal?: AbortSignal, - playbackMode: PlaybackMode = readPlaybackMode(), ): Promise { - const endpoint = streamEndpoint(url, playbackMode); + const endpoint = streamEndpoint(url); return fetchStreamEndpoint(url, endpoint, mode, signal); } diff --git a/apps/web/src/lib/bilibili-manifest.ts b/apps/web/src/lib/bilibili-manifest.ts index 3d9bd89..24f9cb3 100644 --- a/apps/web/src/lib/bilibili-manifest.ts +++ b/apps/web/src/lib/bilibili-manifest.ts @@ -1,6 +1,6 @@ import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import { proxyUrl } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; type VideoCandidate = VideoStreamItem & { codec: string }; type AudioCandidate = AudioStreamItem & { codec: string }; @@ -58,13 +58,13 @@ function isSupportedCodec(mimeType: string, codec: string): boolean { function isVideoCandidate(stream: VideoStreamItem): stream is VideoCandidate { return ( - hasPlayableLegacyUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 + hasPlayableDirectUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 ); } function isAudioCandidate(stream: AudioStreamItem): stream is AudioCandidate { return ( - hasPlayableLegacyUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 + hasPlayableDirectUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 ); } diff --git a/apps/web/src/lib/dash-manifest.ts b/apps/web/src/lib/dash-manifest.ts index 004e6f8..c0421bc 100644 --- a/apps/web/src/lib/dash-manifest.ts +++ b/apps/web/src/lib/dash-manifest.ts @@ -1,6 +1,6 @@ import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import { proxyUrl } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; const EXCLUDED_VIDEO_PREFIXES = ["av01", "vp9", "vp09"]; @@ -9,7 +9,7 @@ type ValidAudioStream = AudioStreamItem & { codec: string }; function isValidVideo(s: VideoStreamItem): s is ValidVideoStream { const codec = s.codec; - if (!hasPlayableLegacyUrl(s)) return false; + if (!hasPlayableDirectUrl(s)) return false; if (codec === null || codec.length === 0) return false; if (!s.mimeType.includes("video/mp4")) return false; return !EXCLUDED_VIDEO_PREFIXES.some((p) => codec.startsWith(p)); @@ -17,7 +17,7 @@ function isValidVideo(s: VideoStreamItem): s is ValidVideoStream { function isValidAudio(s: AudioStreamItem): s is ValidAudioStream { const codec = s.codec; - if (!hasPlayableLegacyUrl(s)) return false; + if (!hasPlayableDirectUrl(s)) return false; if (codec === null || codec.length === 0) return false; if (!codec.startsWith("mp4a")) return false; return s.mimeType.includes("audio/mp4"); diff --git a/apps/web/src/lib/embed-playback.ts b/apps/web/src/lib/embed-playback.ts index 956488a..8304b3a 100644 --- a/apps/web/src/lib/embed-playback.ts +++ b/apps/web/src/lib/embed-playback.ts @@ -1,5 +1,3 @@ -import type { PlaybackMode } from "./playback-mode"; - export function resolveEmbedAutoplay( retryKey: number, playbackIntent: boolean, @@ -7,10 +5,3 @@ export function resolveEmbedAutoplay( ): boolean { return retryKey === 0 ? requestedAutoplay : playbackIntent; } - -export function resolveEmbedPlaybackMode( - framed: boolean, - storedPlaybackMode: PlaybackMode, -): PlaybackMode { - return framed ? "sabr" : storedPlaybackMode; -} diff --git a/apps/web/src/lib/playback-mode.ts b/apps/web/src/lib/playback-mode.ts deleted file mode 100644 index ade4a6f..0000000 --- a/apps/web/src/lib/playback-mode.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type PlaybackMode = "legacy" | "sabr"; - -const STORAGE_KEY = "typed-playback-mode"; -const CHANGE_EVENT = "typed:playback-mode-change"; -const CLASSIC_REQUEST_EVENT = "typed:classic-playback-request"; - -function canUseStorage(): boolean { - return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; -} - -function isPlaybackMode(value: string | null): value is PlaybackMode { - return value === "legacy" || value === "sabr"; -} - -export function readPlaybackMode(): PlaybackMode { - if (!canUseStorage()) return "legacy"; - return resolveStoredPlaybackMode(window.localStorage.getItem(STORAGE_KEY)); -} - -export function resolveStoredPlaybackMode(stored: string | null): PlaybackMode { - if (stored === "adaptive") return "sabr"; - if (stored === "ios-legacy-compat") return "legacy"; - return isPlaybackMode(stored) ? stored : "legacy"; -} - -export function setPlaybackMode(mode: PlaybackMode): void { - if (!canUseStorage()) return; - window.localStorage.setItem(STORAGE_KEY, mode); - window.dispatchEvent(new Event(CHANGE_EVENT)); -} - -export function requestPlaybackMode(mode: PlaybackMode): void { - if (mode === "legacy" && typeof window !== "undefined") { - window.dispatchEvent(new Event(CLASSIC_REQUEST_EVENT)); - return; - } - setPlaybackMode(mode); -} - -export function subscribeClassicPlaybackRequest(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; - window.addEventListener(CLASSIC_REQUEST_EVENT, listener); - return () => window.removeEventListener(CLASSIC_REQUEST_EVENT, listener); -} - -export function subscribePlaybackMode(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; - - const onStorage = (event: StorageEvent) => { - if (event.key !== STORAGE_KEY) return; - listener(); - }; - const onChange = () => listener(); - - window.addEventListener("storage", onStorage); - window.addEventListener(CHANGE_EVENT, onChange); - - return () => { - window.removeEventListener("storage", onStorage); - window.removeEventListener(CHANGE_EVENT, onChange); - }; -} diff --git a/apps/web/src/lib/stream-compatibility.ts b/apps/web/src/lib/stream-compatibility.ts index 91eb24a..bfb11f7 100644 --- a/apps/web/src/lib/stream-compatibility.ts +++ b/apps/web/src/lib/stream-compatibility.ts @@ -1,13 +1,13 @@ import type { VideoStream } from "../types/stream"; import { proxyDashManifest } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; import type { MediaSrc } from "./vidstack"; export function pickCompatibleProgressiveSrc(stream: VideoStream): MediaSrc | null { const progressive = [...(stream.videoStreams ?? [])] .filter( (candidate) => - hasPlayableLegacyUrl(candidate) && + hasPlayableDirectUrl(candidate) && typeof candidate.codec === "string" && candidate.codec.includes("avc1") && candidate.codec.includes("mp4a") && @@ -27,14 +27,14 @@ export function hasCompatibleMp4(stream: VideoStream): boolean { const audios = stream.audioStreams ?? []; const hasMp4Video = videos.some( (video) => - hasPlayableLegacyUrl(video) && + hasPlayableDirectUrl(video) && typeof video.codec === "string" && video.codec.startsWith("avc1") && (video.mimeType?.includes("video/mp4") ?? true), ); const hasMp4Audio = audios.some( (audio) => - hasPlayableLegacyUrl(audio) && + hasPlayableDirectUrl(audio) && typeof audio.codec === "string" && (audio.codec.startsWith("mp4a") || audio.codec === "mp4a") && (audio.mimeType?.includes("audio/mp4") ?? true), diff --git a/apps/web/src/lib/stream-delivery.ts b/apps/web/src/lib/stream-delivery.ts index 949b76f..63ce7a6 100644 --- a/apps/web/src/lib/stream-delivery.ts +++ b/apps/web/src/lib/stream-delivery.ts @@ -11,24 +11,24 @@ function isSabrStream(item: { deliveryMethod?: string | null }): boolean { return item.deliveryMethod === "sabr"; } -export function hasPlayableLegacyUrl(item: DeliveredStream): boolean { +export function hasPlayableDirectUrl(item: DeliveredStream): boolean { return !isSabrStream(item) && item.url.length > 0; } -export function legacyVideoOnlyStreams(stream: VideoStream): VideoStreamItem[] { - return (stream.videoOnlyStreams ?? []).filter(hasPlayableLegacyUrl); +export function directVideoOnlyStreams(stream: VideoStream): VideoStreamItem[] { + return (stream.videoOnlyStreams ?? []).filter(hasPlayableDirectUrl); } -export function legacyAudioStreams(stream: VideoStream): AudioStreamItem[] { - return (stream.audioStreams ?? []).filter(hasPlayableLegacyUrl); +export function directAudioStreams(stream: VideoStream): AudioStreamItem[] { + return (stream.audioStreams ?? []).filter(hasPlayableDirectUrl); } -export function legacyProgressiveStreams(stream: VideoStream): VideoStreamItem[] { - return (stream.videoStreams ?? []).filter(hasPlayableLegacyUrl); +export function directProgressiveStreams(stream: VideoStream): VideoStreamItem[] { + return (stream.videoStreams ?? []).filter(hasPlayableDirectUrl); } -export function hasLegacyDashPair(stream: VideoStream): boolean { - return legacyVideoOnlyStreams(stream).length > 0 && legacyAudioStreams(stream).length > 0; +export function hasDirectDashPair(stream: VideoStream): boolean { + return directVideoOnlyStreams(stream).length > 0 && directAudioStreams(stream).length > 0; } export function hasSabrPlayback(stream: VideoStream): boolean { diff --git a/apps/web/src/lib/stream-fallback-src.ts b/apps/web/src/lib/stream-direct-src.ts similarity index 71% rename from apps/web/src/lib/stream-fallback-src.ts rename to apps/web/src/lib/stream-direct-src.ts index f34c31b..64c3bef 100644 --- a/apps/web/src/lib/stream-fallback-src.ts +++ b/apps/web/src/lib/stream-direct-src.ts @@ -3,10 +3,10 @@ import { buildDashManifest } from "./dash-manifest"; import { API_BASE as BASE } from "./env"; import { pickCompactAudioTracks } from "./stream-audio-compact"; import { pickCompatibleProgressiveSrc } from "./stream-compatibility"; -import { legacyAudioStreams, legacyVideoOnlyStreams } from "./stream-delivery"; +import { directAudioStreams, directVideoOnlyStreams } from "./stream-delivery"; import type { MediaSrc } from "./vidstack"; -export function resolveLegacyFallbackSrc( +export function resolveDirectSrc( stream: VideoStream, maxHeight: number | undefined, compactAudioTracks: boolean, @@ -14,14 +14,14 @@ export function resolveLegacyFallbackSrc( maxCompactAudioTracks: number, allowServerManifests: boolean, ): MediaSrc { - const legacyVideos = legacyVideoOnlyStreams(stream); - const legacyAudios = legacyAudioStreams(stream); + const directVideos = directVideoOnlyStreams(stream); + const directAudios = directAudioStreams(stream); const audioStreams = compactAudioTracks - ? pickCompactAudioTracks(legacyAudios, preferredAudioLanguage, maxCompactAudioTracks) - : legacyAudios; + ? pickCompactAudioTracks(directAudios, preferredAudioLanguage, maxCompactAudioTracks) + : directAudios; - if (legacyVideos.length && audioStreams.length) { - const built = buildDashManifest(legacyVideos, audioStreams, stream.duration, maxHeight); + if (directVideos.length && audioStreams.length) { + const built = buildDashManifest(directVideos, audioStreams, stream.duration, maxHeight); if (built) return { src: built, type: "application/dash+xml" }; } if (!allowServerManifests) { diff --git a/apps/web/src/lib/stream-request.ts b/apps/web/src/lib/stream-request.ts index f58f043..5f541a6 100644 --- a/apps/web/src/lib/stream-request.ts +++ b/apps/web/src/lib/stream-request.ts @@ -1,14 +1,9 @@ import { API_BASE as BASE } from "./env"; -import type { PlaybackMode } from "./playback-mode"; import { detectProvider } from "./provider"; -function effectivePlaybackMode(url: string, playbackMode: PlaybackMode): PlaybackMode { - return playbackMode === "sabr" && detectProvider(url) === "youtube" ? "sabr" : "legacy"; -} - -export function streamEndpoint(url: string, playbackMode: PlaybackMode): string { +export function streamEndpoint(url: string): string { const provider = detectProvider(url); - const path = providerStreamPath(provider, effectivePlaybackMode(url, playbackMode)); + const path = providerStreamPath(provider); return `${BASE}${path}?url=${encodeURIComponent(url)}`; } @@ -17,22 +12,18 @@ export function sabrBootstrapEndpoint(url: string): string | null { return `${BASE}/streams/youtube/sabr/bootstrap?url=${encodeURIComponent(url)}`; } -function providerStreamPath( - provider: ReturnType, - playbackMode: PlaybackMode, -) { - if (provider === "youtube") return `/streams/youtube/${playbackMode}`; +function providerStreamPath(provider: ReturnType) { + if (provider === "youtube") return "/streams/youtube/sabr"; if (provider === "nicovideo") return "/streams/niconico"; if (provider === "bilibili") return "/streams/bilibili"; - return "/streams/youtube/legacy"; + throw new Error("Unsupported video provider"); } export function streamQueryKey( url: string, authenticated: boolean, - playbackMode: PlaybackMode, -): readonly ["stream", string, "auth" | "anon", PlaybackMode] { - return ["stream", url, authenticated ? "auth" : "anon", effectivePlaybackMode(url, playbackMode)]; +): readonly ["stream", string, "auth" | "anon"] { + return ["stream", url, authenticated ? "auth" : "anon"]; } export function sabrBootstrapQueryKey( diff --git a/apps/web/src/lib/stream-src.ts b/apps/web/src/lib/stream-src.ts index 18241d3..72374b1 100644 --- a/apps/web/src/lib/stream-src.ts +++ b/apps/web/src/lib/stream-src.ts @@ -5,8 +5,8 @@ import { buildNicoHlsManifest } from "./nico-hls-manifest"; import { detectProvider } from "./provider"; import { proxyDashManifest } from "./proxy"; import { hasCompatibleMp4, pickCompatibleProgressiveSrc } from "./stream-compatibility"; -import { hasLegacyDashPair } from "./stream-delivery"; -import { resolveLegacyFallbackSrc } from "./stream-fallback-src"; +import { hasDirectDashPair } from "./stream-delivery"; +import { resolveDirectSrc } from "./stream-direct-src"; import type { MediaSrc } from "./vidstack"; type ResolveManifestOptions = { @@ -36,14 +36,14 @@ export function resolveHlsManifestUrl(stream: VideoStream): string { return proxyDashManifest(`${BASE}/streams/hls-manifest?url=${encodeURIComponent(stream.id)}`); } -export function shouldUseClassicHls( +export function shouldUseHls( hlsUrl: string | undefined, isLive: boolean, hlsFailed: boolean, - legacyDashPair: boolean, + directDashPair: boolean, ): boolean { return Boolean( - hlsUrl && !hlsFailed && (isLive || isSignedHlsManifestUrl(hlsUrl) || !legacyDashPair), + hlsUrl && !hlsFailed && (isLive || isSignedHlsManifestUrl(hlsUrl) || !directDashPair), ); } @@ -60,10 +60,9 @@ export function resolveManifestSrc( const allowServerManifests = options?.allowServerManifests ?? true; const provider = detectProvider(stream.id); const safeMaxHeight = qualityFailed ? 720 : 1080; - const legacyDashPair = hasLegacyDashPair(stream); - const allowLegacyServerManifests = allowServerManifests; + const directDashPair = hasDirectDashPair(stream); - if (shouldUseClassicHls(stream.hlsUrl, isLive, options?.hlsFailed ?? false, legacyDashPair)) { + if (shouldUseHls(stream.hlsUrl, isLive, options?.hlsFailed ?? false, directDashPair)) { return { src: resolveHlsManifestUrl(stream), type: "application/x-mpegurl", @@ -101,8 +100,8 @@ export function resolveManifestSrc( provider === "youtube" && options?.enableHighQualityPlayback && !options.highQualityFailed && - allowLegacyServerManifests && - legacyDashPair && + allowServerManifests && + directDashPair && !compatibilityMode ) { return { @@ -111,19 +110,19 @@ export function resolveManifestSrc( }; } - if (!hasCompatibleMp4(stream) && allowLegacyServerManifests) { + if (!hasCompatibleMp4(stream) && allowServerManifests) { return { src: proxyDashManifest(`${BASE}/streams/manifest?url=${encodeURIComponent(stream.id)}`), type: "application/dash+xml", }; } - return resolveLegacyFallbackSrc( + return resolveDirectSrc( stream, safeMaxHeight, compactAudioTracks, options?.preferredAudioLanguage, maxCompactAudioTracks, - allowLegacyServerManifests, + allowServerManifests, ); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2489d97..b790577 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,7 +5,6 @@ import { AuthBackdrop } from "../components/auth-backdrop"; import { GuestDisabledScreen } from "../components/guest-disabled-screen"; import { MobileTabBar } from "../components/mobile-tab-bar"; import { Navbar } from "../components/navbar"; -import { PlaybackTransitionNotice } from "../components/playback-transition-notice"; import { Sidebar } from "../components/sidebar"; import { useAuth } from "../hooks/use-auth"; import { useInstance } from "../hooks/use-instance"; @@ -144,7 +143,6 @@ function RootLayout() { return (
-
@@ -179,7 +177,6 @@ function RootLayout() {
- {watchCinemaPage ? !isMobile && : }
diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index edb96c3..7684330 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -5,14 +5,12 @@ import { EmbedLoading } from "../components/embed-loading"; import { EmbedPlayerShell } from "../components/embed-player-shell"; import { useAuth } from "../hooks/use-auth"; import { useInstance } from "../hooks/use-instance"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; import { useSettings } from "../hooks/use-settings"; import { isStreamUnavailableError, useSabrBootstrap, useStream } from "../hooks/use-stream"; import { FAMILY_LIST_BLOCKED_MESSAGE, isChannelNotAllowedError } from "../lib/allow-list-error"; import { ApiError } from "../lib/api"; import { isYoutubeSessionReconnectError } from "../lib/api-youtube-session"; import { isEmbeddedFrame, resolveEmbedAccess } from "../lib/embed-access"; -import { resolveEmbedPlaybackMode } from "../lib/embed-playback"; import { parseStartTime } from "../lib/parse-start-time"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; @@ -59,28 +57,16 @@ function EmbedPage() { isGuest, settingsReady, }); - const { playbackMode: storedPlaybackMode } = usePlaybackMode(); - const playbackMode = resolveEmbedPlaybackMode(framed, storedPlaybackMode); const useAuthenticatedStream = access.sessionEnabled && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); - const streamQuery = useStream( - sourceUrl, - useAuthenticatedStream, - access.streamEnabled, - playbackMode, - ); - const bootstrap = useSabrBootstrap( - sourceUrl, - useAuthenticatedStream, - access.streamEnabled, - playbackMode, - ); + const streamQuery = useStream(sourceUrl, useAuthenticatedStream, access.streamEnabled); + const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, access.streamEnabled); const publicParam = toPublicWatchParam(sourceUrl); const availabilityPoster = proxyImage(youtubeThumbnailUrl(publicParam) ?? ""); const activeStream = selectProgressiveWatchStream( streamQuery.isPlaceholderData ? undefined : streamQuery.data, - playbackMode === "sabr" ? bootstrap.data : undefined, + bootstrap.data, publicParam, [], ); @@ -158,7 +144,6 @@ function EmbedPage() { startTime={startTime} autoplay={shouldAutoplay} sessionEnabled={access.sessionEnabled} - playbackMode={playbackMode} /> ); } diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 2208ee6..c7ddd6a 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -7,7 +7,6 @@ import { useAuth } from "../hooks/use-auth"; import { useDocumentTitle } from "../hooks/use-document-title"; import { useHistory } from "../hooks/use-history"; import { useInstance } from "../hooks/use-instance"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; import { useProgress } from "../hooks/use-progress"; import { useSettings } from "../hooks/use-settings"; import { useSabrBootstrap, useStream } from "../hooks/use-stream"; @@ -31,17 +30,11 @@ function WatchPage() { const { data: instance, isPending: instancePending } = useInstance(); const { settings, settingsReady } = useSettings(); const navigationSnapshot = useWatchNavigationStore((state) => state.snapshot); - const { playbackMode } = usePlaybackMode(); const useAuthenticatedStream = isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); - const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled, playbackMode); - const bootstrap = useSabrBootstrap( - sourceUrl, - useAuthenticatedStream, - streamEnabled, - playbackMode, - ); + const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled); + const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); const previewMatches = @@ -53,7 +46,7 @@ function WatchPage() { ); const activeStream = selectProgressiveWatchStream( streamQuery.isPlaceholderData ? undefined : streamQuery.data, - playbackMode === "sabr" ? bootstrap.data : undefined, + bootstrap.data, publicParam, previewRelated, ); diff --git a/apps/web/src/settings/settings-playback.tsx b/apps/web/src/settings/settings-playback.tsx index 05e8d4c..425ebeb 100644 --- a/apps/web/src/settings/settings-playback.tsx +++ b/apps/web/src/settings/settings-playback.tsx @@ -1,6 +1,5 @@ import { ChevronDown } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; import { useSettings } from "../hooks/use-settings"; import { PLAYBACK_ROW, PlaybackNumberRow, PlaybackToggleRow } from "./settings-playback-row"; @@ -73,8 +72,6 @@ function QualityDropdown({ value, onChange }: DropdownProps) { export function SettingsPlayback() { const { settings, update } = useSettings(); - const { playbackMode, setMode } = usePlaybackMode(); - const sabrEnabled = playbackMode === "sabr"; const autoplayCountdownSeconds = Math.min( 60, Math.max(0, Math.round(settings.autoplayCountdownSeconds)), @@ -124,12 +121,6 @@ export function SettingsPlayback() { onChange={(q) => update.mutate({ defaultQuality: q })} />
- setMode(sabrEnabled ? "legacy" : "sabr")} - /> ); diff --git a/apps/web/tests/embed-playback.test.ts b/apps/web/tests/embed-playback.test.ts index 6b1d3cf..db6037c 100644 --- a/apps/web/tests/embed-playback.test.ts +++ b/apps/web/tests/embed-playback.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { resolveEmbedAutoplay, resolveEmbedPlaybackMode } from "../src/lib/embed-playback"; +import { resolveEmbedAutoplay } from "../src/lib/embed-playback"; test("uses the requested autoplay value for the initial player", () => { expect(resolveEmbedAutoplay(0, false, true)).toBe(true); @@ -14,9 +14,3 @@ test("keeps the latest playback intent when retrying", () => { expect(resolveEmbedAutoplay(2, false, true)).toBe(false); expect(resolveEmbedAutoplay(2, true, false)).toBe(true); }); - -test("uses SABR for framed playback", () => { - expect(resolveEmbedPlaybackMode(true, "legacy")).toBe("sabr"); - expect(resolveEmbedPlaybackMode(true, "sabr")).toBe("sabr"); - expect(resolveEmbedPlaybackMode(false, "legacy")).toBe("legacy"); -}); diff --git a/apps/web/tests/stream-hls-transport.test.ts b/apps/web/tests/stream-hls-transport.test.ts index ec58d69..c5d1eae 100644 --- a/apps/web/tests/stream-hls-transport.test.ts +++ b/apps/web/tests/stream-hls-transport.test.ts @@ -1,14 +1,14 @@ import { expect, test } from "bun:test"; -import { shouldUseClassicHls } from "../src/lib/stream-src"; +import { shouldUseHls } from "../src/lib/stream-src"; -test("uses classic HLS when DASH is unavailable", () => { - expect(shouldUseClassicHls("https://example.com/master.m3u8", false, false, false)).toBe(true); +test("uses HLS when DASH is unavailable", () => { + expect(shouldUseHls("https://example.com/master.m3u8", false, false, false)).toBe(true); }); -test("keeps DASH ahead of unsigned classic HLS", () => { - expect(shouldUseClassicHls("https://example.com/master.m3u8", false, false, true)).toBe(false); +test("keeps DASH ahead of unsigned HLS", () => { + expect(shouldUseHls("https://example.com/master.m3u8", false, false, true)).toBe(false); }); -test("does not retry failed classic HLS", () => { - expect(shouldUseClassicHls("https://example.com/master.m3u8", false, true, false)).toBe(false); +test("does not retry failed HLS", () => { + expect(shouldUseHls("https://example.com/master.m3u8", false, true, false)).toBe(false); }); diff --git a/apps/web/tests/stream-playback-mode.test.ts b/apps/web/tests/stream-playback-mode.test.ts index 8a63e1f..f05954a 100644 --- a/apps/web/tests/stream-playback-mode.test.ts +++ b/apps/web/tests/stream-playback-mode.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "bun:test"; -import { resolveStoredPlaybackMode } from "../src/lib/playback-mode"; import { sabrBootstrapEndpoint, sabrBootstrapQueryKey, @@ -9,22 +8,12 @@ import { const VIDEO_URL = "https://www.youtube.com/watch?v=test"; -test("uses isolated stream endpoints and query keys for each playback mode", () => { - expect(streamEndpoint(VIDEO_URL, "legacy")).toContain("/streams/youtube/legacy?url="); - expect(streamEndpoint(VIDEO_URL, "sabr")).toContain("/streams/youtube/sabr?url="); - expect(streamQueryKey(VIDEO_URL, false, "legacy")).toEqual([ - "stream", - VIDEO_URL, - "anon", - "legacy", - ]); - expect(streamQueryKey(VIDEO_URL, false, "sabr")).toEqual(["stream", VIDEO_URL, "anon", "sabr"]); - expect(streamEndpoint("https://www.nicovideo.jp/watch/test", "sabr")).toContain( - "/streams/niconico?url=", - ); - expect(streamEndpoint("https://www.bilibili.com/video/test", "sabr")).toContain( - "/streams/bilibili?url=", - ); +test("uses SABR for YouTube and provider-specific direct endpoints", () => { + expect(streamEndpoint(VIDEO_URL)).toContain("/streams/youtube/sabr?url="); + expect(streamQueryKey(VIDEO_URL, false)).toEqual(["stream", VIDEO_URL, "anon"]); + expect(streamEndpoint("https://www.nicovideo.jp/watch/test")).toContain("/streams/niconico?url="); + expect(streamEndpoint("https://www.bilibili.com/video/test")).toContain("/streams/bilibili?url="); + expect(() => streamEndpoint("https://example.com/video")).toThrow("Unsupported video provider"); }); test("uses an isolated YouTube SABR bootstrap request", () => { @@ -32,9 +21,3 @@ test("uses an isolated YouTube SABR bootstrap request", () => { expect(sabrBootstrapQueryKey(VIDEO_URL, true)).toEqual(["stream-bootstrap", VIDEO_URL, "auth"]); expect(sabrBootstrapEndpoint("https://www.nicovideo.jp/watch/test")).toBeNull(); }); - -test("defaults new users to classic and migrates old playback values", () => { - expect(resolveStoredPlaybackMode(null)).toBe("legacy"); - expect(resolveStoredPlaybackMode("adaptive")).toBe("sabr"); - expect(resolveStoredPlaybackMode("ios-legacy-compat")).toBe("legacy"); -}); diff --git a/apps/web/tests/watch-player-key.test.ts b/apps/web/tests/watch-player-key.test.ts index 1c062ad..5987207 100644 --- a/apps/web/tests/watch-player-key.test.ts +++ b/apps/web/tests/watch-player-key.test.ts @@ -37,7 +37,7 @@ describe("buildWatchPlayerKey", () => { ).toBe(buildWatchPlayerKey(base)); }); - test("ignores the unused classic manifest identity for sabr", () => { + test("ignores the unused direct manifest identity for sabr", () => { const base = { streamId: "video", retryKey: 0, From 0a9081dbe2358f844d6d0785063de9a7ea8d0e07 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 28 Jul 2026 13:20:07 +0200 Subject: [PATCH 04/17] feat: show component versions in settings --- apps/web/src/hooks/use-version-info.ts | 12 +++++ apps/web/src/lib/api-version.ts | 61 ++++++++++++++++++++++++ apps/web/src/lib/settings-section.ts | 6 ++- apps/web/src/routes/settings.tsx | 3 ++ apps/web/src/settings/settings-about.tsx | 55 +++++++++++++++++++++ apps/web/src/settings/settings-nav.tsx | 2 +- apps/web/tests/api-version.test.ts | 45 +++++++++++++++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/hooks/use-version-info.ts create mode 100644 apps/web/src/lib/api-version.ts create mode 100644 apps/web/src/settings/settings-about.tsx create mode 100644 apps/web/tests/api-version.test.ts diff --git a/apps/web/src/hooks/use-version-info.ts b/apps/web/src/hooks/use-version-info.ts new file mode 100644 index 0000000..7db88eb --- /dev/null +++ b/apps/web/src/hooks/use-version-info.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchVersionInfo } from "../lib/api-version"; + +const VERSION_INFO_KEY = ["version-info"]; + +export function useVersionInfo() { + return useQuery({ + queryKey: VERSION_INFO_KEY, + queryFn: fetchVersionInfo, + staleTime: 60_000, + }); +} diff --git a/apps/web/src/lib/api-version.ts b/apps/web/src/lib/api-version.ts new file mode 100644 index 0000000..1d65862 --- /dev/null +++ b/apps/web/src/lib/api-version.ts @@ -0,0 +1,61 @@ +import { request } from "./api"; +import { API_BASE as BASE } from "./env"; + +const VERSION_COMPONENTS = ["frontend", "server", "token", "downloader"] as const; + +export type VersionComponent = (typeof VERSION_COMPONENTS)[number]; + +export type ComponentVersion = { + service: string; + version: string; + revision: string; + shortRevision: string; + buildTime: string; +}; + +export type VersionInfo = Record; + +const VERSION_PATHS: Record = { + frontend: "web", + server: "server", + token: "token", + downloader: "downloader", +}; + +export function parseComponentVersion(value: unknown): ComponentVersion | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + if ( + typeof candidate.service !== "string" || + typeof candidate.version !== "string" || + typeof candidate.revision !== "string" || + typeof candidate.shortRevision !== "string" || + typeof candidate.buildTime !== "string" + ) { + return null; + } + return { + service: candidate.service, + version: candidate.version, + revision: candidate.revision, + shortRevision: candidate.shortRevision, + buildTime: candidate.buildTime, + }; +} + +async function fetchComponentVersion(component: VersionComponent): Promise { + const payload = await request(`${BASE}/version/${VERSION_PATHS[component]}`); + const version = parseComponentVersion(payload); + if (!version) throw new Error(`Invalid ${component} version payload`); + return version; +} + +export async function fetchVersionInfo(): Promise { + const results = await Promise.allSettled(VERSION_COMPONENTS.map(fetchComponentVersion)); + return Object.fromEntries( + VERSION_COMPONENTS.map((component, index) => [ + component, + results[index].status === "fulfilled" ? results[index].value : null, + ]), + ) as VersionInfo; +} diff --git a/apps/web/src/lib/settings-section.ts b/apps/web/src/lib/settings-section.ts index 473e1a9..cea73c2 100644 --- a/apps/web/src/lib/settings-section.ts +++ b/apps/web/src/lib/settings-section.ts @@ -6,7 +6,8 @@ export type SettingsSection = | "service" | "import" | "privacy" - | "blocked"; + | "blocked" + | "about"; const SETTINGS_SECTION_KEY = "typetype-settings-section"; @@ -19,7 +20,8 @@ export function isSettingsSection(value: unknown): value is SettingsSection { value === "service" || value === "import" || value === "privacy" || - value === "blocked" + value === "blocked" || + value === "about" ); } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 386e248..962ec2d 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -8,6 +8,7 @@ import { rememberSettingsSection, type SettingsSection, } from "../lib/settings-section"; +import { SettingsAbout } from "../settings/settings-about"; import { SettingsBlocked } from "../settings/settings-blocked"; import { SettingsLandingPage } from "../settings/settings-landing-page"; import { SettingsLanguage } from "../settings/settings-language"; @@ -30,6 +31,7 @@ const BASE_ITEMS: Item[] = [ { key: "import", label: "Import" }, { key: "privacy", label: "Privacy" }, { key: "blocked", label: "Blocked" }, + { key: "about", label: "About" }, ]; function settingsItems(showLanguage: boolean): Item[] { @@ -96,6 +98,7 @@ function SettingsPage() { {activeSection === "import" && } {activeSection === "privacy" && } {activeSection === "blocked" && } + {activeSection === "about" && } ); } diff --git a/apps/web/src/settings/settings-about.tsx b/apps/web/src/settings/settings-about.tsx new file mode 100644 index 0000000..0995010 --- /dev/null +++ b/apps/web/src/settings/settings-about.tsx @@ -0,0 +1,55 @@ +import { RefreshCw } from "lucide-react"; +import { useVersionInfo } from "../hooks/use-version-info"; +import type { ComponentVersion, VersionComponent } from "../lib/api-version"; + +const COMPONENTS: { key: VersionComponent; label: string }[] = [ + { key: "frontend", label: "Frontend" }, + { key: "server", label: "Server" }, + { key: "token", label: "Token" }, + { key: "downloader", label: "Downloader" }, +]; + +function VersionDetails({ version }: { version: ComponentVersion | null }) { + if (!version) return Unavailable; + return ( +
+

{version.version}

+

{version.revision}

+

{version.buildTime}

+
+ ); +} + +export function SettingsAbout() { + const query = useVersionInfo(); + + return ( +
+
+

Versions

+ +
+
+ {COMPONENTS.map(({ key, label }) => ( +
+ {label} + {query.isPending ? ( + Loading... + ) : ( + + )} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/settings/settings-nav.tsx b/apps/web/src/settings/settings-nav.tsx index eed8b60..2119583 100644 --- a/apps/web/src/settings/settings-nav.tsx +++ b/apps/web/src/settings/settings-nav.tsx @@ -14,7 +14,7 @@ type Props = { export function SettingsNav({ items, active, onSelect }: Props) { return (