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}

- + {youtubeSessionReturnTo ? ( + + + Connect with YouTube + + ) : ( + + )}
+

+ 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",