diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 045c08f..cef46ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: exit 1 fi + if [[ "$GITHUB_REF" == "refs/heads/dev" ]]; then + exit 0 + fi + tag="v$version" tag_sha="$(git ls-remote --tags origin "refs/tags/$tag" | cut -f1)" if [[ -z "$tag_sha" ]]; then diff --git a/apps/web/package.json b/apps/web/package.json index 2567f7d..dd37352 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.5.1", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", @@ -13,7 +13,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.44", + "@typetype/mse": "0.1.49", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx index 6aaebb1..9f14c8d 100644 --- a/apps/web/src/components/embed-error.tsx +++ b/apps/web/src/components/embed-error.tsx @@ -15,6 +15,7 @@ type EmbedErrorProps = { image?: string; availability?: VideoAvailability; poster?: string; + watchUrl?: string; }; export function EmbedError({ @@ -24,6 +25,7 @@ export function EmbedError({ image, availability, poster, + watchUrl, }: EmbedErrorProps) { const availabilityCopy = availability ? videoAvailabilityCopy(availability, message) : null; const displayedMessage = availabilityCopy?.message ?? message; @@ -84,6 +86,16 @@ export function EmbedError({ Retry )} + {watchUrl && ( + + Connect YouTube on TypeType + + )} ); diff --git a/apps/web/src/components/format-selector.tsx b/apps/web/src/components/format-selector.tsx index 33c0553..0eb2917 100644 --- a/apps/web/src/components/format-selector.tsx +++ b/apps/web/src/components/format-selector.tsx @@ -4,7 +4,7 @@ import { useDashPlayerSnapshot } from "../lib/dash-player-store"; import { dashTrackGroups, maxTrackHeight, selectDashTrack } from "../lib/dash-video"; import { activeFamily, type CodecFamily, codecFamily, groupByFamily } from "../lib/quality-utils"; import { - maxSabrCodecHeight, + maxSabrCodecLabel, sabrCodecOptions, selectSabrCodec, } from "../lib/sabr-quality-selection"; @@ -60,7 +60,7 @@ export function FormatSelector() { ({ - label: `${codec} ${maxSabrCodecHeight(sabrOptions, codec)}p`, + label: `${codec} ${maxSabrCodecLabel(sabrOptions, codec)}`, value: codec, }))} onChange={onSabrChange} diff --git a/apps/web/src/components/media-progress-events.tsx b/apps/web/src/components/media-progress-events.tsx index 58eba1e..1eddf84 100644 --- a/apps/web/src/components/media-progress-events.tsx +++ b/apps/web/src/components/media-progress-events.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { recordClientEvent } from "../lib/client-debug-log"; -import { isSabrPlaybackEventTransient } from "../lib/sabr-vidstack-bridge"; +import { resolveMediaProgressPosition } from "../lib/media-progress-position"; +import { consumeSabrSeekTarget, isSabrPlaybackEventTransient } from "../lib/sabr-vidstack-bridge"; import { useMediaPlayer } from "../lib/vidstack"; type Props = { @@ -58,7 +59,18 @@ export function MediaProgressEvents({ const media = rootElement.querySelector("video,audio"); if (!media) return false; - const update = () => onTimeUpdateRef.current?.(toPositionMs(media)); + const video = media instanceof HTMLVideoElement ? media : null; + const reportPosition = (requestedPositionMs: number | null = null) => { + const positionMs = resolveMediaProgressPosition( + video, + toPositionMs(media), + requestedPositionMs, + ); + if (positionMs === null) return false; + onTimeUpdateRef.current?.(positionMs); + return true; + }; + const update = () => reportPosition(); const readPosition = () => toPositionMs(media); const suppressPlaybackEvent = () => suppressPlaybackEventsRef.current || @@ -74,11 +86,12 @@ export function MediaProgressEvents({ onPauseRef.current?.(); }; const seeked = () => { - update(); + const requestedPositionMs = video ? consumeSabrSeekTarget(video) : null; + if (!reportPosition(requestedPositionMs)) return; onSeekedRef.current?.(); }; const seeking = () => { - update(); + if (!update()) return; onSeekingRef.current?.(toPositionMs(media)); }; const ended = () => { diff --git a/apps/web/src/components/media-session-sync.tsx b/apps/web/src/components/media-session-sync.tsx index e3489d4..3ad014f 100644 --- a/apps/web/src/components/media-session-sync.tsx +++ b/apps/web/src/components/media-session-sync.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge"; import { useMediaRemote, useMediaState } from "../lib/vidstack"; import { MediaSessionPositionSync } from "./media-session-position-sync"; @@ -8,6 +9,7 @@ type Props = { artwork?: string; canSeek?: boolean; isLive?: boolean; + sabrVideo?: HTMLVideoElement | null; onPreviousTrack?: () => void; onNextTrack?: () => void; }; @@ -28,6 +30,7 @@ export function MediaSessionSync({ artwork, canSeek = true, isLive = false, + sabrVideo = null, onPreviousTrack, onNextTrack, }: Props) { @@ -55,11 +58,17 @@ export function MediaSessionSync({ useEffect(() => { if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return; const session = navigator.mediaSession; + const setPlayback = (playing: boolean) => { + const request = sabrVideo + ? requestSabrVidstackPlayback(sabrVideo, playing, true) + : Promise.resolve(playing ? remote.play() : remote.pause()); + void request.catch(() => {}); + }; safeSetActionHandler(session, "play", () => { - void Promise.resolve(remote.play()).catch(() => {}); + setPlayback(true); }); safeSetActionHandler(session, "pause", () => { - void Promise.resolve(remote.pause()).catch(() => {}); + setPlayback(false); }); if (canSeek) { safeSetActionHandler(session, "seekbackward", (details) => { @@ -80,7 +89,7 @@ export function MediaSessionSync({ }); } safeSetActionHandler(session, "stop", () => { - void Promise.resolve(remote.pause()).catch(() => {}); + setPlayback(false); }); safeSetActionHandler(session, "previoustrack", isLive ? null : (onPreviousTrack ?? null)); safeSetActionHandler(session, "nexttrack", isLive ? null : (onNextTrack ?? null)); @@ -94,7 +103,7 @@ export function MediaSessionSync({ safeSetActionHandler(session, "previoustrack", null); safeSetActionHandler(session, "nexttrack", null); }; - }, [canSeek, isLive, onPreviousTrack, onNextTrack, remote]); + }, [canSeek, isLive, onPreviousTrack, onNextTrack, remote, sabrVideo]); useEffect(() => { if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return; diff --git a/apps/web/src/components/notification-toast-host.tsx b/apps/web/src/components/notification-toast-host.tsx index daf752f..a65d902 100644 --- a/apps/web/src/components/notification-toast-host.tsx +++ b/apps/web/src/components/notification-toast-host.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useAuth } from "../hooks/use-auth"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { NOTIFICATIONS_UNREAD_KEY } from "../hooks/use-notifications"; +import { useSettings } from "../hooks/use-settings"; import { fetchNotifications } from "../lib/api-notifications"; import { advanceNotificationToastCursor, @@ -11,6 +12,7 @@ import { findNewNotificationItems, type NotificationToastCursor, parseNotificationToastCursor, + visibleNotificationToastItems, } from "../lib/notification-toast-cursor"; import { watchRouteSearch } from "../lib/watch-url"; import { useUiStore } from "../stores/ui-store"; @@ -42,7 +44,8 @@ export function NotificationToastHost() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { authReady, isAuthed, isGuest, me } = useAuth(); - const { isBlocked, ready: blockedFilterReady } = useBlockedFilter(); + const { isHidden, ready: visibilityFilterReady } = useBlockedFilter(); + const { settings, settingsReady } = useSettings(); const openNotificationCenter = useUiStore((state) => state.openNotificationCenter); const owner = me?.id ?? null; const enabled = authReady && isAuthed && !isGuest && owner !== null; @@ -66,7 +69,7 @@ export function NotificationToastHost() { }, [owner]); useEffect(() => { - if (!enabled || !owner || !query.data || !blockedFilterReady) return; + if (!enabled || !owner || !query.data || !visibilityFilterReady || !settingsReady) return; queryClient.setQueryData(NOTIFICATIONS_UNREAD_KEY, { unreadCount: query.data.unreadCount, }); @@ -81,9 +84,23 @@ export function NotificationToastHost() { const next = advanceNotificationToastCursor(current, query.data.items); cursorRef.current = { owner, cursor: next }; writeCursor(owner, next); - const visibleItems = newItems.filter((item) => !isBlocked(item.video)); + const visibleItems = visibleNotificationToastItems( + newItems, + settings.notificationPopupsEnabled, + isHidden, + ); + if (!settings.notificationPopupsEnabled) setItems([]); if (visibleItems.length > 0) setItems(visibleItems); - }, [blockedFilterReady, enabled, isBlocked, owner, query.data, queryClient]); + }, [ + enabled, + isHidden, + owner, + query.data, + queryClient, + settings.notificationPopupsEnabled, + settingsReady, + visibilityFilterReady, + ]); useEffect(() => { if (items.length === 0 || paused) return; diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index c845df0..e57795e 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -5,13 +5,13 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { recordClientEvent } from "../lib/client-debug-log"; import { toAbsoluteApiUrl } from "../lib/env"; +import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay"; import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { isAbortError } from "../lib/sabr-playback-retry"; import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek"; import { registerSabrVidstackControls } from "../lib/sabr-vidstack-bridge"; import { useAuthStore } from "../stores/auth-store"; import type { SabrMsePlayerProps } from "./sabr-mse-player-types"; - export function SabrMsePlayer({ config, playbackRatePreference, @@ -34,8 +34,6 @@ export function SabrMsePlayer({ const engineRef = useRef(null); const qualityRef = useRef(null); const pendingPlayRef = useRef(false); - const autoplayStartedRef = useRef(false); - const autoplayConfirmedRef = useRef(false); const seekingRef = useRef(false); const errorReportedRef = useRef(false); const attachedVideoRef = useRef(false); @@ -84,6 +82,7 @@ export function SabrMsePlayer({ errorReportedRef.current = false; const replacingVideo = attachedVideoRef.current; attachedVideoRef.current = true; + const autoplayAttempt = new SabrAutoplayAttempt(); const initialConfig = latestConfig(); const engine = new TypeTypeMsePlayer(video, { endpoint: toAbsoluteApiUrl(""), @@ -118,31 +117,35 @@ export function SabrMsePlayer({ playbackRate.apply(video, false); playbackRateSettled = true; }; - const playEngine = () => - engine.play().then(() => { - settlePlaybackRate(); - }); + const playEngine = () => engine.play().then(settlePlaybackRate); playbackRate.initialize(video); video.addEventListener("volumechange", volumeChange); video.addEventListener("ratechange", playbackRateChange); - let autoplayStartTime = 0; let engineLoaded = false; + const autoplayDeadline = new SabrAutoplayDeadline(() => { + if (!autoplayAttempt.expire()) return; + pendingPlayRef.current = false; + video.autoplay = false; + engine.pause(); + }); + const unguardAutoplay = guardAutoplay(video, autoplayAttempt, () => engine.pause()); const startAutoplay = () => { - if (!engineLoaded || autoplayConfirmedRef.current || video.readyState < 3) return; - if (autoplayStartedRef.current) { - if (!video.paused && video.currentTime >= autoplayStartTime + 0.25) { - autoplayConfirmedRef.current = true; - } else if (video.paused) { - autoplayStartedRef.current = false; - } - return; - } + if (!engineLoaded || video.readyState < 3) return; if (!latestHandlers().autoplay && !pendingPlayRef.current) return; - autoplayStartTime = video.currentTime; - autoplayStartedRef.current = true; - void playEngine().catch(() => { - autoplayStartedRef.current = false; - }); + if (!autoplayAttempt.begin()) return; + autoplayDeadline.arm(); + void playEngine() + .then(() => { + autoplayDeadline.clear(); + if (!autoplayAttempt.resolve()) engine.pause(); + }) + .catch((error: unknown) => { + autoplayDeadline.clear(); + if (!autoplayAttempt.reject(error)) { + pendingPlayRef.current = false; + video.autoplay = false; + } + }); }; video.addEventListener("canplay", startAutoplay); const autoplayTimer = window.setInterval(startAutoplay, 250); @@ -150,12 +153,13 @@ export function SabrMsePlayer({ play: () => { pendingPlayRef.current = true; video.autoplay = true; + if (!engineLoaded) return Promise.resolve(); return playEngine(); }, pause: (userInitiated = false) => { - if (!userInitiated && pendingPlayRef.current && !autoplayConfirmedRef.current) return; + if (!userInitiated && pendingPlayRef.current && !autoplayAttempt.isConfirmed) return; pendingPlayRef.current = false; - autoplayConfirmedRef.current = true; + autoplayAttempt.resolve(); video.autoplay = false; return engine.pause(); }, @@ -184,17 +188,18 @@ export function SabrMsePlayer({ latestHandlers().onPositionReaderChange(() => positionMs(video)); return () => { offError(); + unguardAutoplay(); unregisterControls(); video.removeEventListener("volumechange", volumeChange); video.removeEventListener("ratechange", playbackRateChange); video.removeEventListener("canplay", startAutoplay); window.clearInterval(autoplayTimer); + autoplayDeadline.clear(); engine.destroy(); engineRef.current = null; setEngineReady(false); pendingPlayRef.current = false; - autoplayStartedRef.current = false; - autoplayConfirmedRef.current = false; + autoplayAttempt.reset(); cancelPendingSabrSeek(seekingRef); seekingRef.current = false; latestHandlers().onSeekStateChange(false); diff --git a/apps/web/src/components/shorts-error.tsx b/apps/web/src/components/shorts-error.tsx index bdf3606..22722f6 100644 --- a/apps/web/src/components/shorts-error.tsx +++ b/apps/web/src/components/shorts-error.tsx @@ -1,22 +1,37 @@ +import { Link } from "@tanstack/react-router"; +import { YoutubeIcon } from "./youtube-icon"; + type Props = { message: string; onRetry: () => void; onNext: () => void; + youtubeSessionReturnTo?: string; }; -export function ShortsError({ message, onRetry, onNext }: Props) { +export function ShortsError({ message, onRetry, onNext, youtubeSessionReturnTo }: Props) { return ( {message} - - Retry - + {youtubeSessionReturnTo ? ( + + + Connect with YouTube + + ) : ( + + Retry + + )} )} {state !== "loading" && state !== "ready" && ( - + )} {stream && playbackProps && state === "ready" && ( diff --git a/apps/web/src/components/watch-stream-error.tsx b/apps/web/src/components/watch-stream-error.tsx index 9cc4e01..3ca3e81 100644 --- a/apps/web/src/components/watch-stream-error.tsx +++ b/apps/web/src/components/watch-stream-error.tsx @@ -1,7 +1,7 @@ import { isStreamUnavailableError } 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 { isYoutubeSessionActionError } from "../lib/api-youtube-session"; import { resolveVideoAvailability, videoAvailabilityCopy } from "../lib/video-availability"; import { youtubeSessionReturnToForWatch } from "../lib/youtube-session-route"; import { StreamError } from "./stream-error"; @@ -22,7 +22,7 @@ export function WatchStreamError({ error, publicParam, list, shuffle, poster, on error.message === "Error occurs when fetching the page. Try increase the loading timeout in Settings."; const availability = genericExtractorError ? "members_only" : resolveVideoAvailability(error); - const needsYoutubeSession = isYoutubeSessionReconnectError(error); + const needsYoutubeSession = isYoutubeSessionActionError(error); const familyListBlocked = isChannelNotAllowedError(error); const youtubeSessionReturnTo = needsYoutubeSession ? youtubeSessionReturnToForWatch(publicParam, list, shuffle) @@ -34,7 +34,7 @@ export function WatchStreamError({ error, publicParam, list, shuffle, poster, on : familyListBlocked ? FAMILY_LIST_BLOCKED_MESSAGE : needsYoutubeSession - ? "Connect YouTube to load this browser-only video." + ? "Connect YouTube to access this video." : error instanceof ApiError && (error.status === 400 || error.status === 422) ? error.message : isStreamUnavailableError(error) diff --git a/apps/web/src/hooks/use-blocked-filter.ts b/apps/web/src/hooks/use-blocked-filter.ts index 1c89f69..f2b9418 100644 --- a/apps/web/src/hooks/use-blocked-filter.ts +++ b/apps/web/src/hooks/use-blocked-filter.ts @@ -4,14 +4,17 @@ import { type BlockedChannelIdentity, createBlockedContentMatcher, } from "../lib/blocked-content"; +import { filterMembersOnlyContent, isMembersOnlyContentHidden } from "../lib/video-visibility"; import type { ChannelResultItem } from "../types/api"; import type { PublicPlaylistInfo } from "../types/playlist"; import { useAuth } from "./use-auth"; import { useBlocked } from "./use-blocked"; +import { useSettings } from "./use-settings"; export function useBlockedFilter() { const { isAuthed } = useAuth(); const { channels, videos, keywords } = useBlocked(); + const { settings, settingsReady } = useSettings(); const matcher = useMemo( () => @@ -29,8 +32,16 @@ export function useBlockedFilter() { ); const filter = useCallback( - (streams: T[]): T[] => matcher.filterVideos(streams), - [matcher], + (streams: T[]): T[] => + filterMembersOnlyContent(matcher.filterVideos(streams), settings.hideMembersOnlyContent), + [matcher, settings.hideMembersOnlyContent], + ); + + const isHidden = useCallback( + (stream: BlockableVideo): boolean => + matcher.isVideoBlocked(stream) || + isMembersOnlyContentHidden(stream, settings.hideMembersOnlyContent), + [matcher, settings.hideMembersOnlyContent], ); const isChannelIdentityBlocked = useCallback( @@ -70,6 +81,7 @@ export function useBlockedFilter() { findBlockedChannel, findBlockedVideo, isBlocked, + isHidden, isChannelBlocked, isChannelIdentityBlocked, isPlaylistBlocked, @@ -77,6 +89,6 @@ export function useBlockedFilter() { blockedChannelUrls: matcher.channelUrls, blockedKeywords: matcher.normalizedKeywords, blockedVideoUrls: matcher.videoUrls, - ready: channels.isSuccess && videos.isSuccess && keywords.isSuccess, + ready: settingsReady && channels.isSuccess && videos.isSuccess && keywords.isSuccess, }; } diff --git a/apps/web/src/hooks/use-player-keyboard.ts b/apps/web/src/hooks/use-player-keyboard.ts index a349fc1..6795c4a 100644 --- a/apps/web/src/hooks/use-player-keyboard.ts +++ b/apps/web/src/hooks/use-player-keyboard.ts @@ -8,7 +8,7 @@ import { keyboardSeekOffset, nextKeyboardSeekTarget, } from "../components/player-hotkeys-utils"; -import { requestSabrSeek } from "../lib/sabr-vidstack-bridge"; +import { requestSabrSeek, requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge"; import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack"; import { useHoldFastForward } from "./use-hold-fast-forward"; @@ -36,6 +36,10 @@ export function usePlayerKeyboard(canSeek: boolean, sabrVideo: HTMLVideoElement useEffect(() => { function togglePaused() { + if (sabrVideo) { + void requestSabrVidstackPlayback(sabrVideo, pausedRef.current, true).catch(() => {}); + return; + } if (pausedRef.current) void Promise.resolve(remote.play()).catch(() => {}); else void Promise.resolve(remote.pause()).catch(() => {}); } diff --git a/apps/web/src/hooks/use-progress.ts b/apps/web/src/hooks/use-progress.ts index 1030c2b..ffb478d 100644 --- a/apps/web/src/hooks/use-progress.ts +++ b/apps/web/src/hooks/use-progress.ts @@ -11,7 +11,10 @@ export function useProgress(videoUrl: string) { queryFn: () => isAuthed ? fetchProgress(videoUrl) : Promise.resolve({ videoUrl, position: 0, updatedAt: 0 }), retry: false, - staleTime: Infinity, + staleTime: 0, + refetchOnMount: "always", + refetchOnReconnect: false, + refetchOnWindowFocus: false, enabled: authReady && videoUrl.length > 0, }); } diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts index e0f4871..436c228 100644 --- a/apps/web/src/hooks/use-settings.ts +++ b/apps/web/src/hooks/use-settings.ts @@ -23,6 +23,7 @@ const DEFAULTS: SettingsItem = { audioOnlyPlayback: false, volume: 1, muted: false, + notificationPopupsEnabled: true, subtitlesEnabled: false, defaultSubtitleLanguage: "", defaultAudioLanguage: "", @@ -48,6 +49,7 @@ const DEFAULTS: SettingsItem = { hideComments: false, hideShorts: false, hideSubscriptionLiveStreams: false, + hideMembersOnlyContent: false, accessMode: "unrestricted", captionStyles: EMPTY_CAPTION_STYLES, }; @@ -105,7 +107,10 @@ export function useSettings({ forceAnonymous = false }: UseSettingsOptions = {}) onSuccess: (data, _patch, context) => { const current = qc.getQueryData(KEY); qc.setQueryData(KEY, { ...DEFAULTS, ...current, ...data, ...context?.patch }); - if (context?.patch.hideSubscriptionLiveStreams !== undefined) { + if ( + context?.patch.hideSubscriptionLiveStreams !== undefined || + context?.patch.hideMembersOnlyContent !== undefined + ) { void qc.resetQueries({ queryKey: ["subscription-feed"] }); } }, diff --git a/apps/web/src/hooks/use-shorts-active-stream.ts b/apps/web/src/hooks/use-shorts-active-stream.ts index 3f9e553..091ddb1 100644 --- a/apps/web/src/hooks/use-shorts-active-stream.ts +++ b/apps/web/src/hooks/use-shorts-active-stream.ts @@ -1,4 +1,5 @@ import { ApiError } from "../lib/api"; +import { isYoutubeSessionActionError } from "../lib/api-youtube-session"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { detectProvider } from "../lib/provider"; import { toPublicWatchParam } from "../lib/watch-url"; @@ -38,6 +39,7 @@ export function useShortsActiveStream({ shorts, index, useAuthenticatedStream, e const errorMessage = failed && error instanceof ApiError ? error.message : "Couldn't load this short."; const isMemberOnlyShort = isMemberOnlyApiError(error); + const needsYoutubeSession = isYoutubeSessionActionError(error); return { active, @@ -52,5 +54,6 @@ export function useShortsActiveStream({ shorts, index, useAuthenticatedStream, e current, errorMessage, isMemberOnlyShort, + needsYoutubeSession, }; } diff --git a/apps/web/src/lib/api-auth-status.ts b/apps/web/src/lib/api-auth-status.ts index 05ce6f3..53ccf30 100644 --- a/apps/web/src/lib/api-auth-status.ts +++ b/apps/web/src/lib/api-auth-status.ts @@ -28,7 +28,7 @@ function readMessage(payload: unknown, fallback: string): string { } export async function fetchRegisterStatus(): Promise { - const res = await fetch(`${BASE}/auth/register/status`); + const res = await fetch(`${BASE}/auth/register/status`, { cache: "no-store" }); const payload = await res.json().catch(() => null); if (!res.ok) { throw new ApiError(readMessage(payload, "Unable to read registration status"), res.status); diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts index 226c22b..f339653 100644 --- a/apps/web/src/lib/api-collections.ts +++ b/apps/web/src/lib/api-collections.ts @@ -10,6 +10,7 @@ import { ApiError } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE as BASE } from "./env"; +import { progressWriteQueue } from "./progress-write-queue"; async function throwIfFailed(res: Response, fallback: string): Promise { if (res.ok) return; @@ -18,6 +19,7 @@ async function throwIfFailed(res: Response, fallback: string): Promise { } export async function fetchProgress(videoUrl: string): Promise { + await progressWriteQueue.settle(videoUrl); const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`, undefined, { silentStatuses: [404], }); @@ -32,16 +34,18 @@ export async function updateProgress( position: number, keepalive = false, ): Promise { - const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ position: Math.round(position) }), - keepalive, + return progressWriteQueue.enqueue(videoUrl, async () => { + const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position: Math.round(position) }), + keepalive, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: "update failed" })); + throw new ApiError((body as { error: string }).error, res.status); + } }); - if (!res.ok) { - const body = await res.json().catch(() => ({ error: "update failed" })); - throw new ApiError((body as { error: string }).error, res.status); - } } export function fetchBlockedChannels(): Promise { diff --git a/apps/web/src/lib/api-youtube-session.ts b/apps/web/src/lib/api-youtube-session.ts index 3859aa8..70b9047 100644 --- a/apps/web/src/lib/api-youtube-session.ts +++ b/apps/web/src/lib/api-youtube-session.ts @@ -16,8 +16,11 @@ export type YoutubeRemoteBrowserSession = { expiresAt: number; }; -export function isYoutubeSessionReconnectError(error: unknown): boolean { - return error instanceof ApiError && error.code === "youtube_session_needs_reconnect"; +export function isYoutubeSessionActionError(error: unknown): boolean { + return ( + error instanceof ApiError && + (error.code === "youtube_session_required" || error.code === "youtube_session_needs_reconnect") + ); } export function fetchYoutubeSessionStatus(): Promise { diff --git a/apps/web/src/lib/auth-routes.ts b/apps/web/src/lib/auth-routes.ts index d7bc24f..cc9a711 100644 --- a/apps/web/src/lib/auth-routes.ts +++ b/apps/web/src/lib/auth-routes.ts @@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [ "/youtube-session", ]; const AUTH_PAGES = ["/login", "/register", "/reset-password", "/auth/oidc/callback"]; +const OIDC_CALLBACK_PAGE = "/auth/oidc/callback"; export function requiresAuth(pathname: string): boolean { return PROTECTED_PREFIXES.some( @@ -44,6 +45,10 @@ export function isAuthPage(pathname: string): boolean { return AUTH_PAGES.some((page) => pathname === page || pathname.startsWith(`${page}/`)); } +export function shouldEnforceBootstrapRegistration(pathname: string): boolean { + return pathname !== OIDC_CALLBACK_PAGE; +} + export function sanitizeRedirect(value: string | undefined): RedirectTarget { if (!value) return "/"; if (value.startsWith(SHORTS_REDIRECT_PREFIX)) return value as `/shorts?v=${string}`; diff --git a/apps/web/src/lib/blocked-content.ts b/apps/web/src/lib/blocked-content.ts index e17d608..559f941 100644 --- a/apps/web/src/lib/blocked-content.ts +++ b/apps/web/src/lib/blocked-content.ts @@ -10,6 +10,7 @@ export type BlockableVideo = BlockedChannelIdentity & { title?: string | null; channelUrl?: string | null; channelName?: string | null; + requiresMembership?: boolean; }; type BlockedItem = { diff --git a/apps/web/src/lib/media-progress-position.ts b/apps/web/src/lib/media-progress-position.ts new file mode 100644 index 0000000..6bafb68 --- /dev/null +++ b/apps/web/src/lib/media-progress-position.ts @@ -0,0 +1,11 @@ +import { isSabrPlaybackEventTransient } from "./sabr-vidstack-bridge"; + +export function resolveMediaProgressPosition( + video: HTMLVideoElement | null, + currentPositionMs: number, + requestedPositionMs: number | null = null, +): number | null { + if (requestedPositionMs !== null) return requestedPositionMs; + if (video && isSabrPlaybackEventTransient(video)) return null; + return currentPositionMs; +} diff --git a/apps/web/src/lib/notification-toast-cursor.ts b/apps/web/src/lib/notification-toast-cursor.ts index 8f393db..2efdd23 100644 --- a/apps/web/src/lib/notification-toast-cursor.ts +++ b/apps/web/src/lib/notification-toast-cursor.ts @@ -39,6 +39,14 @@ export function findNewNotificationItems( ); } +export function visibleNotificationToastItems( + items: NotificationItem[], + popupsEnabled: boolean, + isHidden: (item: NotificationItem["video"]) => boolean, +): NotificationItem[] { + return popupsEnabled ? items.filter((item) => !isHidden(item.video)) : []; +} + export function advanceNotificationToastCursor( cursor: NotificationToastCursor, items: NotificationItem[], diff --git a/apps/web/src/lib/progress-write-queue.ts b/apps/web/src/lib/progress-write-queue.ts new file mode 100644 index 0000000..7e2f5dc --- /dev/null +++ b/apps/web/src/lib/progress-write-queue.ts @@ -0,0 +1,26 @@ +type ProgressWrite = () => Promise; + +export class ProgressWriteQueue { + private readonly pending = new Map>(); + + enqueue(videoUrl: string, write: ProgressWrite): Promise { + const previous = this.pending.get(videoUrl) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(write); + this.pending.set(videoUrl, next); + void next.then( + () => this.removeIfCurrent(videoUrl, next), + () => this.removeIfCurrent(videoUrl, next), + ); + return next; + } + + async settle(videoUrl: string): Promise { + await this.pending.get(videoUrl)?.catch(() => undefined); + } + + private removeIfCurrent(videoUrl: string, write: Promise): void { + if (this.pending.get(videoUrl) === write) this.pending.delete(videoUrl); + } +} + +export const progressWriteQueue = new ProgressWriteQueue(); diff --git a/apps/web/src/lib/sabr-autoplay.ts b/apps/web/src/lib/sabr-autoplay.ts new file mode 100644 index 0000000..9231857 --- /dev/null +++ b/apps/web/src/lib/sabr-autoplay.ts @@ -0,0 +1,115 @@ +import { isSabrPlaybackEventTransient } from "./sabr-vidstack-bridge"; + +export function isAutoplayPolicyError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "NotAllowedError" + ); +} + +export class SabrAutoplayAttempt { + private confirmed = false; + private expired = false; + private pending = false; + + get isConfirmed(): boolean { + return this.confirmed; + } + + get isExpired(): boolean { + return this.expired; + } + + begin(): boolean { + if (this.confirmed || this.pending) return false; + this.pending = true; + return true; + } + + resolve(): boolean { + this.pending = false; + if (this.confirmed) return false; + this.confirmed = true; + return true; + } + + expire(): boolean { + if (this.confirmed || !this.pending) return false; + this.pending = false; + this.confirmed = true; + this.expired = true; + return true; + } + + allow(): void { + this.expired = false; + } + + reject(error: unknown): boolean { + this.pending = false; + if (this.confirmed) return false; + if (!isAutoplayPolicyError(error)) return true; + this.confirmed = true; + return false; + } + + reset(): void { + this.confirmed = false; + this.expired = false; + this.pending = false; + } +} + +export function guardAutoplay( + video: HTMLVideoElement, + attempt: SabrAutoplayAttempt, + pause: () => void, +): () => void { + const stopExpiredPlayback = () => { + if (attempt.isExpired && !isSabrPlaybackEventTransient(video)) pause(); + }; + const root = video.closest(".typetype-player-surface"); + const allowPlayback = (event: Event) => { + if ( + event.target === video || + event.target === root || + (event.target instanceof Element && event.target.closest(".vds-play-button")) + ) + attempt.allow(); + }; + video.addEventListener("play", stopExpiredPlayback); + root?.addEventListener("pointerup", allowPlayback, true); + root?.addEventListener("click", allowPlayback, true); + return () => { + video.removeEventListener("play", stopExpiredPlayback); + root?.removeEventListener("pointerup", allowPlayback, true); + root?.removeEventListener("click", allowPlayback, true); + }; +} + +export class SabrAutoplayDeadline { + private timer: ReturnType | undefined; + private readonly onExpire: () => void; + private readonly timeoutMs: number; + + constructor(onExpire: () => void, timeoutMs = 250) { + this.onExpire = onExpire; + this.timeoutMs = timeoutMs; + } + + arm(): void { + this.clear(); + this.timer = globalThis.setTimeout(() => { + this.timer = undefined; + this.onExpire(); + }, this.timeoutMs); + } + + clear(): void { + if (this.timer === undefined) return; + globalThis.clearTimeout(this.timer); + this.timer = undefined; + } +} diff --git a/apps/web/src/lib/sabr-codec-capabilities.ts b/apps/web/src/lib/sabr-codec-capabilities.ts index 71182d3..88c4f04 100644 --- a/apps/web/src/lib/sabr-codec-capabilities.ts +++ b/apps/web/src/lib/sabr-codec-capabilities.ts @@ -1,4 +1,5 @@ import type { SabrQualityOption } from "../stores/sabr-quality-store"; +import { sabrQualityTier } from "./sabr-quality-tier"; import { defaultSabrItag } from "./sabr-source"; export type SabrCodecProbe = ( @@ -52,7 +53,8 @@ export async function bestSabrItag( if (!probe || fallback === null) return fallback; const fallbackOption = options.find((option) => option.itag === fallback); if (!fallbackOption) return fallback; - const candidates = options.filter((option) => option.height === fallbackOption.height); + const fallbackTier = sabrQualityTier(fallbackOption); + const candidates = options.filter((option) => sabrQualityTier(option) === fallbackTier); const scored = (await Promise.all(candidates.map((option) => scoreOption(option, probe)))).filter( (item): item is ScoredOption => item !== null, ); diff --git a/apps/web/src/lib/sabr-quality-selection.ts b/apps/web/src/lib/sabr-quality-selection.ts index d060c2d..24239d8 100644 --- a/apps/web/src/lib/sabr-quality-selection.ts +++ b/apps/web/src/lib/sabr-quality-selection.ts @@ -1,5 +1,6 @@ import type { SabrQualityOption } from "../stores/sabr-quality-store"; import type { CodecFamily } from "./quality-utils"; +import { sabrQualityTier } from "./sabr-quality-tier"; const SABR_CODEC_ORDER: CodecFamily[] = ["H.264", "VP9", "AV1"]; @@ -7,12 +8,13 @@ export function sabrResolutionOptions( options: SabrQualityOption[], selected: SabrQualityOption, ): SabrQualityOption[] { - const byHeight = new Map(); + const byTier = new Map(); for (const option of options) { - const current = byHeight.get(option.height); - if (!current || option.codec === selected.codec) byHeight.set(option.height, option); + const tier = sabrQualityTier(option); + const current = byTier.get(tier); + if (!current || option.codec === selected.codec) byTier.set(tier, option); } - return [...byHeight.values()].sort((left, right) => right.height - left.height); + return [...byTier.values()].sort((left, right) => sabrQualityTier(right) - sabrQualityTier(left)); } export function sabrCodecOptions(options: SabrQualityOption[]): CodecFamily[] { @@ -27,18 +29,19 @@ export function selectSabrCodec( ): SabrQualityOption | null { const matching = options .filter((option) => option.codec === codec) - .sort((left, right) => right.height - left.height); + .sort((left, right) => sabrQualityTier(right) - sabrQualityTier(left)); + const selectedTier = sabrQualityTier(selected); return ( - matching.find((option) => option.height === selected.height) ?? - matching.find((option) => option.height < selected.height) ?? + matching.find((option) => sabrQualityTier(option) === selectedTier) ?? + matching.find((option) => sabrQualityTier(option) < selectedTier) ?? matching.at(-1) ?? null ); } -export function maxSabrCodecHeight(options: SabrQualityOption[], codec: CodecFamily): number { - return Math.max( - 0, - ...options.filter((option) => option.codec === codec).map((item) => item.height), - ); +export function maxSabrCodecLabel(options: SabrQualityOption[], codec: CodecFamily): string { + const highest = options + .filter((option) => option.codec === codec) + .sort((left, right) => sabrQualityTier(right) - sabrQualityTier(left))[0]; + return highest?.label ?? ""; } diff --git a/apps/web/src/lib/sabr-quality-tier.ts b/apps/web/src/lib/sabr-quality-tier.ts new file mode 100644 index 0000000..8e6d7e7 --- /dev/null +++ b/apps/web/src/lib/sabr-quality-tier.ts @@ -0,0 +1,6 @@ +import type { SabrQualityOption } from "../stores/sabr-quality-store"; + +export function sabrQualityTier(option: Pick): number { + const tier = Number.parseInt(option.label, 10); + return Number.isFinite(tier) && tier > 0 ? tier : option.height; +} diff --git a/apps/web/src/lib/sabr-source.ts b/apps/web/src/lib/sabr-source.ts index b8fbdeb..499eb4b 100644 --- a/apps/web/src/lib/sabr-source.ts +++ b/apps/web/src/lib/sabr-source.ts @@ -4,6 +4,7 @@ import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import type { VideoStream } from "../types/stream"; import { type CodecFamily, codecFamily } from "./quality-utils"; import { pickSabrAudio } from "./sabr-audio"; +import { sabrQualityTier } from "./sabr-quality-tier"; type SabrCandidate = VideoStreamItem | AudioStreamItem; type SabrSelection = { @@ -40,8 +41,7 @@ export function isSabrVideoSupported( } function qualityLabel(video: VideoStreamItem): string { - if (video.height > 0) return `${video.height}p`; - return video.resolution || `itag ${video.itag}`; + return video.resolution || (video.height > 0 ? `${video.height}p` : `itag ${video.itag}`); } export function sabrQualityOptions(stream: VideoStream): SabrQualityOption[] { @@ -78,11 +78,13 @@ export function defaultSabrItag( ): number | null { if (options.length === 0) return null; const defaultHeight = defaultQuality ? Number.parseInt(defaultQuality, 10) : 720; - const targetHeight = options.find( - (option) => option.height <= (Number.isFinite(defaultHeight) ? defaultHeight : 720), - )?.height; + const limit = Number.isFinite(defaultHeight) ? defaultHeight : 720; + const targetHeight = Math.max( + 0, + ...options.map(sabrQualityTier).filter((height) => height <= limit), + ); const preferred = ["H.264", "VP9", "AV1"].flatMap((codec) => - options.filter((option) => option.height === targetHeight && option.codec === codec), + options.filter((option) => sabrQualityTier(option) === targetHeight && option.codec === codec), )[0]; return preferred?.itag ?? options.at(-1)?.itag ?? null; } diff --git a/apps/web/src/lib/sabr-vidstack-bridge.ts b/apps/web/src/lib/sabr-vidstack-bridge.ts index 462e175..384cfa5 100644 --- a/apps/web/src/lib/sabr-vidstack-bridge.ts +++ b/apps/web/src/lib/sabr-vidstack-bridge.ts @@ -8,6 +8,7 @@ export type SabrVidstackControls = { const controlsByVideo = new WeakMap(); const pendingPlaybackByVideo = new WeakMap(); +const pendingSeekTargetByVideo = new WeakMap(); export function registerSabrVidstackControls( video: HTMLVideoElement, @@ -19,7 +20,9 @@ export function registerSabrVidstackControls( if (pendingPlayback === true) void controls.play().catch(() => {}); else if (pendingPlayback === false) controls.pause(); return () => { - if (controlsByVideo.get(video) === controls) controlsByVideo.delete(video); + if (controlsByVideo.get(video) !== controls) return; + controlsByVideo.delete(video); + pendingSeekTargetByVideo.delete(video); }; } @@ -33,18 +36,33 @@ export function isSabrPlaybackEventTransient(video: HTMLVideoElement): boolean { export function requestSabrSeek(video: HTMLVideoElement, seconds: number): boolean { const controls = getSabrVidstackControls(video); - if (!controls) return false; - controls.seek(seconds); + if (!controls || !Number.isFinite(seconds)) return false; + const target = Math.max(0, seconds); + pendingSeekTargetByVideo.set(video, target); + try { + controls.seek(target); + } catch (error) { + pendingSeekTargetByVideo.delete(video); + throw error; + } return true; } +export function consumeSabrSeekTarget(video: HTMLVideoElement): number | null { + const target = pendingSeekTargetByVideo.get(video); + pendingSeekTargetByVideo.delete(video); + return target === undefined ? null : Math.round(target * 1000); +} + export function requestSabrVidstackPlayback( video: HTMLVideoElement, playing: boolean, userInitiated = false, ): Promise { const controls = getSabrVidstackControls(video); - if (!playing && !userInitiated && controls?.isTransitioning?.()) return Promise.resolve(); + const hidden = typeof document !== "undefined" && document.visibilityState === "hidden"; + if (!playing && !userInitiated && (hidden || controls?.isTransitioning?.())) + return Promise.resolve(); video.autoplay = playing; if (!controls) { pendingPlaybackByVideo.set(video, playing); diff --git a/apps/web/src/lib/sabr-vidstack-provider.ts b/apps/web/src/lib/sabr-vidstack-provider.ts index ef0976f..e0e9b18 100644 --- a/apps/web/src/lib/sabr-vidstack-provider.ts +++ b/apps/web/src/lib/sabr-vidstack-provider.ts @@ -1,19 +1,44 @@ import { requestSabrSeek, requestSabrVidstackPlayback } from "./sabr-vidstack-bridge"; import type { VideoProvider } from "./vidstack"; +type InitialPositionState = { + protected: boolean; + resumePosition: number; + tracking: boolean; +}; + +const initialPositionByVideo = new WeakMap(); + export function bindSabrVideoProvider(provider: VideoProvider): VideoProvider { - let protectInitialPosition = true; + const initialPosition = initialPositionByVideo.get(provider.video) ?? { + protected: true, + resumePosition: 0, + tracking: false, + }; + initialPositionByVideo.set(provider.video, initialPosition); + if (!initialPosition.tracking) { + initialPosition.tracking = true; + const rememberPosition = () => { + if (provider.video.currentTime > 0) + initialPosition.resumePosition = provider.video.currentTime; + }; + for (const event of ["playing", "seeked", "seeking", "timeupdate"]) { + provider.video.addEventListener(event, rememberPosition); + } + } provider.loadSource = async () => undefined; - provider.play = () => requestSabrVidstackPlayback(provider.video, true); + provider.play = () => { + initialPosition.resumePosition = provider.video.currentTime; + return requestSabrVidstackPlayback(provider.video, true); + }; provider.pause = () => requestSabrVidstackPlayback(provider.video, false); provider.setCurrentTime = (time) => { const isInitialPlaybackReset = - protectInitialPosition && - provider.video.autoplay && + initialPosition.protected && time === 0 && - provider.video.currentTime > 0 && + initialPosition.resumePosition > 0 && !provider.video.ended; - protectInitialPosition = false; + if (time === 0) initialPosition.protected = false; if (isInitialPlaybackReset) return; requestSabrSeek(provider.video, time); }; diff --git a/apps/web/src/lib/video-visibility.ts b/apps/web/src/lib/video-visibility.ts new file mode 100644 index 0000000..ec6a946 --- /dev/null +++ b/apps/web/src/lib/video-visibility.ts @@ -0,0 +1,15 @@ +import type { BlockableVideo } from "./blocked-content"; + +export function isMembersOnlyContentHidden( + video: BlockableVideo, + hideMembersOnlyContent: boolean, +): boolean { + return hideMembersOnlyContent && video.requiresMembership === true; +} + +export function filterMembersOnlyContent( + videos: T[], + hideMembersOnlyContent: boolean, +): T[] { + return videos.filter((video) => !isMembersOnlyContentHidden(video, hideMembersOnlyContent)); +} diff --git a/apps/web/src/lib/watch-resume.ts b/apps/web/src/lib/watch-resume.ts index 08b7579..ea1fc4d 100644 --- a/apps/web/src/lib/watch-resume.ts +++ b/apps/web/src/lib/watch-resume.ts @@ -6,6 +6,14 @@ type WatchResumeInput = { durationSeconds: number; }; +export function shouldWaitForWatchProgress( + authenticated: boolean, + pending: boolean, + fetching: boolean, +): boolean { + return authenticated && (pending || fetching); +} + export function resolveWatchStartTime(input: WatchResumeInput): number | null { if (input.authenticated && input.progressPending) return null; diff --git a/apps/web/src/lib/youtube-session-route.ts b/apps/web/src/lib/youtube-session-route.ts index 557a7c4..03a2828 100644 --- a/apps/web/src/lib/youtube-session-route.ts +++ b/apps/web/src/lib/youtube-session-route.ts @@ -11,8 +11,12 @@ export function youtubeSessionReturnToForWatch( return `/watch?${params.toString()}`; } +export function youtubeSessionReturnToForShorts(v: string): string { + return `/shorts?${new URLSearchParams({ v }).toString()}`; +} + export function sanitizeYoutubeSessionReturnTo(value: unknown): string | undefined { - if (typeof value !== "string" || value.length > 800 || !value.startsWith("/watch?")) { + if (typeof value !== "string" || value.length > 800) { return undefined; } let url: URL; @@ -21,8 +25,11 @@ export function sanitizeYoutubeSessionReturnTo(value: unknown): string | undefin } catch { return undefined; } + if (url.origin !== "https://typetype.invalid") return undefined; const v = url.searchParams.get("v")?.trim(); if (!v) return undefined; + if (url.pathname === "/shorts") return youtubeSessionReturnToForShorts(v); + if (url.pathname !== "/watch") return undefined; return youtubeSessionReturnToForWatch( v, url.searchParams.get("list") ?? undefined, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 42f3931..8964e18 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -11,7 +11,12 @@ import { useInstance } from "../hooks/use-instance"; import { useMobile } from "../hooks/use-mobile"; import { useRegisterStatus } from "../hooks/use-register-status"; import { useSessionActivityReporting } from "../hooks/use-session-activity-reporting"; -import { isAdminRoute, isAuthPage, requiresAuth } from "../lib/auth-routes"; +import { + isAdminRoute, + isAuthPage, + requiresAuth, + shouldEnforceBootstrapRegistration, +} from "../lib/auth-routes"; import { bootstrapSession } from "../lib/auth-session"; import { isEmbeddedFrame } from "../lib/embed-access"; import { applyTheme } from "../lib/theme"; @@ -79,7 +84,7 @@ function RootLayout() { useEffect(() => { if (status === "loading") return; - if (registerStatus.data?.bootstrapAvailable) { + if (registerStatus.data?.bootstrapAvailable && shouldEnforceBootstrapRegistration(pathname)) { setSignedOut(); if (pathname !== "/register") { const redirect = isAuthPage(pathname) diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index 201d765..cac068f 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -9,7 +9,7 @@ import { useSettings } from "../hooks/use-settings"; import { isStreamUnavailableError, useSabrBootstrap, useStream } from "../hooks/use-stream"; import { FAMILY_LIST_BLOCKED_MESSAGE, isChannelNotAllowedError } from "../lib/allow-list-error"; import { ApiError } from "../lib/api"; -import { isYoutubeSessionReconnectError } from "../lib/api-youtube-session"; +import { isYoutubeSessionActionError } 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"; @@ -92,7 +92,7 @@ function EmbedPage() { const availability = genericExtractorError ? "members_only" : resolveVideoAvailability(activeError); - const needsYoutubeSession = isYoutubeSessionReconnectError(activeError); + const needsYoutubeSession = isYoutubeSessionActionError(activeError); const familyListBlocked = isChannelNotAllowedError(activeError); const message = availability ? activeError instanceof Error @@ -101,7 +101,7 @@ function EmbedPage() { : familyListBlocked ? FAMILY_LIST_BLOCKED_MESSAGE : needsYoutubeSession - ? "Connect YouTube to load this browser-only video." + ? "Connect YouTube to access this video." : activeError instanceof ApiError && (activeError.status === 400 || activeError.status === 422) ? activeError.message @@ -113,6 +113,7 @@ function EmbedPage() { message={message} availability={availability ?? undefined} poster={availabilityPoster} + watchUrl={needsYoutubeSession ? watchUrl : undefined} onRetry={ availability || needsYoutubeSession || familyListBlocked ? undefined diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index e91ae3f..36124e2 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -13,7 +13,7 @@ import { useSabrBootstrap, useStream } from "../hooks/use-stream"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; import { videoAvailabilityCopy } from "../lib/video-availability"; -import { resolveWatchStartTime } from "../lib/watch-resume"; +import { resolveWatchStartTime, shouldWaitForWatchProgress } from "../lib/watch-resume"; import { toPublicWatchParam, toWatchSourceUrl, youtubeThumbnailUrl } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; @@ -62,7 +62,11 @@ function WatchPage() { const addToHistoryRef = useRef(add.mutate); addToHistoryRef.current = add.mutate; const historyAddedForRef = useRef(null); - const resumePending = isAuthed && progressFetch.isPending; + const resumePending = shouldWaitForWatchProgress( + isAuthed, + progressFetch.isPending, + progressFetch.isFetching, + ); useEffect(() => { if (v.trim() && publicParam !== v.trim()) { @@ -125,7 +129,7 @@ function WatchPage() { const startTime = resolveWatchStartTime({ authenticated: isAuthed, - progressPending: progressFetch.isPending, + progressPending: resumePending, savedPositionMs: progressFetch.data?.position, serverPositionSeconds: activeStream.startPosition, durationSeconds: activeStream.duration, diff --git a/apps/web/src/settings/hide-everything-toggle.tsx b/apps/web/src/settings/hide-everything-toggle.tsx index d5c76a7..c1c5543 100644 --- a/apps/web/src/settings/hide-everything-toggle.tsx +++ b/apps/web/src/settings/hide-everything-toggle.tsx @@ -45,6 +45,7 @@ export function HideEverythingToggle() { hideComments: true, hideShorts: true, hideSubscriptionLiveStreams: true, + hideMembersOnlyContent: true, }); allowHideEverything(); navigate({ to: "/hide-everything" }); diff --git a/apps/web/src/settings/settings-content-toggles.tsx b/apps/web/src/settings/settings-content-toggles.tsx index 898641d..6f48f9e 100644 --- a/apps/web/src/settings/settings-content-toggles.tsx +++ b/apps/web/src/settings/settings-content-toggles.tsx @@ -14,6 +14,7 @@ const HIDE_KEYS = [ "hideComments", "hideShorts", "hideSubscriptionLiveStreams", + "hideMembersOnlyContent", ] as const; function useHideEverythingTrigger() { @@ -40,6 +41,7 @@ type ToggleKey = Extract< | "hideComments" | "hideShorts" | "hideSubscriptionLiveStreams" + | "hideMembersOnlyContent" >; type ToggleOption = { @@ -101,6 +103,12 @@ const DISCOVERY_OPTIONS: ToggleOption[] = [ description: "Hide active and scheduled live streams from the subscriptions feed.", area: "Subscriptions", }, + { + key: "hideMembersOnlyContent", + label: "Members-only videos", + description: "Hide videos that require a paid YouTube channel membership.", + area: "Discovery", + }, ]; function ToggleRows({ options }: { options: ToggleOption[] }) { diff --git a/apps/web/src/settings/settings-landing-page.tsx b/apps/web/src/settings/settings-landing-page.tsx index d32e96c..103e3cc 100644 --- a/apps/web/src/settings/settings-landing-page.tsx +++ b/apps/web/src/settings/settings-landing-page.tsx @@ -1,5 +1,6 @@ import { useSettings } from "../hooks/use-settings"; import { SettingsDiscoveryToggles } from "./settings-content-toggles"; +import { ROW, ToggleSwitch } from "./settings-toggle-switch"; const LANDING_OPTIONS = [ { value: "home", label: "Home" }, @@ -36,6 +37,25 @@ export function SettingsLandingPage() { ))} + + Notifications + + + + + Notification popups + + Show a popup when a subscribed channel publishes a video. + + + + update.mutate({ notificationPopupsEnabled: !settings.notificationPopupsEnabled }) + } + /> + + diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 6071521..7179f15 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -112,6 +112,7 @@ export type SettingsItem = { audioOnlyPlayback: boolean; volume: number; muted: boolean; + notificationPopupsEnabled: boolean; subtitlesEnabled: boolean; defaultSubtitleLanguage: string; defaultAudioLanguage: string; @@ -137,6 +138,7 @@ export type SettingsItem = { hideComments: boolean; hideShorts: boolean; hideSubscriptionLiveStreams: boolean; + hideMembersOnlyContent: boolean; accessMode: AccessMode; captionStyles: CaptionStyles; }; diff --git a/apps/web/tests/api-auth-status.test.ts b/apps/web/tests/api-auth-status.test.ts new file mode 100644 index 0000000..c795e81 --- /dev/null +++ b/apps/web/tests/api-auth-status.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, mock, test } from "bun:test"; + +if (!("localStorage" in globalThis)) { + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }, + }); +} + +const { fetchRegisterStatus } = await import("../src/lib/api-auth-status"); + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("registration status bypasses browser caches", async () => { + globalThis.fetch = mock(async () => + Response.json({ allowRegistration: false, bootstrapAvailable: false }), + ); + + await fetchRegisterStatus(); + + expect(globalThis.fetch).toHaveBeenCalledWith("/api/auth/register/status", { + cache: "no-store", + }); +}); diff --git a/apps/web/tests/auth-routes.test.ts b/apps/web/tests/auth-routes.test.ts new file mode 100644 index 0000000..e3f9f35 --- /dev/null +++ b/apps/web/tests/auth-routes.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { isAuthPage, shouldEnforceBootstrapRegistration } from "../src/lib/auth-routes"; + +describe("authentication routes", () => { + test("keeps the OIDC callback active during administrator bootstrap", () => { + expect(isAuthPage("/auth/oidc/callback")).toBe(true); + expect(shouldEnforceBootstrapRegistration("/auth/oidc/callback")).toBe(false); + }); + + test("does not exempt other authentication pages from bootstrap", () => { + expect(shouldEnforceBootstrapRegistration("/register")).toBe(true); + expect(shouldEnforceBootstrapRegistration("/login")).toBe(true); + }); +}); diff --git a/apps/web/tests/media-progress-position.test.ts b/apps/web/tests/media-progress-position.test.ts new file mode 100644 index 0000000..a986c23 --- /dev/null +++ b/apps/web/tests/media-progress-position.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { resolveMediaProgressPosition } from "../src/lib/media-progress-position"; +import { registerSabrVidstackControls } from "../src/lib/sabr-vidstack-bridge"; + +test("ignores player-owned transient MSE positions", () => { + const video = {} as HTMLVideoElement; + registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: () => {}, + isApplyingTransientMediaState: () => true, + }); + + expect(resolveMediaProgressPosition(video, 0)).toBeNull(); +}); + +test("preserves an explicit seek target during an MSE transition", () => { + const video = {} as HTMLVideoElement; + registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: () => {}, + isApplyingTransientMediaState: () => true, + }); + + expect(resolveMediaProgressPosition(video, 0, 125_000)).toBe(125_000); +}); + +test("reports stable media positions normally", () => { + expect(resolveMediaProgressPosition(null, 42_000)).toBe(42_000); +}); diff --git a/apps/web/tests/notification-toast-cursor.test.ts b/apps/web/tests/notification-toast-cursor.test.ts index d1d0f03..53fcb73 100644 --- a/apps/web/tests/notification-toast-cursor.test.ts +++ b/apps/web/tests/notification-toast-cursor.test.ts @@ -4,6 +4,7 @@ import { createNotificationToastCursor, findNewNotificationItems, parseNotificationToastCursor, + visibleNotificationToastItems, } from "../src/lib/notification-toast-cursor"; import type { NotificationItem } from "../src/types/notifications"; @@ -80,6 +81,13 @@ describe("notification toast cursor", () => { expect(advanceNotificationToastCursor(cursor, [notification("stale", 100)])).toEqual(cursor); }); + test("keeps popup notifications silent when the preference is disabled", () => { + const item = notification("muted", 500); + + expect(visibleNotificationToastItems([item], false, () => false)).toEqual([]); + expect(visibleNotificationToastItems([item], true, () => false)).toEqual([item]); + }); + test("rejects malformed persisted cursors", () => { expect(parseNotificationToastCursor(null)).toBeNull(); expect(parseNotificationToastCursor({ latestCreatedAt: "now", keysAtLatest: [] })).toBeNull(); diff --git a/apps/web/tests/progress-write-queue.test.ts b/apps/web/tests/progress-write-queue.test.ts new file mode 100644 index 0000000..ca59912 --- /dev/null +++ b/apps/web/tests/progress-write-queue.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { ProgressWriteQueue } from "../src/lib/progress-write-queue"; + +function deferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("orders writes for the same video and makes readers wait", async () => { + const queue = new ProgressWriteQueue(); + const first = deferred(); + const started = deferred(); + const order: string[] = []; + + const firstWrite = queue.enqueue("video-a", async () => { + order.push("first-start"); + started.resolve(); + await first.promise; + order.push("first-end"); + }); + const secondWrite = queue.enqueue("video-a", async () => { + order.push("second"); + }); + const settled = queue.settle("video-a").then(() => order.push("reader")); + + await started.promise; + expect(order).toEqual(["first-start"]); + first.resolve(); + await Promise.all([firstWrite, secondWrite, settled]); + expect(order).toEqual(["first-start", "first-end", "second", "reader"]); +}); + +test("a failed write does not block the next save", async () => { + const queue = new ProgressWriteQueue(); + const failed = queue.enqueue("video-a", () => Promise.reject(new Error("failed"))); + const next = queue.enqueue("video-a", () => Promise.resolve()); + + await expect(failed).rejects.toThrow("failed"); + await expect(next).resolves.toBeUndefined(); + await expect(queue.settle("video-a")).resolves.toBeUndefined(); +}); + +test("different videos do not wait for each other", async () => { + const queue = new ProgressWriteQueue(); + const first = deferred(); + const blocked = queue.enqueue("video-a", () => first.promise); + const independent = queue.enqueue("video-b", () => Promise.resolve()); + + await expect(independent).resolves.toBeUndefined(); + first.resolve(); + await blocked; +}); diff --git a/apps/web/tests/sabr-autoplay.test.ts b/apps/web/tests/sabr-autoplay.test.ts new file mode 100644 index 0000000..d238665 --- /dev/null +++ b/apps/web/tests/sabr-autoplay.test.ts @@ -0,0 +1,200 @@ +import { expect, test } from "bun:test"; +import { + guardAutoplay, + isAutoplayPolicyError, + SabrAutoplayAttempt, + SabrAutoplayDeadline, +} from "../src/lib/sabr-autoplay"; +import { registerSabrVidstackControls } from "../src/lib/sabr-vidstack-bridge"; + +test("stops automatic playback retries after a browser policy rejection", () => { + expect(isAutoplayPolicyError(new DOMException("Play is not allowed", "NotAllowedError"))).toBe( + true, + ); +}); + +test("allows transient playback failures to be retried", () => { + expect(isAutoplayPolicyError(new DOMException("Media is not ready", "InvalidStateError"))).toBe( + false, + ); +}); + +test("keeps one autoplay attempt while browser playback is pending", () => { + const attempt = new SabrAutoplayAttempt(); + + expect(attempt.begin()).toBe(true); + expect(attempt.begin()).toBe(false); + expect(attempt.isConfirmed).toBe(false); +}); + +test("expires a browser playback attempt that remains pending", () => { + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + + expect(attempt.expire()).toBe(true); + expect(attempt.isConfirmed).toBe(true); + expect(attempt.resolve()).toBe(false); + expect(attempt.begin()).toBe(false); + expect(attempt.expire()).toBe(false); +}); + +test("accepts playback that resolves before the deadline", () => { + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + + expect(attempt.resolve()).toBe(true); + expect(attempt.isConfirmed).toBe(true); +}); + +test("pauses a late playback event until user playback is allowed", () => { + let listener = () => {}; + let pauses = 0; + const target = { + addEventListener: (_type: "play", next: () => void) => { + listener = next; + }, + removeEventListener: (_type: "play", next: () => void) => { + if (listener === next) listener = () => {}; + }, + closest: () => null, + }; + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + attempt.expire(); + const unguard = guardAutoplay(target as unknown as HTMLVideoElement, attempt, () => { + pauses += 1; + }); + + listener(); + attempt.allow(); + listener(); + unguard(); + listener(); + + expect(pauses).toBe(1); +}); + +test("allows transient seek playback after autoplay expires", () => { + let listener = () => {}; + let pauses = 0; + let transient = true; + const target = { + addEventListener: (_type: "play", next: () => void) => { + listener = next; + }, + removeEventListener: () => {}, + closest: () => null, + }; + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + attempt.expire(); + const video = target as unknown as HTMLVideoElement; + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: () => {}, + isApplyingTransientMediaState: () => transient, + }); + guardAutoplay(video, attempt, () => { + pauses += 1; + }); + + listener(); + transient = false; + listener(); + unregister(); + + expect(pauses).toBe(1); +}); + +test("allows late playback only after a player surface click", () => { + let playListener = () => {}; + const rootListeners = new Map void>(); + let pauses = 0; + const root = { + addEventListener: (type: string, next: (event: Event) => void) => { + rootListeners.set(type, next); + }, + removeEventListener: () => {}, + }; + const target = { + addEventListener: (_type: "play", next: () => void) => { + playListener = next; + }, + removeEventListener: () => {}, + closest: () => root, + }; + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + attempt.expire(); + guardAutoplay(target as unknown as HTMLVideoElement, attempt, () => { + pauses += 1; + }); + + playListener(); + rootListeners.get("click")?.({ target: root } as unknown as Event); + playListener(); + + expect(pauses).toBe(1); +}); + +test("allows touch playback without a compatibility click", () => { + let playListener = () => {}; + const rootListeners = new Map void>(); + let pauses = 0; + const root = { + addEventListener: (type: string, next: (event: Event) => void) => { + rootListeners.set(type, next); + }, + removeEventListener: () => {}, + }; + const target = { + addEventListener: (_type: "play", next: () => void) => { + playListener = next; + }, + removeEventListener: () => {}, + closest: () => root, + }; + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + attempt.expire(); + guardAutoplay(target as unknown as HTMLVideoElement, attempt, () => { + pauses += 1; + }); + + playListener(); + rootListeners.get("pointerup")?.({ target: root } as unknown as Event); + playListener(); + + expect(pauses).toBe(1); +}); + +test("cancels an armed autoplay deadline", async () => { + let expirations = 0; + const deadline = new SabrAutoplayDeadline(() => { + expirations += 1; + }, 5); + + deadline.arm(); + deadline.clear(); + await Bun.sleep(10); + + expect(expirations).toBe(0); +}); + +test("stops autoplay after a browser policy rejection", () => { + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + + expect(attempt.reject(new DOMException("Play is not allowed", "NotAllowedError"))).toBe(false); + expect(attempt.isConfirmed).toBe(true); + expect(attempt.begin()).toBe(false); +}); + +test("retries autoplay after a transient playback failure", () => { + const attempt = new SabrAutoplayAttempt(); + attempt.begin(); + + expect(attempt.reject(new DOMException("Media is not ready", "InvalidStateError"))).toBe(true); + expect(attempt.begin()).toBe(true); +}); diff --git a/apps/web/tests/sabr-default-quality.test.ts b/apps/web/tests/sabr-default-quality.test.ts index caab0a3..60da949 100644 --- a/apps/web/tests/sabr-default-quality.test.ts +++ b/apps/web/tests/sabr-default-quality.test.ts @@ -23,6 +23,16 @@ test("respects the preferred sabr resolution above 720p", () => { expect(defaultSabrItag(options, "2160p")).toBe(401); }); +test("selects portrait video by canonical quality instead of encoded height", () => { + const portrait = [ + { ...option(399, 1920), label: "1080p", width: 1080 }, + { ...option(398, 1280), label: "720p", width: 720 }, + { ...option(397, 854), label: "480p", width: 480 }, + ]; + + expect(defaultSabrItag(portrait, "720p")).toBe(398); +}); + test("chooses automatic quality from display and network constraints", () => { expect(automaticSabrQuality(1080, 1)).toBe("1080p"); expect(automaticSabrQuality(1440, 2)).toBe("2160p"); diff --git a/apps/web/tests/sabr-quality-label.test.ts b/apps/web/tests/sabr-quality-label.test.ts new file mode 100644 index 0000000..6ce6a08 --- /dev/null +++ b/apps/web/tests/sabr-quality-label.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { sabrQualityOptions } from "../src/lib/sabr-source"; +import type { VideoStreamItem } from "../src/types/api"; +import type { VideoStream } from "../src/types/stream"; + +test("uses the canonical quality tier for non-standard frame heights", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "MediaSource"); + Object.defineProperty(globalThis, "MediaSource", { + configurable: true, + value: { isTypeSupported: () => true }, + }); + const video = { + itag: 137, + codec: "avc1.640028", + mimeType: "video/mp4", + resolution: "1080p", + width: 1920, + height: 960, + fps: 30, + bitrate: 4_000_000, + deliveryMethod: "sabr", + sabrSessionUrl: "/api/sabr/playback/video", + } as VideoStreamItem; + const stream = { id: "video", videoOnlyStreams: [video] } as VideoStream; + + try { + expect(sabrQualityOptions(stream)[0]).toMatchObject({ label: "1080p", height: 960 }); + } finally { + if (original) Object.defineProperty(globalThis, "MediaSource", original); + else delete (globalThis as { MediaSource?: unknown }).MediaSource; + } +}); diff --git a/apps/web/tests/sabr-quality-selection.test.ts b/apps/web/tests/sabr-quality-selection.test.ts index b1de1e5..808f330 100644 --- a/apps/web/tests/sabr-quality-selection.test.ts +++ b/apps/web/tests/sabr-quality-selection.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import type { CodecFamily } from "../src/lib/quality-utils"; import { - maxSabrCodecHeight, + maxSabrCodecLabel, sabrCodecOptions, sabrResolutionOptions, selectSabrCodec, @@ -47,7 +47,7 @@ test("switches sabr codec at the current resolution", () => { expect(selectSabrCodec(options, selected, "AV1")?.itag).toBe(398); expect(sabrCodecOptions(options)).toEqual(["H.264", "VP9", "AV1"]); - expect(maxSabrCodecHeight(options, "H.264")).toBe(1080); + expect(maxSabrCodecLabel(options, "H.264")).toBe("1080p"); }); test("falls back to the nearest lower resolution for a codec", () => { @@ -55,3 +55,19 @@ test("falls back to the nearest lower resolution for a codec", () => { expect(selectSabrCodec(options, selected, "H.264")?.itag).toBe(137); }); + +test("groups portrait streams by their canonical quality tier", () => { + const portrait = [ + { ...option(399, 1920, "AV1"), label: "1080p", width: 1080 }, + { ...option(398, 1280, "AV1"), label: "720p", width: 720 }, + { ...option(397, 854, "AV1"), label: "480p", width: 480 }, + { ...option(396, 480, "AV1"), label: "480p", width: 270 }, + ]; + + expect(sabrResolutionOptions(portrait, portrait[0]).map((item) => item.label)).toEqual([ + "1080p", + "720p", + "480p", + ]); + expect(maxSabrCodecLabel(portrait, "AV1")).toBe("1080p"); +}); diff --git a/apps/web/tests/sabr-vidstack-bridge.test.ts b/apps/web/tests/sabr-vidstack-bridge.test.ts index d407886..1e631d9 100644 --- a/apps/web/tests/sabr-vidstack-bridge.test.ts +++ b/apps/web/tests/sabr-vidstack-bridge.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { + consumeSabrSeekTarget, isSabrPlaybackEventTransient, registerSabrVidstackControls, requestSabrSeek, @@ -61,6 +62,64 @@ test("ignores technical pauses during SABR transitions", async () => { expect(pauses).toBe(0); }); +test("ignores technical pauses while Safari has hidden the page", async () => { + let pauses = 0; + const video = { autoplay: true, pause: () => {} } as HTMLVideoElement; + const previousDocument = globalThis.document; + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { visibilityState: "hidden" }, + }); + + try { + registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => { + pauses += 1; + }, + seek: () => {}, + }); + await requestSabrVidstackPlayback(video, false); + + expect(video.autoplay).toBe(true); + expect(pauses).toBe(0); + } finally { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: previousDocument, + }); + } +}); + +test("applies explicit pauses while Safari has hidden the page", async () => { + let pauses = 0; + const video = { autoplay: true, pause: () => {} } as HTMLVideoElement; + const previousDocument = globalThis.document; + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { visibilityState: "hidden" }, + }); + + try { + registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => { + pauses += 1; + }, + seek: () => {}, + }); + await requestSabrVidstackPlayback(video, false, true); + + expect(video.autoplay).toBe(false); + expect(pauses).toBe(1); + } finally { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: previousDocument, + }); + } +}); + test("applies user pauses during SABR transitions", async () => { let pauses = 0; const video = { autoplay: true, pause: () => {} } as HTMLVideoElement; @@ -92,6 +151,36 @@ test("sends only explicit SABR seek requests to registered MSE controls", () => expect(requestSabrSeek(video, 95)).toBe(true); expect(positions).toEqual([95]); + expect(consumeSabrSeekTarget(video)).toBe(95_000); + expect(consumeSabrSeekTarget(video)).toBeNull(); +}); + +test("keeps the latest explicit SABR seek target for progress persistence", () => { + const video = { autoplay: false, pause: () => {} } as HTMLVideoElement; + registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: () => {}, + }); + + requestSabrSeek(video, 95); + requestSabrSeek(video, 12.25); + + expect(consumeSabrSeekTarget(video)).toBe(12_250); +}); + +test("clears an explicit seek target when its controls are removed", () => { + const video = { autoplay: false, pause: () => {} } as HTMLVideoElement; + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: () => {}, + }); + + requestSabrSeek(video, 95); + unregister(); + + expect(consumeSabrSeekTarget(video)).toBeNull(); }); test("identifies only player-owned transient media events", () => { diff --git a/apps/web/tests/sabr-vidstack-provider.test.ts b/apps/web/tests/sabr-vidstack-provider.test.ts index e438c68..af21179 100644 --- a/apps/web/tests/sabr-vidstack-provider.test.ts +++ b/apps/web/tests/sabr-vidstack-provider.test.ts @@ -4,9 +4,12 @@ import { bindSabrVideoProvider } from "../src/lib/sabr-vidstack-provider"; function setupProvider(currentTime = 0, paused = true, ended = false) { const positions: number[] = []; + const events = new EventTarget(); const video = { + addEventListener: events.addEventListener.bind(events), autoplay: false, currentTime, + dispatchEvent: events.dispatchEvent.bind(events), ended, paused, pause: () => {}, @@ -47,6 +50,64 @@ test("ignores Vidstack's initial playback reset after a saved-position resume", } }); +test("keeps the initial reset guard through resume positioning", async () => { + const { positions, provider, unregister } = setupProvider(11.2, true); + + try { + provider.setCurrentTime(11.2); + provider.video.currentTime = 15.064; + provider.setCurrentTime(15.064); + await provider.play(); + provider.setCurrentTime(0); + + expect(positions).toEqual([11.2, 15.064]); + } finally { + unregister(); + } +}); + +test("keeps the initial reset guard after a technical pause", async () => { + const { positions, provider, unregister } = setupProvider(15.064, true); + + try { + await provider.play(); + await provider.pause(); + provider.setCurrentTime(0); + + expect(positions).toEqual([]); + } finally { + unregister(); + } +}); + +test("keeps the initial reset guard when Vidstack reloads its provider", async () => { + const { positions, provider, unregister } = setupProvider(15.064, true); + + try { + await provider.play(); + provider.video.currentTime = 0; + const replacement = bindSabrVideoProvider({ video: provider.video } as typeof provider); + replacement.setCurrentTime(0); + + expect(positions).toEqual([]); + } finally { + unregister(); + } +}); + +test("ignores a queued reset after resume positioning but before play", () => { + const { positions, provider, unregister } = setupProvider(15.064, true); + + try { + provider.video.dispatchEvent(new Event("seeked")); + provider.setCurrentTime(0); + + expect(positions).toEqual([]); + } finally { + unregister(); + } +}); + test("ignores the initial reset while native Safari playback still reports paused", () => { const { positions, provider, unregister } = setupProvider(486.792, true); diff --git a/apps/web/tests/video-visibility.test.ts b/apps/web/tests/video-visibility.test.ts new file mode 100644 index 0000000..e889f22 --- /dev/null +++ b/apps/web/tests/video-visibility.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { filterMembersOnlyContent, isMembersOnlyContentHidden } from "../src/lib/video-visibility"; + +const publicVideo = { id: "public", requiresMembership: false }; +const membersOnlyVideo = { id: "members", requiresMembership: true }; + +describe("members-only content visibility", () => { + test("keeps every video when the setting is disabled", () => { + expect(filterMembersOnlyContent([publicVideo, membersOnlyVideo], false)).toEqual([ + publicVideo, + membersOnlyVideo, + ]); + }); + + test("removes members-only videos when the setting is enabled", () => { + expect(filterMembersOnlyContent([publicVideo, membersOnlyVideo], true)).toEqual([publicVideo]); + expect(isMembersOnlyContentHidden(membersOnlyVideo, true)).toBe(true); + expect(isMembersOnlyContentHidden(publicVideo, true)).toBe(false); + }); +}); diff --git a/apps/web/tests/watch-resume.test.ts b/apps/web/tests/watch-resume.test.ts index 5ed49ee..93e4130 100644 --- a/apps/web/tests/watch-resume.test.ts +++ b/apps/web/tests/watch-resume.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "bun:test"; -import { resolveWatchStartTime } from "../src/lib/watch-resume"; +import { resolveWatchStartTime, shouldWaitForWatchProgress } from "../src/lib/watch-resume"; + +test("waits while a cached progress value is refreshed on remount", () => { + expect(shouldWaitForWatchProgress(true, false, true)).toBe(true); + expect(shouldWaitForWatchProgress(true, false, false)).toBe(false); + expect(shouldWaitForWatchProgress(false, false, true)).toBe(false); +}); test("waits for authenticated progress before choosing the initial position", () => { expect( diff --git a/apps/web/tests/youtube-session-errors.test.ts b/apps/web/tests/youtube-session-errors.test.ts new file mode 100644 index 0000000..d79536e --- /dev/null +++ b/apps/web/tests/youtube-session-errors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { + sanitizeYoutubeSessionReturnTo, + youtubeSessionReturnToForShorts, +} from "../src/lib/youtube-session-route"; + +if (!("localStorage" in globalThis)) { + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }, + }); +} + +const { ApiError } = await import("../src/lib/api"); +const { isYoutubeSessionActionError } = await import("../src/lib/api-youtube-session"); + +describe("YouTube session errors", () => { + test("recognizes missing and expired YouTube sessions", () => { + expect( + isYoutubeSessionActionError(new ApiError("Connect YouTube", 400, "youtube_session_required")), + ).toBe(true); + expect( + isYoutubeSessionActionError( + new ApiError("Reconnect YouTube", 400, "youtube_session_needs_reconnect"), + ), + ).toBe(true); + }); + + test("does not turn playback failures into account actions", () => { + expect(isYoutubeSessionActionError(new ApiError("SABR failed", 422, "sabr_failed"))).toBe( + false, + ); + expect(isYoutubeSessionActionError(new TypeError("Network error"))).toBe(false); + }); +}); + +describe("YouTube session return routes", () => { + test("keeps a Shorts target through YouTube connection", () => { + const returnTo = youtubeSessionReturnToForShorts("video-id"); + expect(returnTo).toBe("/shorts?v=video-id"); + expect(sanitizeYoutubeSessionReturnTo(returnTo)).toBe(returnTo); + }); + + test("rejects unrelated and external routes", () => { + expect(sanitizeYoutubeSessionReturnTo("/settings?v=video-id")).toBeUndefined(); + expect(sanitizeYoutubeSessionReturnTo("https://example.com/watch?v=video-id")).toBeUndefined(); + }); +}); diff --git a/bun.lock b/bun.lock index 30439fd..c06b611 100644 --- a/bun.lock +++ b/bun.lock @@ -12,11 +12,11 @@ }, "apps/web": { "name": "@typetype/web", - "version": "1.4.0", + "version": "1.6.0", "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.44", + "@typetype/mse": "0.1.49", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", @@ -312,7 +312,7 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typetype/mse": ["@typetype/mse@0.1.44", "", {}, "sha512-a+jY23vtZi24mTb0eJQeRBAKNb+hL/KOiC1Nnw8VjM9ZFBhJlwYECYTTH6RzvUlqWWgA2k5hhk1dMTChyEsPGw=="], + "@typetype/mse": ["@typetype/mse@0.1.49", "", {}, "sha512-YdM9VuRyppI1SsW38geTR+NR0gCKoCNNSn2OTSTFSJuxkpCypGTa0lVd/kPduNIwcQEhrG48wi+XaLlbsPkPnw=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], diff --git a/package.json b/package.json index 7055b25..dffeb68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/frontend", - "version": "1.5.1", + "version": "1.6.0", "devDependencies": { "@biomejs/biome": "^2.5.7", "knip": "^6.32.0",
{message}
+ Notifications +