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. +

+
+
+ + 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..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); +});