diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx
new file mode 100644
index 0000000..a4695c4
--- /dev/null
+++ b/apps/web/src/components/embed-error.tsx
@@ -0,0 +1,61 @@
+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";
+
+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, heading, image }: EmbedErrorProps) {
+ const countryCode = parseGeoRestriction(message);
+ const isMemberOnly = isMemberOnlyMessage(message);
+ const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE;
+ 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 (
+
+

+
+
{headingText}
+
+ {countryCode &&
}
+
{message}
+
+
+
+ {onRetry && (
+
+ )}
+
+
+ );
+}
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..47b7356
--- /dev/null
+++ b/apps/web/src/components/embed-guest-required.tsx
@@ -0,0 +1,27 @@
+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.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/embed-loading.tsx b/apps/web/src/components/embed-loading.tsx
new file mode 100644
index 0000000..175bd1e
--- /dev/null
+++ b/apps/web/src/components/embed-loading.tsx
@@ -0,0 +1,5 @@
+import { PageSpinner } from "./page-spinner";
+
+export function EmbedLoading() {
+ return ;
+}
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..9716ecf
--- /dev/null
+++ b/apps/web/src/components/embed-player-shell.tsx
@@ -0,0 +1,137 @@
+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";
+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";
+import { EmbedVideoPlayer } from "./embed-player";
+
+type Props = {
+ stream: VideoStream;
+ sourceUrl: string;
+ startTime: number;
+ autoplay: boolean;
+ sessionEnabled: boolean;
+ playbackMode: PlaybackMode;
+};
+
+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, playbackMode);
+ const handleVolumeChange = useVolumeSync(update.mutate);
+
+ const positionRef = useRef(0);
+ const playbackIntentRef = useRef(autoplay);
+ const prevStreamId = useRef(stream.id);
+ if (prevStreamId.current !== stream.id) {
+ prevStreamId.current = stream.id;
+ playbackIntentRef.current = autoplay;
+ }
+
+ const { retryStartTime, handlePlayerError } = usePlayerErrorResume(
+ stream.id,
+ stream.duration,
+ positionRef,
+ player.handleError,
+ );
+
+ const effectiveStartTime = retryStartTime > 0 ? retryStartTime : startTime;
+ const effectiveAutoplay = resolveEmbedAutoplay(
+ player.retryKey,
+ playbackIntentRef.current,
+ autoplay,
+ );
+
+ const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`;
+
+ const sponsor = useWatchSponsorBlock(stream, settings);
+ const autoSkipSponsorBlock = sessionEnabled && 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(":");
+
+ if (player.playerFailed) {
+ return ;
+ }
+
+ return (
+ update.mutate({ captionStyles })}
+ onVolumeChange={handleVolumeChange}
+ onTimeUpdate={(positionMs) => {
+ positionRef.current = positionMs;
+ }}
+ onPlay={() => {
+ playbackIntentRef.current = true;
+ player.clearFailed();
+ }}
+ onPause={() => {
+ playbackIntentRef.current = false;
+ }}
+ onError={handlePlayerError}
+ watchUrl={watchUrl}
+ />
+ );
+}
diff --git a/apps/web/src/components/embed-player.tsx b/apps/web/src/components/embed-player.tsx
new file mode 100644
index 0000000..74b14d2
--- /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 EmbedVideoPlayer({ playerKey, watchUrl, ...props }: Props) {
+ const overlay =
+ props.title && watchUrl ? (
+
+ {props.title}
+
+ ) : null;
+
+ 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}
{
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/lib/parse-start-time.ts b/apps/web/src/lib/parse-start-time.ts
new file mode 100644
index 0000000..cb916f8
--- /dev/null
+++ b/apps/web/src/lib/parse-start-time.ts
@@ -0,0 +1,17 @@
+export function parseStartTime(raw?: string | number): number {
+ if (raw == null) return 0;
+ 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, 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);
+ const minutes = Number(match[2] ?? 0);
+ const seconds = Number(match[3] ?? 0);
+ return hours * 3600 + minutes * 60 + seconds;
+}
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/__root.tsx b/apps/web/src/routes/__root.tsx
index 5c2df1d..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,19 +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);
@@ -119,7 +123,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 +152,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";
diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx
new file mode 100644
index 0000000..a93f358
--- /dev/null
+++ b/apps/web/src/routes/embed_.$videoId.tsx
@@ -0,0 +1,170 @@
+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";
+import { usePlaybackMode } from "../hooks/use-playback-mode";
+import { useSettings } from "../hooks/use-settings";
+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 { 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";
+
+type EmbedSearch = {
+ t?: string | number;
+ start?: string | number;
+ time_continue?: string | number;
+ autoplay?: number;
+};
+
+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 {
+ data: instance,
+ isPending: instancePending,
+ isError: instanceError,
+ refetch: retryInstance,
+ } = useInstance();
+ 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 =
+ 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 publicParam = toPublicWatchParam(sourceUrl);
+ const activeStream = selectProgressiveWatchStream(
+ streamQuery.isPlaceholderData ? undefined : streamQuery.data,
+ playbackMode === "sabr" ? bootstrap.data : undefined,
+ publicParam,
+ [],
+ );
+ const startTime = parseStartTime(t ?? start ?? time_continue) * 1000;
+ const shouldAutoplay = autoplay === 1;
+
+ if (instancePending) return ;
+
+ if (instanceError || !instance)
+ return void retryInstance()} />;
+
+ if (!guestAllowed && !access.accountAuthenticated)
+ return ;
+
+ const pending = streamQuery.isLoading || bootstrap.isLoading;
+ if (!activeStream && (!access.streamEnabled || pending)) return ;
+
+ if (!activeStream) {
+ const activeError = streamQuery.error ?? bootstrap.error;
+ const genericExtractorError =
+ activeError instanceof ApiError &&
+ 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) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+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,
+});
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);
+});
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..3acf9ae
--- /dev/null
+++ b/apps/web/tests/parse-start-time.test.ts
@@ -0,0 +1,94 @@
+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("returns 0 for null input", () => {
+ expect(parseStartTime(null)).toBe(0);
+});
+
+test("parses seconds with suffix", () => {
+ expect(parseStartTime("90s")).toBe(90);
+});
+
+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);
+});