From ce676b965aaf5be8f3bd1285ac8f64df083ead65 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 09:14:32 +0200 Subject: [PATCH 01/18] feat: add embed player wrapper --- apps/web/src/components/embed-player.tsx | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/web/src/components/embed-player.tsx diff --git a/apps/web/src/components/embed-player.tsx b/apps/web/src/components/embed-player.tsx new file mode 100644 index 0000000..37fcbfe --- /dev/null +++ b/apps/web/src/components/embed-player.tsx @@ -0,0 +1,30 @@ +import { VideoPlayer } from "./video-player"; +import type { VideoPlayerProps } from "./video-player-types"; + +type Props = VideoPlayerProps & { + playerKey?: string | number; + watchUrl?: string; +}; + +export function EmbedPlayer({ playerKey, watchUrl, ...props }: Props) { + const overlay = + props.title && watchUrl ? ( + + {props.title} + + ) : null; + + return ( +
+ +
+ ); +} From 2f0bdadd19f41307268cb6fc68d80f3ed115b826 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 09:29:18 +0200 Subject: [PATCH 02/18] feat: add embed player shell --- .../web/src/components/embed-player-shell.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/web/src/components/embed-player-shell.tsx diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx new file mode 100644 index 0000000..e86dd8d --- /dev/null +++ b/apps/web/src/components/embed-player-shell.tsx @@ -0,0 +1,84 @@ +import { usePlayerError } from "../hooks/use-player-error"; +import { useSabrPlaybackConfig } from "../hooks/use-sabr-playback-config"; +import { useSettings } from "../hooks/use-settings"; +import { useVolumeSync } from "../hooks/use-volume-sync"; +import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; +import { useWatchVttAssets } from "../hooks/use-watch-layout-assets"; +import { getOriginalAudioLocale } from "../lib/audio-track"; +import type { VideoStream } from "../types/stream"; +import { toPublicWatchParam } from "../lib/watch-url"; +import { EmbedPlayer } from "./embed-player"; + +type Props = { + stream: VideoStream; + sourceUrl: string; + startTime: number; + autoplay: boolean; + isAuthed: boolean; +}; + +export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAuthed }: Props) { + const { settings, settingsReady, update } = useSettings(); + const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream"; + const player = usePlayerError(stream, isLive); + const handleVolumeChange = useVolumeSync(update.mutate); + + const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; + + const sponsor = useWatchSponsorBlock(stream, settings); + const autoSkipSponsorBlock = isAuthed && settings.sponsorBlockMode !== "disabled"; + + const { thumbnailVtt, chaptersVtt } = useWatchVttAssets( + stream, + sponsor.segments, + settings.sponsorBlockShowChapters, + ); + + const sabrConfig = useSabrPlaybackConfig( + stream, + player.sabrEnabled, + settings.defaultQuality, + settings.defaultAudioLanguage, + false, + ); + + const playerKey = [ + stream.id, + player.retryKey, + player.sabrEnabled ? "sabr" : "std", + thumbnailVtt ? "thumbs" : "no-thumbs", + chaptersVtt ? "chapters" : "no-chapters", + ].join(":"); + + return ( + update.mutate({ captionStyles })} + onVolumeChange={handleVolumeChange} + onError={player.handleError} + watchUrl={watchUrl} + /> + ); +} From 693df175c1caebe4155d543f03db85040718e2d2 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 09:41:05 +0200 Subject: [PATCH 03/18] feat: add embed route --- apps/web/src/routeTree.gen.ts | 21 ++++ apps/web/src/routes/embed_.$videoId.tsx | 143 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 apps/web/src/routes/embed_.$videoId.tsx diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 219527c..5d8a45b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as SubscriptionsChannelsRouteImport } from './routes/subscription import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id' import { Route as ImportYoutubeRouteImport } from './routes/import/youtube' import { Route as ImportPipepipeRouteImport } from './routes/import/pipepipe' +import { Route as EmbedVideoIdRouteImport } from './routes/embed_.$videoId' import { Route as ChannelChannelIdRouteImport } from './routes/channel_.$channelId' import { Route as AuthOidcCallbackRouteImport } from './routes/auth.oidc.callback' @@ -174,6 +175,11 @@ const ImportPipepipeRoute = ImportPipepipeRouteImport.update({ path: '/pipepipe', getParentRoute: () => ImportRoute, } as any) +const EmbedVideoIdRoute = EmbedVideoIdRouteImport.update({ + id: '/embed_/$videoId', + path: '/embed/$videoId', + getParentRoute: () => rootRouteImport, +} as any) const ChannelChannelIdRoute = ChannelChannelIdRouteImport.update({ id: '/channel_/$channelId', path: '/channel/$channelId', @@ -209,6 +215,7 @@ export interface FileRoutesByFullPath { '/watch-later': typeof WatchLaterRoute '/youtube-session': typeof YoutubeSessionRoute '/channel/$channelId': typeof ChannelChannelIdRoute + '/embed/$videoId': typeof EmbedVideoIdRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute @@ -239,6 +246,7 @@ export interface FileRoutesByTo { '/watch-later': typeof WatchLaterRoute '/youtube-session': typeof YoutubeSessionRoute '/channel/$channelId': typeof ChannelChannelIdRoute + '/embed/$videoId': typeof EmbedVideoIdRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute @@ -271,6 +279,7 @@ export interface FileRoutesById { '/watch-later': typeof WatchLaterRoute '/youtube-session': typeof YoutubeSessionRoute '/channel_/$channelId': typeof ChannelChannelIdRoute + '/embed_/$videoId': typeof EmbedVideoIdRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists_/$id': typeof PlaylistsIdRoute @@ -304,6 +313,7 @@ export interface FileRouteTypes { | '/watch-later' | '/youtube-session' | '/channel/$channelId' + | '/embed/$videoId' | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' @@ -334,6 +344,7 @@ export interface FileRouteTypes { | '/watch-later' | '/youtube-session' | '/channel/$channelId' + | '/embed/$videoId' | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' @@ -365,6 +376,7 @@ export interface FileRouteTypes { | '/watch-later' | '/youtube-session' | '/channel_/$channelId' + | '/embed_/$videoId' | '/import/pipepipe' | '/import/youtube' | '/playlists_/$id' @@ -397,6 +409,7 @@ export interface RootRouteChildren { WatchLaterRoute: typeof WatchLaterRoute YoutubeSessionRoute: typeof YoutubeSessionRoute ChannelChannelIdRoute: typeof ChannelChannelIdRoute + EmbedVideoIdRoute: typeof EmbedVideoIdRoute PlaylistsIdRoute: typeof PlaylistsIdRoute SubscriptionsChannelsRoute: typeof SubscriptionsChannelsRoute AuthOidcCallbackRoute: typeof AuthOidcCallbackRoute @@ -593,6 +606,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ImportPipepipeRouteImport parentRoute: typeof ImportRoute } + '/embed_/$videoId': { + id: '/embed_/$videoId' + path: '/embed/$videoId' + fullPath: '/embed/$videoId' + preLoaderRoute: typeof EmbedVideoIdRouteImport + parentRoute: typeof rootRouteImport + } '/channel_/$channelId': { id: '/channel_/$channelId' path: '/channel/$channelId' @@ -649,6 +669,7 @@ const rootRouteChildren: RootRouteChildren = { WatchLaterRoute: WatchLaterRoute, YoutubeSessionRoute: YoutubeSessionRoute, ChannelChannelIdRoute: ChannelChannelIdRoute, + EmbedVideoIdRoute: EmbedVideoIdRoute, PlaylistsIdRoute: PlaylistsIdRoute, SubscriptionsChannelsRoute: SubscriptionsChannelsRoute, AuthOidcCallbackRoute: AuthOidcCallbackRoute, diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx new file mode 100644 index 0000000..72cfd58 --- /dev/null +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -0,0 +1,143 @@ +import { createFileRoute } from "@tanstack/react-router"; +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 { MEMBER_ONLY_MESSAGE, useSabrBootstrap, useStream } from "../hooks/use-stream"; +import { ApiError } from "../lib/api"; +import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; +import { toPublicWatchParam, toWatchSourceUrl } from "../lib/watch-url"; + +type EmbedSearch = { + t?: string | number; + autoplay?: number; +}; + +function parseStartTime(raw?: string | number): number { + if (raw == null) return 0; + if (typeof raw === "number") return Math.max(0, raw); + const trimmed = raw.trim(); + if (!trimmed) return 0; + const num = Number(trimmed); + if (Number.isFinite(num)) return Math.max(0, num); + const match = trimmed.match(/^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s?)?$/); + if (!match) return 0; + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0); + return hours * 3600 + minutes * 60 + seconds; +} + +function EmbedLoading() { + return ( +
+
+
+
+
+ ); +} + +function EmbedError({ message }: { message: string }) { + return ( +
+
+

{message}

+
+
+ ); +} + +function EmbedAuthRequired({ watchUrl }: { watchUrl: string }) { + return ( +
+
+

Embed unavailable

+

+ This instance does not allow guest access, which is required for embedded playback. +

+ + Go to video + +
+
+ ); +} + +function EmbedPage() { + const { videoId } = Route.useParams(); + const { t, autoplay } = Route.useSearch(); + const sourceUrl = toWatchSourceUrl(videoId); + const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; + const { data: instance, isPending: instancePending } = useInstance(); + const { authReady, isAuthed } = useAuth(); + const { settings, settingsReady } = useSettings(); + const { playbackMode } = usePlaybackMode(); + const guestAllowed = instance?.guestAllowed ?? false; + const useAuthenticatedStream = + isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const streamEnabled = (guestAllowed || isAuthed) && authReady && (!isAuthed || settingsReady); + const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled, playbackMode); + const bootstrap = useSabrBootstrap( + sourceUrl, + useAuthenticatedStream, + streamEnabled, + playbackMode, + ); + const publicParam = toPublicWatchParam(sourceUrl); + const activeStream = selectProgressiveWatchStream( + streamQuery.isPlaceholderData ? undefined : streamQuery.data, + playbackMode === "sabr" ? bootstrap.data : undefined, + publicParam, + [], + ); + const startTime = parseStartTime(t) * 1000; + const shouldAutoplay = autoplay === 1; + + if (instancePending || !instance) return ; + + if (!guestAllowed && !isAuthed) return ; + + if (!activeStream) { + const activeError = streamQuery.error ?? bootstrap.error; + if ( + activeError instanceof ApiError && + (activeError.status === 401 || activeError.status === 403) + ) { + return ; + } + const message = + activeError instanceof ApiError && (activeError.status === 400 || activeError.status === 422) + ? activeError.message + : "Failed to load video."; + return ; + } + + if (activeStream.requiresMembership) { + return ; + } + + return ( + + ); +} + +export const Route = createFileRoute("/embed_/$videoId")({ + validateSearch: (search: Record): EmbedSearch => ({ + t: typeof search.t === "string" || typeof search.t === "number" ? search.t : undefined, + autoplay: typeof search.autoplay === "number" ? search.autoplay : undefined, + }), + component: EmbedPage, +}); From 9f5da9584a3346e7fcd43be8d75feef38a61cef4 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 09:53:47 +0200 Subject: [PATCH 04/18] fix: add embed layout to root --- apps/web/src/routes/__root.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5c2df1d..69df475 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -44,6 +44,7 @@ function RootLayout() { const pathWithSearch = `${pathname}${location.searchStr}`; const hideEverythingPage = pathname === "/hide-everything"; const shortsPage = pathname === "/shorts"; + const embedPage = pathname.startsWith("/embed/"); const watchCinemaPage = pathname === "/watch" && cinemaMode; const wasWatchCinemaPage = useRef(watchCinemaPage); useSessionActivityReporting(); @@ -119,7 +120,7 @@ function RootLayout() { const authPage = isAuthPage(pathname); - if (instance?.guestAllowed === false && (!isAuthed || isGuest) && !authPage) { + if (instance?.guestAllowed === false && (!isAuthed || isGuest) && !authPage && !embedPage) { return ; } @@ -148,8 +149,16 @@ function RootLayout() { ); } + if (embedPage) { + return ( +
+ +
+ ); + } + const topPadding = { paddingTop: "calc(3.5rem + env(safe-area-inset-top, 0px))" }; - const showTabBar = isMobile && !shortsPage && !watchCinemaPage; + const showTabBar = isMobile && !shortsPage && !watchCinemaPage && !embedPage; const mainBottomPad = showTabBar ? "pb-[calc(env(safe-area-inset-bottom)+4.5rem)]" : "pb-5 sm:pb-6"; From b2dc9236329191a8005489d711b9ae0094977d55 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 13:03:44 +0200 Subject: [PATCH 05/18] fix: show loading state while embed stream is pending --- apps/web/src/routes/embed_.$videoId.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index 72cfd58..253ec8e 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -104,6 +104,9 @@ function EmbedPage() { if (!guestAllowed && !isAuthed) return ; + const pending = streamQuery.isLoading || bootstrap.isLoading; + if (!activeStream && (!streamEnabled || pending)) return ; + if (!activeStream) { const activeError = streamQuery.error ?? bootstrap.error; if ( From d8b58d82fc68ca16969141def8661ced920f487a Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 13:12:24 +0200 Subject: [PATCH 06/18] refactor: rename some embed components --- apps/web/src/components/embed-player-shell.tsx | 4 ++-- apps/web/src/components/embed-player.tsx | 2 +- apps/web/src/routes/embed_.$videoId.tsx | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index e86dd8d..baebbff 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -7,7 +7,7 @@ import { useWatchVttAssets } from "../hooks/use-watch-layout-assets"; import { getOriginalAudioLocale } from "../lib/audio-track"; import type { VideoStream } from "../types/stream"; import { toPublicWatchParam } from "../lib/watch-url"; -import { EmbedPlayer } from "./embed-player"; +import { EmbedVideoPlayer } from "./embed-player"; type Props = { stream: VideoStream; @@ -51,7 +51,7 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut ].join(":"); return ( -
@@ -102,7 +102,7 @@ function EmbedPage() { if (instancePending || !instance) return ; - if (!guestAllowed && !isAuthed) return ; + if (!guestAllowed && !isAuthed) return ; const pending = streamQuery.isLoading || bootstrap.isLoading; if (!activeStream && (!streamEnabled || pending)) return ; @@ -113,7 +113,7 @@ function EmbedPage() { activeError instanceof ApiError && (activeError.status === 401 || activeError.status === 403) ) { - return ; + return ; } const message = activeError instanceof ApiError && (activeError.status === 400 || activeError.status === 422) From 74a4832cfa250d7e760197fb0eebe965abce9974 Mon Sep 17 00:00:00 2001 From: tam1m Date: Wed, 15 Jul 2026 20:08:22 +0200 Subject: [PATCH 07/18] fix: lint --- apps/web/src/components/embed-player-shell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index baebbff..6eb8d8c 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -2,11 +2,11 @@ import { usePlayerError } from "../hooks/use-player-error"; import { useSabrPlaybackConfig } from "../hooks/use-sabr-playback-config"; import { useSettings } from "../hooks/use-settings"; import { useVolumeSync } from "../hooks/use-volume-sync"; -import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { useWatchVttAssets } from "../hooks/use-watch-layout-assets"; +import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { getOriginalAudioLocale } from "../lib/audio-track"; -import type { VideoStream } from "../types/stream"; import { toPublicWatchParam } from "../lib/watch-url"; +import type { VideoStream } from "../types/stream"; import { EmbedVideoPlayer } from "./embed-player"; type Props = { From 1f7c336bd219e50d3995cc3a63c2378b88a4c881 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 08:38:24 +0200 Subject: [PATCH 08/18] refactor: extract embed components into separate files --- apps/web/src/components/embed-error.tsx | 13 ++++++ .../src/components/embed-guest-required.tsx | 24 ++++++++++ apps/web/src/components/embed-loading.tsx | 9 ++++ apps/web/src/routes/embed_.$videoId.tsx | 44 ++----------------- 4 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/components/embed-error.tsx create mode 100644 apps/web/src/components/embed-guest-required.tsx create mode 100644 apps/web/src/components/embed-loading.tsx diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx new file mode 100644 index 0000000..dd1c636 --- /dev/null +++ b/apps/web/src/components/embed-error.tsx @@ -0,0 +1,13 @@ +type EmbedErrorProps = { + message: string; +}; + +export function EmbedError({ message }: EmbedErrorProps) { + return ( +
+
+

{message}

+
+
+ ); +} diff --git a/apps/web/src/components/embed-guest-required.tsx b/apps/web/src/components/embed-guest-required.tsx new file mode 100644 index 0000000..69e5962 --- /dev/null +++ b/apps/web/src/components/embed-guest-required.tsx @@ -0,0 +1,24 @@ +type EmbedGuestRequiredProps = { + watchUrl: string; +}; + +export function EmbedGuestRequired({ watchUrl }: EmbedGuestRequiredProps) { + return ( +
+
+

Embed unavailable

+

+ This instance does not allow guest access, which is required for embedded playback. +

+ + Go to video + +
+
+ ); +} diff --git a/apps/web/src/components/embed-loading.tsx b/apps/web/src/components/embed-loading.tsx new file mode 100644 index 0000000..bb6ebea --- /dev/null +++ b/apps/web/src/components/embed-loading.tsx @@ -0,0 +1,9 @@ +export function EmbedLoading() { + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index f872389..992383a 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -1,4 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; +import { EmbedError } from "../components/embed-error"; +import { EmbedGuestRequired } from "../components/embed-guest-required"; +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"; @@ -29,47 +32,6 @@ function parseStartTime(raw?: string | number): number { return hours * 3600 + minutes * 60 + seconds; } -function EmbedLoading() { - return ( -
-
-
-
-
- ); -} - -function EmbedError({ message }: { message: string }) { - return ( -
-
-

{message}

-
-
- ); -} - -function EmbedGuestRequired({ watchUrl }: { watchUrl: string }) { - return ( -
-
-

Embed unavailable

-

- This instance does not allow guest access, which is required for embedded playback. -

- - Go to video - -
-
- ); -} - function EmbedPage() { const { videoId } = Route.useParams(); const { t, autoplay } = Route.useSearch(); From 26b5eb6c7ca026c2708cbe4978d3c97bc4d76dd6 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 11:08:18 +0200 Subject: [PATCH 09/18] fix: mirror watch page error handling and style --- apps/web/src/components/embed-error.tsx | 47 ++++++++++++-- .../src/components/embed-guest-required.tsx | 13 ++-- apps/web/src/routes/embed_.$videoId.tsx | 64 +++++++++++++++---- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx index dd1c636..aea9cf4 100644 --- a/apps/web/src/components/embed-error.tsx +++ b/apps/web/src/components/embed-error.tsx @@ -1,12 +1,51 @@ +import { FAMILY_LIST_BLOCKED_MESSAGE } from "../lib/allow-list-error"; +import { parseGeoRestriction } from "../lib/geo-restriction"; +import { isMemberOnlyMessage } from "../lib/member-only"; +import { FlagIcon } from "./flag-icon"; + type EmbedErrorProps = { message: string; + onRetry?: () => void; }; -export function EmbedError({ message }: EmbedErrorProps) { +export function EmbedError({ message, onRetry }: EmbedErrorProps) { + const countryCode = parseGeoRestriction(message); + const isMemberOnly = isMemberOnlyMessage(message); + const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE; + const imageSrc = familyListBlocked + ? "/family-list-blocked.gif" + : isMemberOnly + ? "/member-only-source.gif" + : "/error-cat.gif"; + return ( -
-
-

{message}

+
+ +
+

+ Couldn't load this video +

+
+ {countryCode && } +

{message}

+
+
+
+ {onRetry && ( + + )}
); diff --git a/apps/web/src/components/embed-guest-required.tsx b/apps/web/src/components/embed-guest-required.tsx index 69e5962..47b7356 100644 --- a/apps/web/src/components/embed-guest-required.tsx +++ b/apps/web/src/components/embed-guest-required.tsx @@ -4,17 +4,20 @@ type EmbedGuestRequiredProps = { export function EmbedGuestRequired({ watchUrl }: EmbedGuestRequiredProps) { return ( -
-
-

Embed unavailable

-

+

+ +
+

Embed unavailable

+

This instance does not allow guest access, which is required for embedded playback.

+
+
Go to video diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index 992383a..f675214 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -7,8 +7,16 @@ 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 { MEMBER_ONLY_MESSAGE, useSabrBootstrap, useStream } from "../hooks/use-stream"; +import { + isMemberOnlyApiError, + isStreamUnavailableError, + MEMBER_ONLY_MESSAGE, + 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 { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { toPublicWatchParam, toWatchSourceUrl } from "../lib/watch-url"; @@ -37,7 +45,12 @@ function EmbedPage() { const { t, autoplay } = Route.useSearch(); const sourceUrl = toWatchSourceUrl(videoId); const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; - const { data: instance, isPending: instancePending } = useInstance(); + const { + data: instance, + isPending: instancePending, + isError: instanceError, + refetch: retryInstance, + } = useInstance(); const { authReady, isAuthed } = useAuth(); const { settings, settingsReady } = useSettings(); const { playbackMode } = usePlaybackMode(); @@ -62,7 +75,10 @@ function EmbedPage() { const startTime = parseStartTime(t) * 1000; const shouldAutoplay = autoplay === 1; - if (instancePending || !instance) return ; + if (instancePending) return ; + + if (instanceError || !instance) + return void retryInstance()} />; if (!guestAllowed && !isAuthed) return ; @@ -71,17 +87,39 @@ function EmbedPage() { if (!activeStream) { const activeError = streamQuery.error ?? bootstrap.error; - if ( + const genericExtractorError = activeError instanceof ApiError && - (activeError.status === 401 || activeError.status === 403) - ) { - return ; - } - const message = - activeError instanceof ApiError && (activeError.status === 400 || activeError.status === 422) - ? activeError.message - : "Failed to load video."; - return ; + activeError.status === 422 && + activeError.message === + "Error occurs when fetching the page. Try increase the loading timeout in Settings."; + const isMemberOnlyError = isMemberOnlyApiError(activeError) || genericExtractorError; + const needsYoutubeSession = isYoutubeSessionReconnectError(activeError); + const familyListBlocked = isChannelNotAllowedError(activeError); + const message = isMemberOnlyError + ? MEMBER_ONLY_MESSAGE + : familyListBlocked + ? FAMILY_LIST_BLOCKED_MESSAGE + : needsYoutubeSession + ? "Connect YouTube to load this browser-only video." + : activeError instanceof ApiError && + (activeError.status === 400 || activeError.status === 422) + ? activeError.message + : isStreamUnavailableError(activeError) + ? "This video is currently unavailable" + : "Failed to load stream."; + return ( + { + void streamQuery.refetch(); + void bootstrap.refetch(); + } + } + /> + ); } if (activeStream.requiresMembership) { From bc63a9014047cd0911ed12ce49f2f0ca5219ce12 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 11:28:05 +0200 Subject: [PATCH 10/18] feat: support start and time_continue aliases for embed start time --- apps/web/src/lib/parse-start-time.ts | 14 ++++++++++++ apps/web/src/routes/embed_.$videoId.tsx | 30 +++++++++++-------------- 2 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/lib/parse-start-time.ts diff --git a/apps/web/src/lib/parse-start-time.ts b/apps/web/src/lib/parse-start-time.ts new file mode 100644 index 0000000..e010358 --- /dev/null +++ b/apps/web/src/lib/parse-start-time.ts @@ -0,0 +1,14 @@ +export function parseStartTime(raw?: string | number): number { + if (raw == null) return 0; + if (typeof raw === "number") return Math.max(0, raw); + const trimmed = raw.trim(); + if (!trimmed) return 0; + const num = Number(trimmed); + if (Number.isFinite(num)) return Math.max(0, num); + const match = trimmed.match(/^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s?)?$/); + if (!match) return 0; + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0); + return hours * 3600 + minutes * 60 + seconds; +} diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index f675214..25323ea 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -17,32 +17,20 @@ import { import { FAMILY_LIST_BLOCKED_MESSAGE, isChannelNotAllowedError } from "../lib/allow-list-error"; import { ApiError } from "../lib/api"; import { isYoutubeSessionReconnectError } from "../lib/api-youtube-session"; +import { parseStartTime } from "../lib/parse-start-time"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { toPublicWatchParam, toWatchSourceUrl } from "../lib/watch-url"; type EmbedSearch = { t?: string | number; + start?: string | number; + time_continue?: string | number; autoplay?: number; }; -function parseStartTime(raw?: string | number): number { - if (raw == null) return 0; - if (typeof raw === "number") return Math.max(0, raw); - const trimmed = raw.trim(); - if (!trimmed) return 0; - const num = Number(trimmed); - if (Number.isFinite(num)) return Math.max(0, num); - const match = trimmed.match(/^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s?)?$/); - if (!match) return 0; - const hours = Number(match[1] ?? 0); - const minutes = Number(match[2] ?? 0); - const seconds = Number(match[3] ?? 0); - return hours * 3600 + minutes * 60 + seconds; -} - function EmbedPage() { const { videoId } = Route.useParams(); - const { t, autoplay } = Route.useSearch(); + const { t, start, time_continue, autoplay } = Route.useSearch(); const sourceUrl = toWatchSourceUrl(videoId); const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; const { @@ -72,7 +60,7 @@ function EmbedPage() { publicParam, [], ); - const startTime = parseStartTime(t) * 1000; + const startTime = parseStartTime(t ?? start ?? time_continue) * 1000; const shouldAutoplay = autoplay === 1; if (instancePending) return ; @@ -140,6 +128,14 @@ function EmbedPage() { export const Route = createFileRoute("/embed_/$videoId")({ validateSearch: (search: Record): EmbedSearch => ({ t: typeof search.t === "string" || typeof search.t === "number" ? search.t : undefined, + start: + typeof search.start === "string" || typeof search.start === "number" + ? search.start + : undefined, + time_continue: + typeof search.time_continue === "string" || typeof search.time_continue === "number" + ? search.time_continue + : undefined, autoplay: typeof search.autoplay === "number" ? search.autoplay : undefined, }), component: EmbedPage, From c5b0a39b900157f7b641feac867d842ef8f9ba06 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 11:29:17 +0200 Subject: [PATCH 11/18] test: add parseStartTime tests --- apps/web/tests/parse-start-time.test.ts | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/web/tests/parse-start-time.test.ts diff --git a/apps/web/tests/parse-start-time.test.ts b/apps/web/tests/parse-start-time.test.ts new file mode 100644 index 0000000..c67c0b5 --- /dev/null +++ b/apps/web/tests/parse-start-time.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { parseStartTime } from "../src/lib/parse-start-time"; + +test("returns 0 for undefined input", () => { + expect(parseStartTime(undefined)).toBe(0); +}); + +test("returns 0 for zero", () => { + expect(parseStartTime(0)).toBe(0); +}); + +test("returns the number for positive integer", () => { + expect(parseStartTime(90)).toBe(90); +}); + +test("parses a numeric string", () => { + expect(parseStartTime("90")).toBe(90); +}); + +test("parses hours and minutes", () => { + expect(parseStartTime("1h30m")).toBe(5400); +}); + +test("parses hours, minutes, and seconds", () => { + expect(parseStartTime("1h30m15s")).toBe(5415); +}); + +test("parses hours only", () => { + expect(parseStartTime("2h")).toBe(7200); +}); + +test("returns 0 for garbage input", () => { + expect(parseStartTime("garbage")).toBe(0); +}); + +test("returns 0 for empty string", () => { + expect(parseStartTime("")).toBe(0); +}); + +test("clamps negative numbers to 0", () => { + expect(parseStartTime(-5)).toBe(0); +}); + +test("trims whitespace", () => { + expect(parseStartTime(" 90 ")).toBe(90); +}); + +test("parses seconds with suffix", () => { + expect(parseStartTime("90s")).toBe(90); +}); + +test("parses minutes only", () => { + expect(parseStartTime("30m")).toBe(1800); +}); + +test("floors fractional numeric strings", () => { + expect(parseStartTime("1.7")).toBe(1); +}); + +test("handles leading zeros", () => { + expect(parseStartTime("01h05m")).toBe(3900); +}); From b742eb283175a5db8bcf427ea49c2ab4aec29b75 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 12:57:00 +0200 Subject: [PATCH 12/18] fix: preserve playback position and show retry on embed errors --- .../web/src/components/embed-player-shell.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index 6eb8d8c..f159d53 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -1,4 +1,6 @@ +import { useRef } from "react"; import { usePlayerError } from "../hooks/use-player-error"; +import { usePlayerErrorResume } from "../hooks/use-player-error-resume"; import { useSabrPlaybackConfig } from "../hooks/use-sabr-playback-config"; import { useSettings } from "../hooks/use-settings"; import { useVolumeSync } from "../hooks/use-volume-sync"; @@ -7,6 +9,7 @@ import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { getOriginalAudioLocale } from "../lib/audio-track"; import { toPublicWatchParam } from "../lib/watch-url"; import type { VideoStream } from "../types/stream"; +import { EmbedError } from "./embed-error"; import { EmbedVideoPlayer } from "./embed-player"; type Props = { @@ -23,6 +26,24 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut const player = usePlayerError(stream, isLive); const handleVolumeChange = useVolumeSync(update.mutate); + const positionRef = useRef(0); + const playbackIntentRef = useRef(null); + const prevStreamId = useRef(stream.id); + if (prevStreamId.current !== stream.id) { + prevStreamId.current = stream.id; + playbackIntentRef.current = null; + } + + const { retryStartTime, handlePlayerError } = usePlayerErrorResume( + stream.id, + stream.duration, + positionRef, + player.handleError, + ); + + const effectiveStartTime = retryStartTime > 0 ? retryStartTime : startTime; + const effectiveAutoplay = player.retryKey !== 0 ? (playbackIntentRef.current ?? false) : autoplay; + const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; const sponsor = useWatchSponsorBlock(stream, settings); @@ -50,6 +71,10 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut chaptersVtt ? "chapters" : "no-chapters", ].join(":"); + if (player.playerFailed) { + return ; + } + return ( update.mutate({ captionStyles })} onVolumeChange={handleVolumeChange} - onError={player.handleError} + onTimeUpdate={(positionMs) => { + positionRef.current = positionMs; + }} + onPlay={() => { + playbackIntentRef.current = true; + player.clearFailed(); + }} + onPause={() => { + playbackIntentRef.current = false; + }} + onError={handlePlayerError} watchUrl={watchUrl} /> ); From 3ac508177a943318433d7733e6001981639031d3 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 14:39:41 +0200 Subject: [PATCH 13/18] fix: handle keyboard focus on embed title overlay --- apps/web/src/components/embed-player.tsx | 2 +- apps/web/src/lib/parse-start-time.ts | 7 ++++-- apps/web/tests/parse-start-time.test.ts | 32 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/embed-player.tsx b/apps/web/src/components/embed-player.tsx index a291053..6ae03ce 100644 --- a/apps/web/src/components/embed-player.tsx +++ b/apps/web/src/components/embed-player.tsx @@ -13,7 +13,7 @@ export function EmbedVideoPlayer({ playerKey, watchUrl, ...props }: Props) { href={watchUrl} target="_blank" rel="noopener noreferrer" - className="absolute top-0 left-0 right-0 z-10 flex items-center gap-2 px-3 py-2 text-sm text-white/90 transition-opacity opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto" + className="absolute top-0 left-0 right-0 z-10 flex items-center gap-2 px-3 py-2 text-sm text-white/90 transition-opacity opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80" style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%)", }} diff --git a/apps/web/src/lib/parse-start-time.ts b/apps/web/src/lib/parse-start-time.ts index e010358..cb916f8 100644 --- a/apps/web/src/lib/parse-start-time.ts +++ b/apps/web/src/lib/parse-start-time.ts @@ -1,10 +1,13 @@ export function parseStartTime(raw?: string | number): number { if (raw == null) return 0; - if (typeof raw === "number") return Math.max(0, raw); + if (typeof raw === "number") { + if (!Number.isFinite(raw)) return 0; + return Math.max(0, Math.floor(raw)); + } const trimmed = raw.trim(); if (!trimmed) return 0; const num = Number(trimmed); - if (Number.isFinite(num)) return Math.max(0, num); + if (Number.isFinite(num)) return Math.max(0, Math.floor(num)); const match = trimmed.match(/^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s?)?$/); if (!match) return 0; const hours = Number(match[1] ?? 0); diff --git a/apps/web/tests/parse-start-time.test.ts b/apps/web/tests/parse-start-time.test.ts index c67c0b5..3acf9ae 100644 --- a/apps/web/tests/parse-start-time.test.ts +++ b/apps/web/tests/parse-start-time.test.ts @@ -45,6 +45,10 @@ test("trims whitespace", () => { expect(parseStartTime(" 90 ")).toBe(90); }); +test("returns 0 for null input", () => { + expect(parseStartTime(null)).toBe(0); +}); + test("parses seconds with suffix", () => { expect(parseStartTime("90s")).toBe(90); }); @@ -53,10 +57,38 @@ test("parses minutes only", () => { expect(parseStartTime("30m")).toBe(1800); }); +test("floors fractional seconds", () => { + expect(parseStartTime(1.7)).toBe(1); +}); + test("floors fractional numeric strings", () => { expect(parseStartTime("1.7")).toBe(1); }); +test("returns 0 for NaN", () => { + expect(parseStartTime(NaN)).toBe(0); +}); + +test("returns 0 for Infinity", () => { + expect(parseStartTime(Infinity)).toBe(0); +}); + +test("parses HMS with spaces between components", () => { + expect(parseStartTime("1h 30m")).toBe(5400); +}); + +test("parses HMS with leading and trailing spaces", () => { + expect(parseStartTime(" 1h 30m 15s ")).toBe(5415); +}); + test("handles leading zeros", () => { expect(parseStartTime("01h05m")).toBe(3900); }); + +test("returns 0 for zero HMS", () => { + expect(parseStartTime("0h0m0s")).toBe(0); +}); + +test("handles large hour values", () => { + expect(parseStartTime("10h")).toBe(36000); +}); From bdf2ef02d2ceac85adc4a366995d77e238dc48f0 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 16:42:13 +0200 Subject: [PATCH 14/18] fix: use PageSpinner while stream is loading in embed --- apps/web/src/components/embed-loading.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/embed-loading.tsx b/apps/web/src/components/embed-loading.tsx index bb6ebea..175bd1e 100644 --- a/apps/web/src/components/embed-loading.tsx +++ b/apps/web/src/components/embed-loading.tsx @@ -1,9 +1,5 @@ +import { PageSpinner } from "./page-spinner"; + export function EmbedLoading() { - return ( -
-
-
-
-
- ); + return ; } From 3009c1ecea46b39c11a9ec2b415447bc03049bc7 Mon Sep 17 00:00:00 2001 From: tam1m Date: Thu, 16 Jul 2026 18:56:41 +0200 Subject: [PATCH 15/18] refactor: detect embed playback error like the others --- apps/web/src/components/embed-error.tsx | 27 ++++++++++++------- .../web/src/components/embed-player-shell.tsx | 4 +-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx index aea9cf4..a4695c4 100644 --- a/apps/web/src/components/embed-error.tsx +++ b/apps/web/src/components/embed-error.tsx @@ -3,20 +3,31 @@ import { parseGeoRestriction } from "../lib/geo-restriction"; import { isMemberOnlyMessage } from "../lib/member-only"; import { FlagIcon } from "./flag-icon"; +export const PLAYBACK_FAILED_MESSAGE = + "This video could not be played. The stream may be unavailable or unsupported."; + type EmbedErrorProps = { message: string; onRetry?: () => void; + heading?: string; + image?: string; }; -export function EmbedError({ message, onRetry }: EmbedErrorProps) { +export function EmbedError({ message, onRetry, heading, image }: EmbedErrorProps) { const countryCode = parseGeoRestriction(message); const isMemberOnly = isMemberOnlyMessage(message); const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE; - const imageSrc = familyListBlocked - ? "/family-list-blocked.gif" - : isMemberOnly - ? "/member-only-source.gif" - : "/error-cat.gif"; + const playbackFailed = message === PLAYBACK_FAILED_MESSAGE; + const imageSrc = + image ?? + (playbackFailed + ? "/sad-sigh.gif" + : familyListBlocked + ? "/family-list-blocked.gif" + : isMemberOnly + ? "/member-only-source.gif" + : "/error-cat.gif"); + const headingText = heading ?? (playbackFailed ? "Playback failed" : "Couldn't load this video"); return (
@@ -28,9 +39,7 @@ export function EmbedError({ message, onRetry }: EmbedErrorProps) { className="rounded-2xl" />
-

- Couldn't load this video -

+

{headingText}

{countryCode && }

{message}

diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index f159d53..ebf633e 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -9,7 +9,7 @@ import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { getOriginalAudioLocale } from "../lib/audio-track"; import { toPublicWatchParam } from "../lib/watch-url"; import type { VideoStream } from "../types/stream"; -import { EmbedError } from "./embed-error"; +import { EmbedError, PLAYBACK_FAILED_MESSAGE } from "./embed-error"; import { EmbedVideoPlayer } from "./embed-player"; type Props = { @@ -72,7 +72,7 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut ].join(":"); if (player.playerFailed) { - return ; + return ; } return ( From b877d755ed72ed073d25940ead9b674d4473b996 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 16 Jul 2026 22:37:52 +0200 Subject: [PATCH 16/18] fix: isolate embedded playback sessions --- .../web/src/components/embed-player-shell.tsx | 38 +++++++++++---- apps/web/src/hooks/use-player-error.ts | 10 +++- .../hooks/use-session-activity-reporting.ts | 4 +- apps/web/src/hooks/use-settings.ts | 16 +++++-- apps/web/src/lib/embed-access.ts | 30 ++++++++++++ apps/web/src/lib/embed-playback.ts | 7 +++ apps/web/src/routes/__root.tsx | 9 ++-- apps/web/src/routes/embed_.$videoId.tsx | 48 +++++++++++++++---- 8 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/lib/embed-access.ts create mode 100644 apps/web/src/lib/embed-playback.ts diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index ebf633e..9716ecf 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -7,6 +7,8 @@ import { useVolumeSync } from "../hooks/use-volume-sync"; 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"; @@ -17,21 +19,31 @@ type Props = { sourceUrl: string; startTime: number; autoplay: boolean; - isAuthed: boolean; + sessionEnabled: boolean; + playbackMode: PlaybackMode; }; -export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAuthed }: Props) { - const { settings, settingsReady, update } = useSettings(); +export function EmbedPlayerShell({ + stream, + sourceUrl, + 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); + const player = usePlayerError(stream, isLive, playbackMode); const handleVolumeChange = useVolumeSync(update.mutate); const positionRef = useRef(0); - const playbackIntentRef = useRef(null); + const playbackIntentRef = useRef(autoplay); const prevStreamId = useRef(stream.id); if (prevStreamId.current !== stream.id) { prevStreamId.current = stream.id; - playbackIntentRef.current = null; + playbackIntentRef.current = autoplay; } const { retryStartTime, handlePlayerError } = usePlayerErrorResume( @@ -42,12 +54,16 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut ); const effectiveStartTime = retryStartTime > 0 ? retryStartTime : startTime; - const effectiveAutoplay = player.retryKey !== 0 ? (playbackIntentRef.current ?? false) : autoplay; + const effectiveAutoplay = resolveEmbedAutoplay( + player.retryKey, + playbackIntentRef.current, + autoplay, + ); const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; const sponsor = useWatchSponsorBlock(stream, settings); - const autoSkipSponsorBlock = isAuthed && settings.sponsorBlockMode !== "disabled"; + const autoSkipSponsorBlock = sessionEnabled && settings.sponsorBlockMode !== "disabled"; const { thumbnailVtt, chaptersVtt } = useWatchVttAssets( stream, @@ -94,8 +110,10 @@ export function EmbedPlayerShell({ stream, sourceUrl, startTime, autoplay, isAut initialVolume={settings.volume} initialMuted={settings.muted} sponsorBlockSegments={sponsor.segments} - autoSkipSponsorBlockSegments={isAuthed ? sponsor.autoSkipSegments : []} - manualSkipSponsorBlockSegments={isAuthed ? sponsor.manualSkipSegments : sponsor.segments} + autoSkipSponsorBlockSegments={sessionEnabled ? sponsor.autoSkipSegments : []} + manualSkipSponsorBlockSegments={ + sessionEnabled ? sponsor.manualSkipSegments : sponsor.segments + } autoSkipSponsorBlock={autoSkipSponsorBlock} muteSponsorBlockInsteadOfSkip={settings.sponsorBlockMuteInsteadOfSkip} showCurrentSponsorBlockSegment={settings.sponsorBlockShowCurrentSegment} diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts index cc1b2a7..e16f775 100644 --- a/apps/web/src/hooks/use-player-error.ts +++ b/apps/web/src/hooks/use-player-error.ts @@ -3,6 +3,7 @@ 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 { @@ -30,12 +31,17 @@ type UsePlayerErrorReturn = { seekStartTime: number | null; }; -export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerErrorReturn { +export function usePlayerError( + stream: VideoStream, + isLive: boolean, + playbackModeOverride?: PlaybackMode, +): UsePlayerErrorReturn { const debugVideo = sanitizeVideoContext(stream.id) ?? "unknown"; const provider = detectProvider(stream.id); const iosDevice = isIosDevice(); const { data: instance } = useInstance(); - const { playbackMode } = usePlaybackMode(); + const { playbackMode: storedPlaybackMode } = usePlaybackMode(); + const playbackMode = playbackModeOverride ?? storedPlaybackMode; const playbackSourceId = stream.id.length === 0 ? "" : `${stream.id}:${playbackMode}`; const preferServerManifests = instance?.guestAllowed !== false; const legacyDashPair = hasLegacyDashPair(stream); diff --git a/apps/web/src/hooks/use-session-activity-reporting.ts b/apps/web/src/hooks/use-session-activity-reporting.ts index 8ccb1bd..de94904 100644 --- a/apps/web/src/hooks/use-session-activity-reporting.ts +++ b/apps/web/src/hooks/use-session-activity-reporting.ts @@ -5,9 +5,9 @@ import { useAuth } from "./use-auth"; const ACTIVITY_INTERVAL_MS = 60_000; -export function useSessionActivityReporting() { +export function useSessionActivityReporting(allowed = true) { const { status } = useAuth(); - const enabled = status === "authenticated"; + const enabled = allowed && status === "authenticated"; useEffect(() => { if (!enabled) return; diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts index bd5457b..0d0a879 100644 --- a/apps/web/src/hooks/use-settings.ts +++ b/apps/web/src/hooks/use-settings.ts @@ -8,6 +8,10 @@ import { useAuth } from "./use-auth"; const KEY = ["settings"]; const AUDIO_ONLY_STORAGE_KEY = "typetype-audio-only-playback"; +type UseSettingsOptions = { + forceAnonymous?: boolean; +}; + const DEFAULTS: SettingsItem = { defaultService: 0, defaultLandingPage: "home", @@ -62,26 +66,30 @@ function withLocalAudioOnly(settings: SettingsItem): SettingsItem { return audioOnlyPlayback === null ? settings : { ...settings, audioOnlyPlayback }; } -export function useSettings() { +export function useSettings({ forceAnonymous = false }: UseSettingsOptions = {}) { const qc = useQueryClient(); const { authReady, isAuthed } = useAuth(); + const useAccountSettings = isAuthed && !forceAnonymous; const query = useQuery({ queryKey: KEY, queryFn: () => fetchSettings(), - enabled: authReady && isAuthed, + enabled: authReady && useAccountSettings, placeholderData: DEFAULTS, staleTime: 5 * 60 * 1000, }); const settingsReady = - (authReady && !isAuthed) || (query.isSuccess && !query.isPlaceholderData) || query.isError; + forceAnonymous || + (authReady && !isAuthed) || + (query.isSuccess && !query.isPlaceholderData) || + query.isError; const update = useMutation({ mutationFn: (patch: Partial) => { const stored = qc.getQueryData(KEY); const current = stored ? { ...DEFAULTS, ...stored } : DEFAULTS; const next = { ...current, ...patch }; - if (!isAuthed) return Promise.resolve(next); + if (!useAccountSettings) return Promise.resolve(next); return updateSettings(next); }, onMutate: async (patch) => { diff --git a/apps/web/src/lib/embed-access.ts b/apps/web/src/lib/embed-access.ts new file mode 100644 index 0000000..bafe208 --- /dev/null +++ b/apps/web/src/lib/embed-access.ts @@ -0,0 +1,30 @@ +export type EmbedAccessInput = { + framed: boolean; + guestAllowed: boolean; + authReady: boolean; + isAuthed: boolean; + isGuest: boolean; + settingsReady: boolean; +}; + +export type EmbedAccess = { + accountAuthenticated: boolean; + sessionEnabled: boolean; + streamEnabled: boolean; +}; + +export function isEmbeddedFrame(): boolean { + return typeof window !== "undefined" && window.self !== window.top; +} + +export function resolveEmbedAccess(input: EmbedAccessInput): EmbedAccess { + const accountAuthenticated = !input.framed && input.isAuthed && !input.isGuest; + const sessionEnabled = !input.framed && input.isAuthed && (!input.isGuest || input.guestAllowed); + const authReady = input.framed || input.authReady; + const streamEnabled = + (input.guestAllowed || accountAuthenticated) && + authReady && + (!sessionEnabled || input.settingsReady); + + return { accountAuthenticated, sessionEnabled, streamEnabled }; +} diff --git a/apps/web/src/lib/embed-playback.ts b/apps/web/src/lib/embed-playback.ts new file mode 100644 index 0000000..8304b3a --- /dev/null +++ b/apps/web/src/lib/embed-playback.ts @@ -0,0 +1,7 @@ +export function resolveEmbedAutoplay( + retryKey: number, + playbackIntent: boolean, + requestedAutoplay: boolean, +): boolean { + return retryKey === 0 ? requestedAutoplay : playbackIntent; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69df475..c0f6971 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,6 +14,7 @@ import { useRegisterStatus } from "../hooks/use-register-status"; import { useSessionActivityReporting } from "../hooks/use-session-activity-reporting"; import { isAdminRoute, isAuthPage, requiresAuth } from "../lib/auth-routes"; import { bootstrapSession } from "../lib/auth-session"; +import { isEmbeddedFrame } from "../lib/embed-access"; import { applyTheme } from "../lib/theme"; import { useAuthStore } from "../stores/auth-store"; import { useThemeStore } from "../stores/theme-store"; @@ -38,20 +39,22 @@ function RootLayout() { const { isAuthed, isAdmin, isGuest, status } = useAuth(); const setSignedOut = useAuthStore((s) => s.setSignedOut); const { data: instance } = useInstance(); - const registerStatus = useRegisterStatus(status !== "loading"); const location = useRouterState({ select: (state) => state.location }); const pathname = location.pathname; const pathWithSearch = `${pathname}${location.searchStr}`; const hideEverythingPage = pathname === "/hide-everything"; const shortsPage = pathname === "/shorts"; const embedPage = pathname.startsWith("/embed/"); + const framedEmbedPage = embedPage && isEmbeddedFrame(); + const registerStatus = useRegisterStatus(status !== "loading" && !framedEmbedPage); const watchCinemaPage = pathname === "/watch" && cinemaMode; const wasWatchCinemaPage = useRef(watchCinemaPage); - useSessionActivityReporting(); + useSessionActivityReporting(!framedEmbedPage); useEffect(() => { + if (framedEmbedPage) return; void bootstrapSession(); - }, []); + }, [framedEmbedPage]); useEffect(() => { applyTheme(theme); diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index 25323ea..a93f358 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -17,6 +17,7 @@ import { 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 { parseStartTime } from "../lib/parse-start-time"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { toPublicWatchParam, toWatchSourceUrl } from "../lib/watch-url"; @@ -31,6 +32,7 @@ type EmbedSearch = { function EmbedPage() { const { videoId } = Route.useParams(); const { t, start, time_continue, autoplay } = Route.useSearch(); + const framed = isEmbeddedFrame(); const sourceUrl = toWatchSourceUrl(videoId); const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`; const { @@ -39,18 +41,42 @@ function EmbedPage() { isError: instanceError, refetch: retryInstance, } = useInstance(); - const { authReady, isAuthed } = useAuth(); - const { settings, settingsReady } = useSettings(); - const { playbackMode } = usePlaybackMode(); + const { authReady, isAuthed, isGuest } = useAuth(); const guestAllowed = instance?.guestAllowed ?? false; + const accessWithoutSettings = resolveEmbedAccess({ + framed, + guestAllowed, + authReady, + isAuthed, + isGuest, + settingsReady: false, + }); + const { settings, settingsReady } = useSettings({ + forceAnonymous: !accessWithoutSettings.sessionEnabled, + }); + const access = resolveEmbedAccess({ + framed, + guestAllowed, + authReady, + isAuthed, + isGuest, + settingsReady, + }); + const { playbackMode: storedPlaybackMode } = usePlaybackMode(); + const playbackMode = framed ? "legacy" : storedPlaybackMode; const useAuthenticatedStream = - isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); - const streamEnabled = (guestAllowed || isAuthed) && authReady && (!isAuthed || settingsReady); - const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled, playbackMode); + access.sessionEnabled && + (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const streamQuery = useStream( + sourceUrl, + useAuthenticatedStream, + access.streamEnabled, + playbackMode, + ); const bootstrap = useSabrBootstrap( sourceUrl, useAuthenticatedStream, - streamEnabled, + access.streamEnabled, playbackMode, ); const publicParam = toPublicWatchParam(sourceUrl); @@ -68,10 +94,11 @@ function EmbedPage() { if (instanceError || !instance) return void retryInstance()} />; - if (!guestAllowed && !isAuthed) return ; + if (!guestAllowed && !access.accountAuthenticated) + return ; const pending = streamQuery.isLoading || bootstrap.isLoading; - if (!activeStream && (!streamEnabled || pending)) return ; + if (!activeStream && (!access.streamEnabled || pending)) return ; if (!activeStream) { const activeError = streamQuery.error ?? bootstrap.error; @@ -120,7 +147,8 @@ function EmbedPage() { sourceUrl={sourceUrl} startTime={startTime} autoplay={shouldAutoplay} - isAuthed={isAuthed} + sessionEnabled={access.sessionEnabled} + playbackMode={playbackMode} /> ); } From 147826f9fdf50d857c8a089970b18cab637a1bde Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 16 Jul 2026 22:38:12 +0200 Subject: [PATCH 17/18] fix: stabilize embed playback recovery --- apps/web/src/components/embed-player.tsx | 2 +- apps/web/src/components/player-seeker.tsx | 7 +++---- apps/web/src/components/video-player-layout.tsx | 4 +++- apps/web/src/components/video-player-types.ts | 1 + apps/web/src/components/video-player.tsx | 2 ++ 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/embed-player.tsx b/apps/web/src/components/embed-player.tsx index 6ae03ce..74b14d2 100644 --- a/apps/web/src/components/embed-player.tsx +++ b/apps/web/src/components/embed-player.tsx @@ -24,7 +24,7 @@ export function EmbedVideoPlayer({ playerKey, watchUrl, ...props }: Props) { return (
- +
); } diff --git a/apps/web/src/components/player-seeker.tsx b/apps/web/src/components/player-seeker.tsx index 2bfeaff..1d0649e 100644 --- a/apps/web/src/components/player-seeker.tsx +++ b/apps/web/src/components/player-seeker.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef } from "react"; import { recordClientEvent } from "../lib/client-debug-log"; import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack"; +const SEEK_SETTLE_DELAY_MS = 750; + function seekable(media: HTMLMediaElement, target: number) { if (media.readyState === 0 && !Number.isFinite(media.duration)) return false; if (media.seekable.length === 0) return true; @@ -40,9 +42,6 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { return; } applying = true; - try { - media.currentTime = target; - } catch {} remote.seek(target); recordClientEvent("player.seek_apply", { targetMs: Math.round(target * 1000), @@ -61,7 +60,7 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { return; } seekMedia(media); - }, 250); + }, SEEK_SETTLE_DELAY_MS); } if (canPlay) remote.seek(target); diff --git a/apps/web/src/components/video-player-layout.tsx b/apps/web/src/components/video-player-layout.tsx index f95beed..63453c0 100644 --- a/apps/web/src/components/video-player-layout.tsx +++ b/apps/web/src/components/video-player-layout.tsx @@ -12,6 +12,7 @@ import { SabrTimeSlider } from "./sabr-time-slider"; type Props = { audioOnly?: boolean; + hideCinemaMode?: boolean; audioUsesVideoProvider?: boolean; sabr?: boolean; sabrVideo?: HTMLVideoElement | null; @@ -24,6 +25,7 @@ type Props = { export function VideoPlayerLayout({ audioOnly = false, + hideCinemaMode = false, audioUsesVideoProvider = false, sabr = false, sabrVideo = null, @@ -119,7 +121,7 @@ export function VideoPlayerLayout({ beforeFullscreenButton: ( <> - + {!hideCinemaMode && } ), }} diff --git a/apps/web/src/components/video-player-types.ts b/apps/web/src/components/video-player-types.ts index 441b9f8..2175e08 100644 --- a/apps/web/src/components/video-player-types.ts +++ b/apps/web/src/components/video-player-types.ts @@ -26,6 +26,7 @@ export type VideoPlayerProps = { settingsReady?: boolean; autoplay?: boolean; audioOnly?: boolean; + hideCinemaMode?: boolean; originalAudioLocale?: string | null; overlay?: ReactNode; captionStyles?: CaptionStyles; diff --git a/apps/web/src/components/video-player.tsx b/apps/web/src/components/video-player.tsx index 28c11ce..8cf61a4 100644 --- a/apps/web/src/components/video-player.tsx +++ b/apps/web/src/components/video-player.tsx @@ -46,6 +46,7 @@ export function VideoPlayer({ settingsReady = false, autoplay = false, audioOnly = false, + hideCinemaMode = false, originalAudioLocale, overlay, captionStyles, @@ -137,6 +138,7 @@ export function VideoPlayer({ {overlay} Date: Thu, 16 Jul 2026 22:38:27 +0200 Subject: [PATCH 18/18] test: cover embed access and retry behavior --- apps/web/tests/embed-access.test.ts | 87 +++++++++++++++++++++++++++ apps/web/tests/embed-playback.test.ts | 16 +++++ 2 files changed, 103 insertions(+) create mode 100644 apps/web/tests/embed-access.test.ts create mode 100644 apps/web/tests/embed-playback.test.ts diff --git a/apps/web/tests/embed-access.test.ts b/apps/web/tests/embed-access.test.ts new file mode 100644 index 0000000..461a6ec --- /dev/null +++ b/apps/web/tests/embed-access.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { resolveEmbedAccess } from "../src/lib/embed-access"; + +test("uses anonymous public access inside an allowed iframe", () => { + expect( + resolveEmbedAccess({ + framed: true, + guestAllowed: true, + authReady: false, + isAuthed: true, + isGuest: false, + settingsReady: false, + }), + ).toEqual({ + accountAuthenticated: false, + sessionEnabled: false, + streamEnabled: true, + }); +}); + +test("blocks anonymous iframe access when guest mode is disabled", () => { + expect( + resolveEmbedAccess({ + framed: true, + guestAllowed: false, + authReady: true, + isAuthed: true, + isGuest: false, + settingsReady: true, + }), + ).toEqual({ + accountAuthenticated: false, + sessionEnabled: false, + streamEnabled: false, + }); +}); + +test("does not treat a guest session as an account", () => { + expect( + resolveEmbedAccess({ + framed: false, + guestAllowed: false, + authReady: true, + isAuthed: true, + isGuest: true, + settingsReady: true, + }), + ).toEqual({ + accountAuthenticated: false, + sessionEnabled: false, + streamEnabled: false, + }); +}); + +test("waits for guest settings before loading a direct embed", () => { + const input = { + framed: false, + guestAllowed: true, + authReady: true, + isAuthed: true, + isGuest: true, + }; + + expect(resolveEmbedAccess({ ...input, settingsReady: false }).streamEnabled).toBe(false); + expect(resolveEmbedAccess({ ...input, settingsReady: true })).toEqual({ + accountAuthenticated: false, + sessionEnabled: true, + streamEnabled: true, + }); +}); + +test("allows a signed-in account when guest mode is disabled", () => { + expect( + resolveEmbedAccess({ + framed: false, + guestAllowed: false, + authReady: true, + isAuthed: true, + isGuest: false, + settingsReady: true, + }), + ).toEqual({ + accountAuthenticated: true, + sessionEnabled: true, + streamEnabled: true, + }); +}); diff --git a/apps/web/tests/embed-playback.test.ts b/apps/web/tests/embed-playback.test.ts new file mode 100644 index 0000000..db6037c --- /dev/null +++ b/apps/web/tests/embed-playback.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { resolveEmbedAutoplay } from "../src/lib/embed-playback"; + +test("uses the requested autoplay value for the initial player", () => { + expect(resolveEmbedAutoplay(0, false, true)).toBe(true); + expect(resolveEmbedAutoplay(0, true, false)).toBe(false); +}); + +test("keeps requested autoplay when the first attempt fails before playback", () => { + expect(resolveEmbedAutoplay(1, true, true)).toBe(true); +}); + +test("keeps the latest playback intent when retrying", () => { + expect(resolveEmbedAutoplay(2, false, true)).toBe(false); + expect(resolveEmbedAutoplay(2, true, false)).toBe(true); +});