diff --git a/src-tauri/src/mpv.rs b/src-tauri/src/mpv.rs index 160645a90..521fb5425 100644 --- a/src-tauri/src/mpv.rs +++ b/src-tauri/src/mpv.rs @@ -303,6 +303,7 @@ const OBSERVED_PROPS: &[(&str, u64, PropertyKind)] = &[ // so upscaler presets need these to know how much headroom the window has. ("width", 22, PropertyKind::Int64), ("height", 23, PropertyKind::Int64), + ("sub-fps", 24, PropertyKind::Double), ]; #[derive(Clone, Copy)] diff --git a/src/assets/ui-icons/add-to.svg b/src/assets/ui-icons/add-to.svg index 6e2f4204d..3a1159dd3 100644 --- a/src/assets/ui-icons/add-to.svg +++ b/src/assets/ui-icons/add-to.svg @@ -1,6 +1,7 @@ - - - - + + + + + diff --git a/src/components/context-menu.tsx b/src/components/context-menu.tsx index 65cc333ac..38b6f1e43 100644 --- a/src/components/context-menu.tsx +++ b/src/components/context-menu.tsx @@ -19,7 +19,6 @@ import { RotateCcw, Share2, UserPlus, - Wallpaper, X, ListPlus, } from "lucide-react"; @@ -51,7 +50,6 @@ import { import { useContextWatchedState } from "@/lib/context-watched-state"; import { useTmdbImdbId } from "@/lib/providers/tmdb"; import { useIsFavorite } from "@/lib/media-favorites"; -import { clearTitleBackdrop, getTitleBackdrop, setTitleBackdrop } from "@/lib/title-backdrop"; import { MyListSubmenu } from "./context-menu/my-list-submenu"; import { useSettings } from "@/lib/settings"; import { @@ -86,18 +84,12 @@ import { anyProfileSharesStremioWith, useProfiles } from "@/lib/profiles"; import { currentAuthor, subscribeAuthor } from "@/lib/theme-auth"; import { MenuSurface, menuItemClass, useMenuExecution } from "./context-menu/menu-surface"; import { ActionItems, QuickActions, MenuIcon } from "./context-menu/action-items"; -import { findAction, type ActionSource } from "@/lib/context-actions"; -import { - contextNavigationFooter, - omitContextActions, - partitionContextActions, -} from "@/lib/context-quick-actions"; +import type { ActionSource } from "@/lib/context-actions"; +import { contextNavigationFooter, partitionContextActions } from "@/lib/context-quick-actions"; import { contentActions } from "./context-menu/content-actions"; -import { ContextImageViewer } from "./context-image-viewer"; -import type { ContextImage } from "@/lib/context-image"; import { parseExternalLink } from "@/lib/social/external-link-policy"; import { usePageContextTarget } from "@/chrome/context-page-navigation"; -import { isPageContextBackground } from "@/lib/context-page-background"; +import { isBackgroundMenuTarget, isPageContextBackground } from "@/lib/context-page-background"; import { useManualDownload } from "./context-menu/use-manual-download"; import { LastPlaybackCommand } from "./context-menu/last-playback-command"; import { MenuBrandFooter } from "./context-menu/menu-brand-footer"; @@ -155,7 +147,6 @@ export function ContextMenu() { openService, openAddonDetail, openSettings, - meta: currentMeta, topKind, topPath, player, @@ -173,6 +164,8 @@ export function ContextMenu() { () => currentAuthor()?.id ?? null, () => null, ); + // A docked live player can coexist with page space. The semantic target below + // still excludes the player surface itself from navigation and last playback. const pageContext = usePageContextTarget({ includePlayer: true }); const playerMenu = useRef({ session: state?.session, src: playerActions?.src }); if (playerMenu.current.session !== state?.session) @@ -187,15 +180,8 @@ export function ContextMenu() { const { beginManualDownload, manualDownloadDialog } = useManualDownload({ actorKey: `${activeProfile?.id ?? ""}:${authorId ?? ""}:${stremioUser?._id ?? ""}`, }); - const [viewedImage, setViewedImage] = useState<{ - image: ContextImage; - origin: HTMLElement | null; - } | null>(null); - const viewImage = (image: ContextImage) => - setViewedImage({ image, origin: state?.origin ?? null }); useEffect(() => { closeCurrent(false); - setViewedImage(null); }, [topPath, activeProfile?.id, authorId, stremioUser?._id, closeCurrent]); const { openAt } = useContextMenu(); useEffect(() => { @@ -389,15 +375,11 @@ export function ContextMenu() { open(e, pageContext); return; } - const backdropEl = - e.target instanceof HTMLElement ? e.target.closest("[data-title-backdrop]") : null; - if (backdropEl && currentMeta) { - const backdropUrl = backdropEl.getAttribute("data-title-backdrop"); - if (backdropUrl) { - e.preventDefault(); - open(e, { kind: "backdrop", metaId: currentMeta.id, url: backdropUrl }); - return; - } + if (el?.closest("[data-title-backdrop]")) { + // Artwork without context commands is still an occupied surface. + e.preventDefault(); + closeCurrent(false); + return; } if (el?.closest("[data-harbor-player]") && player?.meta) { const source = currentPlayerActions()?.src; @@ -429,22 +411,9 @@ export function ContextMenu() { }; document.addEventListener("contextmenu", handler); return () => document.removeEventListener("contextmenu", handler); - }, [open, closeCurrent, currentMeta, player?.meta, topKind, activeAddon, pageContext, inSession]); + }, [open, closeCurrent, player?.meta, topKind, activeAddon, pageContext, inSession]); - const viewer = viewedImage ? ( - setViewedImage(null)} - /> - ) : null; - if (!state) - return ( - <> - {viewer} - {manualDownloadDialog} - - ); + if (!state) return manualDownloadDialog; const subtitleDetails = state.target.kind === "subtitle" ? (state.target.details ?? null) : null; const target = state.target; @@ -454,19 +423,12 @@ export function ContextMenu() { ? { src: target.meta.poster, publicUrl: target.meta.poster, label: target.meta.name } : undefined); const isPlayer = target.kind === "meta" && !!target.player; - const allowQuick = - target.navigation !== false && - target.kind !== "edit" && - target.kind !== "subtitle" && - target.kind !== "backdrop" && - !(target.kind === "meta" && target.player); + const isBackground = isBackgroundMenuTarget(target); const navigation = () => - (allowQuick || isPlayer) && pageContext?.kind === "actions" && pageContext.isValid?.() !== false + isBackground && pageContext?.kind === "actions" && pageContext.isValid?.() !== false ? pageContext.actions() : []; - const playerSources = isPlayer - ? playerContextSources(readMenuPlayer, () => findAction(navigation(), "page:go:settings")) - : null; + const playerSources = isPlayer ? playerContextSources(readMenuPlayer) : null; const availableActions = (source?: ActionSource) => source?.isValid?.() === false ? [] : (source?.actions() ?? []); const makeParts = () => { @@ -476,43 +438,32 @@ export function ContextMenu() { : target.kind === "meta" ? [...availableActions(target.primary), ...availableActions(target.extra)] : []; - const content = contentActions( - { ...target, image: artwork }, - viewImage, - target.kind === "content", - ); - return allowQuick - ? partitionContextActions(entity, content, navigation()) - : { quick: [], entity, content }; + const content = contentActions({ ...target, image: artwork }); + return partitionContextActions(entity, content, navigation(), target); }; const parts = makeParts(); const filteredSource = (source: ActionSource): ActionSource => ({ ...source, - actions: () => - omitContextActions( - source.actions(), - new Set([ - ...makeParts().quick.map((action) => action.id), - ...contextNavigationFooter(navigation(), makeParts().quick).map((action) => action.id), - ]), - ), + // Page commands are composed once, only for a semantic background target. + // Item-specific copies stay in their original source as ordinary rows. + actions: () => source.actions().filter((action) => !action.id.startsWith("page:")), }); - const quickSource: ActionSource = playerSources?.quick ?? { + const quickSource: ActionSource = { actions: () => makeParts().quick, isValid: target.isValid, }; - const menuWidth = subtitleDetails - ? SUBTITLE_MENU_WIDTH - : allowQuick || isPlayer - ? 264 - : MENU_WIDTH; + const menuWidth = subtitleDetails ? SUBTITLE_MENU_WIDTH : isBackground ? 264 : MENU_WIDTH; const items: React.ReactNode[] = []; let membershipMenu: { index: number; props: React.ComponentProps; } | null = null; - if (state.target.kind === "meta" && state.target.primary) + if ( + state.target.kind === "meta" && + state.target.primary && + filteredSource(state.target.primary).actions().length + ) items.push( , , @@ -734,10 +685,9 @@ export function ContextMenu() { }, }; if (organizedMedia && !playerActions && (!episodeScope || targetEpisode)) { - items.push(); const summary = watchedState.summary; const status = watchedState.loading - ? t("Checking watched status…") + ? null : summary?.status === "unknown" ? t("Watched status unavailable") : summary?.status === "partial" @@ -746,6 +696,20 @@ export function ContextMenu() { total: summary.total, }) : null; + const choices = watchedState.loading + ? [] + : summary?.status === "watched" + ? [false] + : summary?.status === "unwatched" + ? [true] + : [true, false]; + if ( + status || + summary?.unavailable.length || + choices.length || + watchedError?.session === state.session + ) + items.push(); if (status) items.push(

@@ -760,13 +724,6 @@ export function ContextMenu() { })}

, ); - const choices = watchedState.loading - ? [] - : summary?.status === "watched" - ? [false] - : summary?.status === "unwatched" - ? [true] - : [true, false]; choices.forEach((on, index) => items.push( , ); } - items.push( - } - label={t("Share as link")} - onClick={() => shareLink(meta.type, meta.id)} - />, - ); + if (meta.type !== "movie" && meta.type !== "series") + items.push( + } + label={t("Share as link")} + onClick={() => shareLink(meta.type, meta.id)} + />, + ); if (organizedMedia && inSession && !playerActions) { items.push( , ); } - } else if (state.target.kind === "backdrop") { - const { metaId, url } = state.target; - const isCurrent = getTitleBackdrop(metaId) === url; - items.push( - } - label={t("Set as a backdrop")} - onClick={() => { - setTitleBackdrop(metaId, url); - close(); - }} - accent={isCurrent} - />, - ); - if (getTitleBackdrop(metaId)) { - items.push( - } - label={t("Reset to original")} - onClick={() => { - clearTitleBackdrop(metaId); - close(); - }} - />, - ); - } } else if (state.target.kind === "subtitle") { const { download, details } = state.target; if (details) { @@ -1313,15 +1243,16 @@ export function ContextMenu() { , ); if (parts.content.some((action) => action.id === "selection:copy")) { + const separator = items.length ? [] : []; items.unshift( contentActions({ selection: target.selection }, viewImage, true) }} + source={{ actions: () => contentActions({ selection: target.selection }) }} onClose={close} />, - , + ...separator, ); - if (membershipMenu) membershipMenu.index += 2; + if (membershipMenu) membershipMenu.index += 1 + separator.length; } const tools = parts.content.filter((action) => action.id !== "selection:copy"); if (tools.length) @@ -1334,24 +1265,22 @@ export function ContextMenu() { onClose={close} />, ); - // The player reuses navigation policy for Settings only, never browsing rows. const navigationFooter = () => - isPlayer ? [] : contextNavigationFooter(navigation(), makeParts().quick); + isBackground ? contextNavigationFooter(navigation(), makeParts().quick) : []; const footer = navigationFooter(); if (items.length === 0 && parts.quick.length === 0 && footer.length === 0) return ( <> - {viewer} + {manualDownloadDialog} ); return ( <> - {viewer} {manualDownloadDialog} - - {(last) => ( + + {(_last, lastRow) => ( - {last && ( - <> - [last] }} onClose={close} /> - {(items.length > 0 || footer.length > 0) && } - - )} + {lastRow} {membershipMenu ? ( <> {items.slice(0, membershipMenu.index)} @@ -1411,6 +1335,24 @@ export function ContextMenu() { ); } +/** A consumed image/empty entity must not keep hover previews or validation + * listeners suspended just because it has no menu surface to finish closing. */ +function EmptyMenu({ + session, + close, + completeClose, +}: { + session: number; + close: (restoreFocus: boolean, expectedSession: number) => void; + completeClose: (expectedSession: number) => void; +}) { + useEffect(() => { + close(false, session); + completeClose(session); + }, [session, close, completeClose]); + return null; +} + function SubtitleDetailsCard({ details, onBack, diff --git a/src/components/context-menu/action-command.tsx b/src/components/context-menu/action-command.tsx index 9e527494c..9b895bab5 100644 --- a/src/components/context-menu/action-command.tsx +++ b/src/components/context-menu/action-command.tsx @@ -97,7 +97,7 @@ export function ActionCommand({ > { if (!(await copyText(text))) throw new Error(t("Could not copy to the clipboard.")); } -export function contentActions( - content: ContentContext, - showImage: (image: ContextImage) => void, - standalone: boolean, -): ContextAction[] { +export function contentActions(content: ContentContext): ContextAction[] { const actions: ContextAction[] = []; if (content.selection) actions.push({ @@ -33,9 +22,15 @@ export function contentActions( group: "selection", }); const parsed = content.link && parseExternalLink(content.link); - const imageLink = content.image && publicContextImageUrl(content.image); const linkIsImage = - !!imageLink && parsed && parsed.ok && new URL(content.link!).href === imageLink; + parsed && + parsed.ok && + content.image && + [content.image.src, content.image.originalSrc, content.image.publicUrl].some((src) => { + if (!src) return false; + const imageLink = parseExternalLink(src); + return imageLink.ok && imageLink.link.href === parsed.link.href; + }); if (parsed && parsed.ok && !linkIsImage) { actions.push({ id: "link:open", @@ -55,63 +50,5 @@ export function contentActions( group: "link", }); } - if (content.image) { - const image = content.image; - const children: ContextAction[] = [ - { - id: "image:view", - label: t("View image"), - icon: , - run: () => showImage(image), - restoreFocus: false, - }, - { - id: "image:save", - label: t("Save image…"), - icon: , - run: async () => { - await saveContextImage(image); - }, - }, - ...(canCopyContextImage() - ? [ - { - id: "image:copy", - label: t("Copy image"), - icon: , - run: () => copyContextImage(image), - }, - ] - : []), - ]; - const url = publicContextImageUrl(image); - if (url) - children.push( - { - id: "image:copy-link", - label: t("Copy image link"), - group: "link", - icon: , - run: () => copyContextText(url), - }, - { - id: "image:open-link", - label: t("Open image link"), - group: "link", - icon: , - run: () => openLinkOut(url), - restoreFocus: false, - }, - ); - if (standalone) actions.push(...children); - else - actions.push({ - id: "image", - label: t("Image"), - group: "content", - icon: , - children, - }); - } return actions; } diff --git a/src/components/context-menu/last-playback-command.tsx b/src/components/context-menu/last-playback-command.tsx index 9a4ac027e..0d4bd32b6 100644 --- a/src/components/context-menu/last-playback-command.tsx +++ b/src/components/context-menu/last-playback-command.tsx @@ -32,11 +32,11 @@ function PlaybackArtwork({ src }: { src?: string }) { type LastPlaybackCommandProps = { onClose: (restoreFocus?: boolean) => void; enabled?: boolean; - children?: (action: ContextAction | null) => ReactNode; + children?: (action: ContextAction | null, row: ReactNode | null) => ReactNode; }; export function LastPlaybackCommand({ enabled = true, ...props }: LastPlaybackCommandProps) { - return enabled ? : props.children?.(null); + return enabled ? : props.children?.(null, null); } function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandProps) { @@ -54,14 +54,30 @@ function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandP } : null, ); - if (!target) return children?.(null) ?? null; + if (!target) { + const action: ContextAction = { + id: "playback:last:unavailable", + label: t("Resume last watched"), + icon: , + disabled: true, + }; + const row = ( + [action] }} onClose={onClose} /> + ); + return children ? children(action, row) : row; + } const presentation = lastPlaybackPresentation(target, settings, summary?.status === "watched"); - const tooltip = [presentation.title ?? t("Local video"), presentation.episode] + const tooltip = [ + target.src.meta.id.startsWith("playback-private:") + ? t("Direct stream") + : (presentation.title ?? t("Local video")), + presentation.episode, + ] .filter(Boolean) .join(" · "); const action: ContextAction = { id: `playback:last:${target.id}`, - label: t("Continue last watched"), + label: t("Resume last watched"), icon: , reason: tooltip, tooltipIntent: "deliberate", @@ -83,10 +99,8 @@ function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandP await continuation.continuePlayback(target, { restart: target.completed }); }, }; - if (children) return children(action); - return ( - <> - [action] }} onClose={onClose} /> - + const row = ( + [action] }} onClose={onClose} /> ); + return children ? children(action, row) : row; } diff --git a/src/components/context-menu/menu-brand-footer.css b/src/components/context-menu/menu-brand-footer.css index b8742c521..98e028831 100644 --- a/src/components/context-menu/menu-brand-footer.css +++ b/src/components/context-menu/menu-brand-footer.css @@ -6,7 +6,7 @@ gap: 3px; min-height: 16px; padding: 3px 8px 1px; - color: var(--color-ink); + color: #eba018; opacity: 0.68; pointer-events: none; user-select: none; @@ -25,11 +25,14 @@ object-fit: contain; } -/* Follow the actual chrome's monochrome mark; img assets retain their colors. */ -html:is([data-theme-layout="dracula"], [data-theme-layout="rail"]) .context-menu-brand-footer { - color: var(--color-accent); +.context-menu-brand-filter { + position: absolute; + width: 0; + height: 0; + overflow: hidden; } -html[data-theme-layout="stremio"] .context-menu-brand-footer { - color: rgb(255 255 255 / 90%); +/* Keep the menu's mark monochrome even when the kid theme colors its sails. */ +.context-menu-brand-footer > svg.context-menu-brand-mark path { + fill: currentColor; } diff --git a/src/components/context-menu/menu-brand-footer.tsx b/src/components/context-menu/menu-brand-footer.tsx index ccef6e23e..c2cc31ee3 100644 --- a/src/components/context-menu/menu-brand-footer.tsx +++ b/src/components/context-menu/menu-brand-footer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { HarborMark } from "@/components/icons/harbor-mark"; import { useHarborLogo } from "@/lib/harbor-logo"; import "./menu-brand-footer.css"; @@ -6,18 +6,30 @@ import "./menu-brand-footer.css"; /** Decorative identity for the root context menu, never a menu item. */ export function MenuBrandFooter() { const { mark, wordmark } = useHarborLogo(); + const tintId = useId(); const [failedMark, setFailedMark] = useState(null); const [failedWordmark, setFailedWordmark] = useState(null); return (
{mark && mark !== failedMark ? ( - setFailedMark(mark)} - /> + <> + + + + + + + + + setFailedMark(mark)} + /> + ) : ( )} diff --git a/src/components/context-menu/player-actions.tsx b/src/components/context-menu/player-actions.tsx index 15d884c26..1a035f976 100644 --- a/src/components/context-menu/player-actions.tsx +++ b/src/components/context-menu/player-actions.tsx @@ -1,12 +1,4 @@ -import { - AudioLines, - Download, - ExternalLink, - Link2, - Magnet, - Maximize, - Settings, -} from "lucide-react"; +import { AudioLines, Download, ExternalLink, Link2, Magnet, Maximize } from "lucide-react"; import type { ActionSource, ContextAction } from "@/lib/context-actions"; import type { PlayerActions } from "@/lib/player-actions"; import { t } from "@/lib/i18n"; @@ -14,37 +6,11 @@ import { openLinkOut } from "@/lib/social/link-out"; import { copyContextText } from "./content-actions"; /** Reads the active session again at invocation; source changes never reuse an old URL. */ -export function playerContextSources( - readCurrent: () => PlayerActions | null, - readSettings: () => ContextAction | undefined, -): { quick: ActionSource; rows: ActionSource } { +export function playerContextSources(readCurrent: () => PlayerActions | null): { + rows: ActionSource; +} { const isValid = () => readCurrent() != null; return { - quick: { - isValid, - actions: () => { - const player = readCurrent(); - if (!player) return []; - const actions: ContextAction[] = []; - if (player.magnetUrl) - actions.push({ - id: "player:copy-magnet", - label: t("Copy magnet"), - icon: , - run: () => copyContextText(player.magnetUrl!), - }); - const settings = readSettings(); - if (settings) - actions.push({ - ...settings, - id: "player:settings", - label: t("Settings"), - icon: settings.icon ?? , - restoreFocus: false, - }); - return actions; - }, - }, rows: { isValid, actions: () => { @@ -106,6 +72,14 @@ export function playerContextSources( group: "links", }, ); + if (player.magnetUrl) + actions.push({ + id: "player:copy-magnet", + label: t("Copy magnet"), + icon: , + run: () => copyContextText(player.magnetUrl!), + group: "links", + }); return actions; }, }, diff --git a/src/components/continue-card.tsx b/src/components/continue-card.tsx index b1630cbb3..95d83fc7b 100644 --- a/src/components/continue-card.tsx +++ b/src/components/continue-card.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Check, X, Play as ContextPlay, ListVideo } from "lucide-react"; +import { Check, X, ListVideo } from "lucide-react"; import { Play } from "@/components/icons/play-filled"; import simklLogo from "@/assets/simkl.png"; import traktLogo from "@/assets/trakt.svg"; @@ -693,7 +693,6 @@ export const ContinueCard = memo(function ContinueCard({ }, onError: reportActionError, icons: { - play: , sources: , dismiss: , }, @@ -724,7 +723,6 @@ export const ContinueCard = memo(function ContinueCard({ } : (ep ?? undefined), primary: commands.primary, - extra: commands.extra, }); return ( diff --git a/src/components/music/music-item-menu.tsx b/src/components/music/music-item-menu.tsx index 3f0506590..9f41cda79 100644 --- a/src/components/music/music-item-menu.tsx +++ b/src/components/music/music-item-menu.tsx @@ -1,5 +1,5 @@ import { useRef, useState, type MouseEvent } from "react"; -import { ChevronRight, ImageDown, Play, UserRound } from "@/components/icons/music-icons"; +import { ChevronRight, Play, UserRound } from "@/components/icons/music-icons"; import { coverCardTitle } from "@/components/music/music-cover-card"; import { useMusicNavigate } from "@/components/music/music-navigate"; import { useMusicPlaylistPicker } from "@/components/music/music-playlist-picker"; @@ -10,7 +10,6 @@ import { } from "@/components/music/music-track-menu"; import { useT } from "@/lib/i18n"; import { enqueueMusic, toggleMusicLiked } from "@/lib/music/player"; -import { saveArtwork } from "@/lib/music/artwork-save"; import { pinMusicPlaylist, unpinMusicPlaylist, @@ -71,19 +70,6 @@ export function musicItemMenuItems({ icon: , run: onGoToArtist, }); - if (item.artwork) { - const art = Array.isArray(item.artwork) ? item.artwork[0] : item.artwork; - const name = coverCardTitle(item); - const by = "artist" in item ? (item.artist ?? "") : ""; - items.push({ - id: "artwork", - label: t("music.artwork.save"), - icon: , - run: async () => { - await saveArtwork(art, name, by); - }, - }); - } return items; } @@ -128,8 +114,7 @@ export function useMusicItemMenu(handlers: Handlers) { const track = item?.kind === "track" ? item : null; // Only Harbor's own playlists have a playlist page to pin; catalog playlists // live in whichever service they came from. - const localPlaylist = - item?.kind === "playlist" && item.connectorId === "local" ? item : null; + const localPlaylist = item?.kind === "playlist" && item.connectorId === "local" ? item : null; const pinned = useMusicPlaylistPinned(localPlaylist?.id ?? ""); const trackItems = useMusicTrackMenuItems(track, { onPlay: handlers.onPlay && track ? () => handlers.onPlay?.(track, index) : undefined, @@ -169,9 +154,10 @@ export function useMusicItemMenu(handlers: Handlers) { const open = (item: MusicCatalogItem, index: number, event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - const target = event.target instanceof Element - ? event.target.closest("button, [href], [tabindex]:not([tabindex='-1'])") - : null; + const target = + event.target instanceof Element + ? event.target.closest("button, [href], [tabindex]:not([tabindex='-1'])") + : null; anchor.current = target && event.currentTarget.contains(target) ? target : event.currentTarget; setTarget({ item, @@ -187,8 +173,10 @@ export function useMusicItemMenu(handlers: Handlers) { return { open, close, - openFor: (item: MusicCatalogItem, index = 0) => (event: MouseEvent) => - open(item, index, event), + openFor: + (item: MusicCatalogItem, index = 0) => + (event: MouseEvent) => + open(item, index, event), isOpen: target !== null, menu: ( , - run: async () => { - await saveArtwork(track.artwork, track.album || track.title, track.artist); - }, - }); - } if (onAddToQueue) { items.push({ id: "queue", diff --git a/src/hooks/use-last-playback-continuation.ts b/src/hooks/use-last-playback-continuation.ts index 045f7323e..b1f0f98c9 100644 --- a/src/hooks/use-last-playback-continuation.ts +++ b/src/hooks/use-last-playback-continuation.ts @@ -4,6 +4,7 @@ import { useProfiles } from "@/lib/profiles"; import { useView, type PlayerSrc } from "@/lib/view"; import { capturePlaybackActor, + getPlaybackRevision, isPlaybackActorCurrent, readLastActualPlayback, subscribePlayback, @@ -12,12 +13,6 @@ import { import { currentPlayerActions } from "@/lib/player-actions"; import { continueActualPlayback } from "@/lib/player/continue-playback"; -let version = 0; -subscribePlayback(() => { - version += 1; -}); -const snapshotVersion = () => version; - async function prepareServer(src: PlayerSrc, positionMs: number): Promise { const context = src.homeServer; if (!context) throw new Error("This is not home-server playback."); @@ -50,10 +45,17 @@ export function useLastPlaybackContinuation() { const view = useView(); const { activeProfile } = useProfiles(); const { user } = useAuth(); - const revision = useSyncExternalStore(subscribePlayback, snapshotVersion, snapshotVersion); - const target = useMemo(() => readLastActualPlayback(), [revision, activeProfile?.id, user?._id]); + const revision = useSyncExternalStore( + subscribePlayback, + getPlaybackRevision, + getPlaybackRevision, + ); const currentActor = capturePlaybackActor(); const actorKey = JSON.stringify(currentActor); + const target = useMemo( + () => readLastActualPlayback(), + [revision, activeProfile?.id, user?._id, actorKey], + ); const operation = useRef<{ actorKey: string; token: object | null; mounted: boolean }>({ actorKey, token: null, diff --git a/src/lib/addon-configuration-key.ts b/src/lib/addon-configuration-key.ts index 27d825677..423ad8407 100644 --- a/src/lib/addon-configuration-key.ts +++ b/src/lib/addon-configuration-key.ts @@ -1,8 +1,40 @@ import { sha256 } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; +import type { AddonOrigin } from "./cinemeta"; export function addonConfigurationKey(base: string): string | undefined { if (!/^https?:\/\//i.test(base)) return undefined; const normalized = base.replace(/\/manifest\.json$/, ""); return bytesToHex(sha256(new TextEncoder().encode(normalized))); } + +export function persistableAddonOrigin(origin: unknown): AddonOrigin | undefined { + if (!origin || typeof origin !== "object") return undefined; + const value = origin as { + id?: unknown; + name?: unknown; + logo?: unknown; + base?: unknown; + configurationKey?: unknown; + }; + if (typeof value.id !== "string" || !value.id) return undefined; + // Plugin catalogue identifiers pin saved rows to their provider. Network transport URLs can + // contain credentials; resolve those from the installed addon configuration instead. + const base = + typeof value.base === "string" && + /^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value.base) + ? value.base + : undefined; + const configurationKey = + (typeof value.base === "string" ? addonConfigurationKey(value.base) : undefined) ?? + (typeof value.configurationKey === "string" && /^[a-f0-9]{64}$/.test(value.configurationKey) + ? value.configurationKey + : undefined); + return { + id: value.id, + name: typeof value.name === "string" && value.name ? value.name : value.id, + logo: typeof value.logo === "string" ? value.logo : undefined, + ...(base ? { base } : {}), + ...(configurationKey ? { configurationKey } : {}), + }; +} diff --git a/src/lib/cinemeta.ts b/src/lib/cinemeta.ts index 41a291b86..a3f9981fb 100644 --- a/src/lib/cinemeta.ts +++ b/src/lib/cinemeta.ts @@ -1,6 +1,6 @@ import { withMetaCache } from "@/lib/cinemeta-cache"; import { safeFetch as fetch } from "@/lib/safe-fetch"; -import { addonConfigurationKey } from "@/lib/addon-configuration-key"; +export { persistableAddonOrigin } from "./addon-configuration-key"; const CINEMETA = "https://v3-cinemeta.strem.io"; @@ -87,37 +87,6 @@ export type Meta = { }>; }; -export function persistableAddonOrigin(origin: unknown): AddonOrigin | undefined { - if (!origin || typeof origin !== "object") return undefined; - const value = origin as { - id?: unknown; - name?: unknown; - logo?: unknown; - base?: unknown; - configurationKey?: unknown; - }; - if (typeof value.id !== "string" || !value.id) return undefined; - // Plugin catalogue identifiers pin saved rows to their provider. Network transport URLs can - // contain credentials; resolve those from the installed addon configuration instead. - const base = - typeof value.base === "string" && - /^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value.base) - ? value.base - : undefined; - const configurationKey = - (typeof value.base === "string" ? addonConfigurationKey(value.base) : undefined) ?? - (typeof value.configurationKey === "string" && /^[a-f0-9]{64}$/.test(value.configurationKey) - ? value.configurationKey - : undefined); - return { - id: value.id, - name: typeof value.name === "string" && value.name ? value.name : value.id, - logo: typeof value.logo === "string" ? value.logo : undefined, - ...(base ? { base } : {}), - ...(configurationKey ? { configurationKey } : {}), - }; -} - export function hasEmbeddedStreams(videos: Meta["videos"]): boolean { return videos?.some((v) => Array.isArray(v.streams) && v.streams.length > 0) === true; } diff --git a/src/lib/context-actions.ts b/src/lib/context-actions.ts index d63dde782..6eada7c52 100644 --- a/src/lib/context-actions.ts +++ b/src/lib/context-actions.ts @@ -28,7 +28,6 @@ export type ContextAction = { checked?: boolean; dismiss?: "on-success" | "keep-open"; confirmation?: ActionConfirmation; - quickCopy?: boolean; searchable?: boolean; searchPlaceholder?: string; submenuVariant?: "navigation"; diff --git a/src/lib/context-menu.tsx b/src/lib/context-menu.tsx index f3fe4a8d9..d6ce6a815 100644 --- a/src/lib/context-menu.tsx +++ b/src/lib/context-menu.tsx @@ -75,7 +75,6 @@ export type ContextMenuTarget = ContentContext & { | { kind: "ebook"; id: string } | { kind: "nav"; itemId?: NavItemId; view?: View; label?: string; onOpen?: () => void } | { kind: "edit"; element: HTMLElement | null; selection: string } - | { kind: "backdrop"; metaId: string; url: string } | { kind: "subtitle"; label: string; diff --git a/src/lib/context-page-background.ts b/src/lib/context-page-background.ts index 8d6afd77d..b8c1fa88b 100644 --- a/src/lib/context-page-background.ts +++ b/src/lib/context-page-background.ts @@ -6,6 +6,9 @@ const OWNED_CONTENT = [ "select", "option", "iframe", + "img", + "picture", + "svg", "canvas", "video", "audio", @@ -16,6 +19,8 @@ const OWNED_CONTENT = [ "[role='textbox']", "[data-harbor-context-layer]", "[data-harbor-player]", + "[data-harbor-image-viewer]", + "[data-title-backdrop]", "[data-ebook-page]", "[data-bp-root]", "[data-bp-focusable]", @@ -33,3 +38,29 @@ export function isPageContextBackground(target: Element | null): boolean { ) ); } + +/** A generic action target is not page space. Only the trusted page binding can + * opt in, and content under the pointer always keeps its own context. */ +export function isBackgroundMenuTarget( + target: + | { + kind: string; + scope?: "page-background"; + navigation?: boolean; + image?: unknown; + link?: string; + selection?: string; + } + | null + | undefined, +): boolean { + return ( + !!target && + target.kind === "actions" && + target.scope === "page-background" && + target.navigation !== false && + !target.image && + !target.link && + !target.selection + ); +} diff --git a/src/lib/context-quick-actions.ts b/src/lib/context-quick-actions.ts index f1db7f031..dceeb8f41 100644 --- a/src/lib/context-quick-actions.ts +++ b/src/lib/context-quick-actions.ts @@ -1,4 +1,6 @@ import { findAction, type ContextAction } from "./context-actions"; +import type { ContextMenuTarget } from "./context-menu"; +import { isBackgroundMenuTarget } from "./context-page-background"; export function omitContextActions( actions: ContextAction[], @@ -16,22 +18,22 @@ export function partitionContextActions( entity: ContextAction[], content: ContextAction[], navigation: ContextAction[], + target?: ContextMenuTarget, ): { quick: ContextAction[]; entity: ContextAction[]; content: ContextAction[] } { - const find = findAction; - const copy = [ - entity.find((action) => action.quickCopy), - find(content, "image:copy"), - find(content, "link:copy"), - find(content, "selection:copy"), - ].find((action) => action && !action.disabled); - const quick = [find(navigation, "page:back"), copy, find(navigation, "page:go:settings")].filter( - (action): action is ContextAction => !!action, + const quick = ( + isBackgroundMenuTarget(target) + ? [findAction(navigation, "page:back"), findAction(navigation, "page:go:settings")] + : [] + ).filter((action): action is ContextAction => !!action); + // Page commands are exclusively rendered in the background strip/footer. + // Item copy commands remain in their original command list. + const moved = new Set( + entity.filter((action) => action.id.startsWith("page:")).map((action) => action.id), ); - const moved = new Set(quick.map((action) => action.id)); return { quick, entity: omitContextActions(entity, moved), - content: omitContextActions(content, moved), + content, }; } diff --git a/src/lib/continue-card-actions.ts b/src/lib/continue-card-actions.ts index eedf235cc..0ac47a88c 100644 --- a/src/lib/continue-card-actions.ts +++ b/src/lib/continue-card-actions.ts @@ -33,7 +33,7 @@ export function createContinueCardActions(options: { dismiss?: () => void | CwDismissResult | Promise; onDismissed?: (result: void | CwDismissResult) => void; onError?: (error: unknown) => void; - icons?: { play: ReactNode; sources: ReactNode; dismiss: ReactNode }; + icons?: { sources: ReactNode; dismiss: ReactNode }; }) { const identity = continueCardIdentity(options.item); const listeners = new Set<() => void>(); @@ -92,15 +92,6 @@ export function createContinueCardActions(options: { const disabled = pending || !isValid() || waitingForAir(); const reason = waitingForAir() ? options.t("This episode has not aired yet.") : undefined; return [ - { - id: `continue:play:${identity}`, - label: options.t("Continue watching"), - icon: options.icons?.play, - disabled, - reason, - restoreFocus: false, - run: () => run("play"), - }, { id: `continue:sources:${identity}`, label: options.t("Choose another source"), @@ -110,30 +101,24 @@ export function createContinueCardActions(options: { restoreFocus: false, run: () => run("sources"), }, + ...(options.dismiss + ? [ + { + id: `continue:dismiss:${identity}`, + label: options.t("Remove from Continue Watching"), + icon: options.icons?.dismiss, + disabled: pending || removed || !options.isCurrent(), + restoreFocus: false, + run: () => run("dismiss"), + }, + ] + : []), ]; }, }; - const extra: ActionSource = { - isValid, - subscribe, - actions: () => - options.dismiss - ? [ - { - id: `continue:dismiss:${identity}`, - label: options.t("Remove from Continue Watching"), - icon: options.icons?.dismiss, - disabled: pending || removed || !options.isCurrent(), - restoreFocus: false, - run: () => run("dismiss"), - }, - ] - : [], - }; return { isValid, primary, - extra, subscribe, isPending: () => pending, play: () => run("play"), diff --git a/src/lib/i18n/locales/ar/context-actions.ts b/src/lib/i18n/locales/ar/context-actions.ts index aa7be4688..109bea07b 100644 --- a/src/lib/i18n/locales/ar/context-actions.ts +++ b/src/lib/i18n/locales/ar/context-actions.ts @@ -17,6 +17,12 @@ const contextActions: Record = { "Play this same video from the beginning?": "تشغيل الفيديو نفسه من البداية؟", "Opening playback…": "جارٍ فتح التشغيل…", "Continue last watched": "متابعة آخر مشاهدة", + "Resume last watched": "استكمال آخر مشاهدة", + "The saved audio track could not be restored. Choose an audio track.": + "تعذّر استعادة مسار الصوت المحفوظ. اختر مسار صوت.", + "The saved subtitle could not be restored. Choose a subtitle.": + "تعذّر استعادة الترجمة المحفوظة. اختر ترجمة.", + "The saved subtitle timing could not be restored.": "تعذّر استعادة توقيت الترجمة المحفوظ.", "Local video": "فيديو محلي", "Open my profile": "فتح ملفي الشخصي", "Add to page": "إضافة إلى صفحة", @@ -512,6 +518,8 @@ const contextActions: Record = { "هناك فيديو آخر قيد التشغيل. عُد إلى المشغّل أولًا.", "The last video file is missing or unavailable.": "ملف الفيديو الأخير مفقود أو غير متاح.", "The last playback source is no longer available.": "لم يعد مصدر آخر مشاهدة متاحًا.", + "Open this video from its source or enter the link again. Its network address was not saved.": + "افتح الفيديو من مصدره أو أدخل الرابط مجددًا. لم يُحفظ عنوانه الشبكي.", "Playback changed. Open the menu again.": "تغيّر التشغيل. افتح القائمة مجددًا.", "Playback controls are not available in this session.": "أدوات التحكم بالتشغيل غير متاحة في هذه الجلسة.", diff --git a/src/lib/i18n/locales/context-actions-fallback.ts b/src/lib/i18n/locales/context-actions-fallback.ts index 3c6100da2..80d6855fe 100644 --- a/src/lib/i18n/locales/context-actions-fallback.ts +++ b/src/lib/i18n/locales/context-actions-fallback.ts @@ -19,6 +19,13 @@ const contextActionsFallback: Record = { "Play this same video from the beginning?": "Play this same video from the beginning?", "Opening playback…": "Opening playback…", "Continue last watched": "Continue last watched", + "Resume last watched": "Resume last watched", + "The saved audio track could not be restored. Choose an audio track.": + "The saved audio track could not be restored. Choose an audio track.", + "The saved subtitle could not be restored. Choose a subtitle.": + "The saved subtitle could not be restored. Choose a subtitle.", + "The saved subtitle timing could not be restored.": + "The saved subtitle timing could not be restored.", "Local video": "Local video", "Open my profile": "Open my profile", "Add to page": "Add to page", @@ -541,6 +548,8 @@ const contextActionsFallback: Record = { "The last video file is missing or unavailable.", "The last playback source is no longer available.": "The last playback source is no longer available.", + "Open this video from its source or enter the link again. Its network address was not saved.": + "Open this video from its source or enter the link again. Its network address was not saved.", "Playback changed. Open the menu again.": "Playback changed. Open the menu again.", "Playback controls are not available in this session.": "Playback controls are not available in this session.", diff --git a/src/lib/membership-actions.ts b/src/lib/membership-actions.ts index ad60d9cbb..c5c409cbd 100644 --- a/src/lib/membership-actions.ts +++ b/src/lib/membership-actions.ts @@ -221,7 +221,7 @@ export function buildMembershipActionSource(options: Options): ActionSource { const actions: ContextAction[] = [ { id: "membership:add", - label: t("Add to"), + label: t("Add to list"), icon: options.commandIcon?.("add"), group: "membership", disabled: !available, diff --git a/src/lib/playback-history.ts b/src/lib/playback-history.ts index d843c2338..267622524 100644 --- a/src/lib/playback-history.ts +++ b/src/lib/playback-history.ts @@ -2,13 +2,33 @@ import { useSyncExternalStore } from "react"; import type { AddonProgress } from "./streams/addons"; import type { PlayerSrc } from "./view"; import type { PlaybackAudioChoice } from "./player/audio-choice"; +import type { PlaybackSubtitleChoice } from "./player/subtitle-choice"; +import { + persistablePlaybackSource, + persistablePlaybackSubtitle, + persistablePlaybackUrl, + playbackUrlFingerprint, + persistablePlaybackFields, + persistablePlaybackIdentity, + persistablePlaybackPreference, +} from "./player/playback-history-source"; import { noteNotify } from "./render-cause"; +import { subtitleMediaKey, subtitleStreamKey } from "./subtitles/subtitle-memory"; +import { + activeScopedProfileId, + isNuvioProviderActive, + stremioProfileId, +} from "./active-profile-id"; +import { cwProfileScope } from "./cw-profile"; + +type ActualPlaybackState = { actual?: ActualPlayback }; export type PlaybackEntry = { infoHash?: string | null; fileIdx?: number | null; addonId?: string | null; url?: string | null; + urlFingerprint?: string; title?: string | null; parsedTitle?: string | null; resolution?: string | null; @@ -21,12 +41,16 @@ export type PlaybackEntry = { savedAt: number; /** Written by observed player activity, never by source selection or watched edits. */ actual?: ActualPlayback; + /** Same history, with observed ownership retained when private CW is enabled. */ + actualByProfile?: Record; }; export type PlaybackActor = { profileId: string; storageProfileId: string; accountId: string | null; + provider?: "nuvio"; + privateCw?: boolean; }; export type ActualPlayback = { @@ -37,8 +61,12 @@ export type ActualPlayback = { durationMs: number; completed: boolean; src: Omit; + sourceFingerprint?: string; requiresSourceRefresh?: boolean; audioTrack?: PlaybackAudioChoice; + subtitle?: PlaybackSubtitleChoice; + subDelaySec?: number; + audioDelaySec?: number; }; export type PlaybackContinuation = { @@ -46,9 +74,13 @@ export type PlaybackContinuation = { positionMs: number; source: Omit; sourceKey: string; + target?: { metaId: string; season?: number; episode?: number }; actor: PlaybackActor; restart?: boolean; audioTrack?: PlaybackAudioChoice; + subtitle?: PlaybackSubtitleChoice; + subDelaySec?: number; + audioDelaySec?: number; }; const STORAGE_KEY_PREFIX = "harbor.playback-history.v1."; @@ -58,8 +90,39 @@ const TTL_MS = 30 * 24 * 60 * 60 * 1000; const MAX_ENTRIES = 200; const listeners = new Set<() => void>(); +let playbackRevision = 0; + +export function getPlaybackRevision(): number { + return playbackRevision; +} + +function notifyPlayback(): void { + playbackRevision += 1; + noteNotify("store:playback"); + listeners.forEach((listener) => listener()); +} + +function nuvioAccountId(): string | null { + try { + const raw = + localStorage.getItem("harbor.nuvio.state.v1") ?? localStorage.getItem("harbor.nuvio.state"); + const userId = JSON.parse(raw ?? "null")?.session?.user?.id; + return typeof userId === "string" && userId ? userId : null; + } catch { + return null; + } +} export function capturePlaybackActor(): PlaybackActor { + if (isNuvioProviderActive()) { + const profileId = activeScopedProfileId(); + return { + profileId, + storageProfileId: profileId, + accountId: nuvioAccountId(), + provider: "nuvio", + }; + } const storageProfileId = activeProfileId(); try { const state = JSON.parse(localStorage.getItem(PROFILES_KEY) ?? "null") as { @@ -74,6 +137,7 @@ export function capturePlaybackActor(): PlaybackActor { profileId: state?.activeId ?? storageProfileId, storageProfileId, accountId: session?.user?._id ?? null, + ...(cwProfileScope().private ? { privateCw: true } : {}), }; } catch { return { profileId: storageProfileId, storageProfileId, accountId: null }; @@ -85,30 +149,14 @@ export function isPlaybackActorCurrent(actor: PlaybackActor): boolean { return ( current.profileId === actor.profileId && current.storageProfileId === actor.storageProfileId && - current.accountId === actor.accountId + current.accountId === actor.accountId && + current.provider === actor.provider && + !!current.privateCw === !!actor.privateCw ); } function activeProfileId(): string { - try { - const raw = localStorage.getItem(PROFILES_KEY); - if (!raw) return ""; - const s = JSON.parse(raw) as { - activeId?: string; - profiles?: Array<{ id?: string; isPrimary?: boolean; shareStremioWith?: string | null }>; - }; - const profiles = Array.isArray(s.profiles) ? s.profiles : []; - const active = profiles.find((p) => p.id === s.activeId) ?? null; - const own = active?.id ?? profiles.find((p) => p?.isPrimary)?.id ?? ""; - if (!own) return ""; - if (active && typeof active.shareStremioWith === "string" && active.shareStremioWith) { - const shared = profiles.find((p) => p.id === active.shareStremioWith); - if (shared?.id) return shared.id; - } - return own; - } catch { - return ""; - } + return stremioProfileId(); } function primaryProfileId(): string { @@ -125,11 +173,15 @@ function primaryProfileId(): string { } function storeKey(): string { + if (isNuvioProviderActive()) { + return `${STORAGE_KEY_PREFIX}nuvio.${encodeURIComponent(nuvioAccountId() ?? "anon")}.${activeScopedProfileId()}`; + } const id = activeProfileId(); return id ? STORAGE_KEY_PREFIX + id : LEGACY_STORAGE_KEY; } function migrateLegacy(): void { + if (isNuvioProviderActive()) return; try { const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); if (!legacy) return; @@ -143,32 +195,163 @@ function migrateLegacy(): void { } } +const scopeEvents = [ + "storage", + "harbor:active-profile-changed", + "harbor:profiles-updated", + "harbor:settings-changed", + "harbor:nuvio-profile-changed", + "harbor:nuvio-state-changed", + "harbor:account-provider-changed", +]; +let stopScopeEvents: (() => void) | null = null; + export function subscribePlayback(fn: () => void): () => void { + if (!stopScopeEvents && typeof window !== "undefined") { + const target = window; + let actorKey = JSON.stringify(capturePlaybackActor()); + const onScopeChange = () => { + const next = JSON.stringify(capturePlaybackActor()); + if (next === actorKey) return; + actorKey = next; + notifyPlayback(); + }; + for (const event of scopeEvents) target.addEventListener(event, onScopeChange); + stopScopeEvents = () => { + for (const event of scopeEvents) target.removeEventListener(event, onScopeChange); + }; + } listeners.add(fn); return () => { listeners.delete(fn); + if (listeners.size === 0) { + stopScopeEvents?.(); + stopScopeEvents = null; + } }; } +function samePlaybackOwner(actual: ActualPlayback, actor: PlaybackActor): boolean { + return ( + validActualPlayback(actual) && + Date.now() - actual.playedAt <= TTL_MS && + actual.actor.provider === actor.provider && + actual.actor.storageProfileId === actor.storageProfileId && + actual.actor.accountId === actor.accountId + ); +} + +function retainedActualProfiles( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): Record { + let profileIds = new Set([actor.profileId]); + if (!actor.provider) { + try { + const roster = JSON.parse(localStorage.getItem(PROFILES_KEY) ?? "null") as { + profiles?: Array<{ id: string }>; + } | null; + profileIds = new Set([ + actor.profileId, + ...(roster?.profiles ?? []).map((profile) => profile.id), + ]); + } catch { + // Keep existing slots if the roster is temporarily unavailable. + profileIds = new Set([actor.profileId, ...Object.keys(entry?.actualByProfile ?? {})]); + } + } + return Object.fromEntries( + Object.entries(entry?.actualByProfile ?? {}).filter( + ([profileId, state]) => + profileIds.has(profileId) && + !!state?.actual && + validActualPlayback(state.actual) && + state.actual.actor.profileId === profileId && + Date.now() - state.actual.playedAt <= TTL_MS, + ), + ); +} + +function ownActualState( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): ActualPlaybackState { + const own = entry?.actualByProfile?.[actor.profileId]; + if ( + own?.actual && + own.actual.actor?.profileId === actor.profileId && + samePlaybackOwner(own.actual, actor) + ) + return { actual: own.actual }; + // Old shared entries establish private ownership only through the observed actor. + return entry?.actual?.actor?.profileId === actor.profileId && + samePlaybackOwner(entry.actual, actor) + ? { actual: entry.actual } + : {}; +} + +function selectedActualState( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): ActualPlaybackState { + const state = actor.privateCw ? ownActualState(entry, actor) : entry; + return state?.actual && samePlaybackOwner(state.actual, actor) ? { actual: state.actual } : {}; +} + function entryKey(metaId: string, season?: number, episode?: number): string { + metaId = persistablePlaybackIdentity(metaId); if (typeof season === "number" && typeof episode === "number") { return `${metaId}|s${season}e${episode}`; } return metaId; } -function readAll(): Record { +function legacyEntryKey(key: string, entry: PlaybackEntry): string { + const observations = [ + entry.actual, + ...Object.values(entry.actualByProfile ?? {}).map((state) => state?.actual), + ]; + for (const actual of observations) { + const id = actual?.src?.meta?.id; + if (typeof id !== "string") continue; + const episode = actual?.src?.episode; + const suffix = + typeof episode?.season === "number" && typeof episode?.episode === "number" + ? `|s${episode.season}e${episode.episode}` + : ""; + if (key === `${id}${suffix}`) return entryKey(id, episode?.season, episode?.episode); + } + if (typeof entry.url === "string" && key === `url:${entry.url}`) return entryKey(key); + const suffix = /^(.*)(\|s\d+e\d+)$/.exec(key); + return suffix + ? `${persistablePlaybackIdentity(suffix[1])}${suffix[2]}` + : persistablePlaybackIdentity(key); +} + +function readAll(includeExpired = false): Record { migrateLegacy(); try { const raw = localStorage.getItem(storeKey()); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; const now = Date.now(); - const fresh: Record = {}; + const fresh: Record = Object.create(null); + let scrubbed = false; for (const [k, v] of Object.entries(parsed)) { - if (!v?.savedAt) continue; - if (now - v.savedAt > TTL_MS) continue; - fresh[k] = v; + if (!v) continue; + const key = legacyEntryKey(k, v); + const safe = persistableEntry(v, key); + scrubbed ||= key !== k || JSON.stringify(safe) !== JSON.stringify(v); + if (!includeExpired && (!v.savedAt || now - v.savedAt > TTL_MS)) continue; + const previous = fresh[key]; + fresh[key] = previous ? mergeHistoryEntries(previous, safe) : safe; + } + if (scrubbed) { + try { + localStorage.setItem(storeKey(), JSON.stringify(fresh)); + } catch { + // Reads remain safe when storage is unavailable; a later read can retry. + } } return fresh; } catch { @@ -176,6 +359,33 @@ function readAll(): Record { } } +function newerActual( + a: ActualPlayback | undefined, + b: ActualPlayback | undefined, +): ActualPlayback | undefined { + if (!a || !validActualPlayback(a)) return b; + if (!b || !validActualPlayback(b)) return a; + return a.playedAt >= b.playedAt ? a : b; +} + +// A partially migrated key can coexist with its old URL-derived spelling. +// Preserve each profile's observed session when those keys converge. +function mergeHistoryEntries(a: PlaybackEntry, b: PlaybackEntry): PlaybackEntry { + const profiles: Record = Object.assign( + Object.create(null), + a.actualByProfile, + ); + for (const [id, state] of Object.entries(b.actualByProfile ?? {})) { + const actual = newerActual(profiles[id]?.actual, state.actual); + profiles[id] = { ...state, ...(actual ? { actual } : {}) }; + } + return { + ...(a.savedAt >= b.savedAt ? a : b), + actual: newerActual(a.actual, b.actual), + ...(Object.keys(profiles).length ? { actualByProfile: profiles } : {}), + }; +} + function writeAll(map: Record): void { try { const keys = Object.keys(map); @@ -183,25 +393,17 @@ function writeAll(map: Record): void { const sorted = Object.entries(map).sort((a, b) => b[1].savedAt - a[1].savedAt); map = Object.fromEntries(sorted.slice(0, MAX_ENTRIES)); } - localStorage.setItem(storeKey(), JSON.stringify(map)); + localStorage.setItem(storeKey(), JSON.stringify(persistableEntries(map))); } catch { // A failed replacement must leave the last persisted source/session intact. return; } - noteNotify("store:playback"); - listeners.forEach((l) => l()); + notifyPlayback(); } export function playbackHistoryRawKeys(): string[] { - migrateLegacy(); - try { - const raw = localStorage.getItem(storeKey()); - if (!raw) return []; - const parsed = JSON.parse(raw) as Record; - return Object.keys(parsed); - } catch { - return []; - } + // Statistics inspect every retained key; only regular history reads apply TTL. + return Object.keys(readAll(true)); } export function savePlayback( @@ -222,12 +424,20 @@ export function savePlayback( parsedTitle: entry.parsedTitle ?? prev.parsedTitle, savedAt: Date.now(), } - : { ...entry, actual: prev?.actual, savedAt: Date.now() }; + : { + ...entry, + actual: prev?.actual, + actualByProfile: prev?.actualByProfile, + savedAt: Date.now(), + }; writeAll(all); } export function recordActualPlayback(playback: ActualPlayback): boolean { - if (!isPlaybackActorCurrent(playback.actor) || !validActualPlayback(playback)) return false; + if (!isPlaybackActorCurrent(playback.actor)) return false; + const safe = persistableActualPlayback(playback); + if (!safe || !validActualPlayback(safe)) return false; + playback = safe; const all = readAll(); const key = entryKey( playback.src.meta.id, @@ -235,47 +445,82 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { playback.src.episode?.episode, ); const previous = all[key]; - const previousActual = previous?.actual; + const previousOwn = ownActualState(previous, playback.actor); + const previousActual = previousOwn.actual; // mpv can clear its track list at natural EOF. That is not a new audio // choice; keep only the last observed choice from this exact session. - const actual = + const sameEndedSession = playback.completed && - !playback.audioTrack && - previousActual?.audioTrack && + previousActual && validActualPlayback(previousActual) && previousActual.id === playback.id && - isPlaybackActorCurrent(previousActual.actor) && - playbackSourceKey(previousActual.src) === playbackSourceKey(playback.src) - ? { ...playback, audioTrack: previousActual.audioTrack } - : playback; + actualPlaybackSourceKey(previousActual) === actualPlaybackSourceKey(playback); + const actual = sameEndedSession + ? { + ...playback, + audioTrack: playback.audioTrack ?? previousActual.audioTrack, + subtitle: playback.subtitle ?? previousActual.subtitle, + subDelaySec: playback.subDelaySec ?? previousActual.subDelaySec, + audioDelaySec: playback.audioDelaySec ?? previousActual.audioDelaySec, + } + : playback; + const shared = previous?.actual; + const updateShared = + !shared || !samePlaybackOwner(shared, playback.actor) || shared.playedAt <= actual.playedAt; + const updateOwn = !previousActual || previousActual.playedAt <= actual.playedAt; const next = { ...all, [key]: { ...previous, title: previous?.title ?? playback.src.meta.name, savedAt: Math.max(previous?.savedAt ?? 0, playback.playedAt), - actual, + actual: updateShared ? actual : shared, + actualByProfile: { + ...retainedActualProfiles(previous, playback.actor), + ...(updateOwn + ? { + [playback.actor.profileId]: { + actual, + }, + } + : {}), + }, }, }; const entries = Object.entries(next).sort((a, b) => b[1].savedAt - a[1].savedAt); try { localStorage.setItem( storeKey(), - JSON.stringify(Object.fromEntries(entries.slice(0, MAX_ENTRIES))), + JSON.stringify(persistableEntries(Object.fromEntries(entries.slice(0, MAX_ENTRIES)))), ); } catch { return false; } - noteNotify("store:playback"); - listeners.forEach((listener) => listener()); + notifyPlayback(); return true; } function validActualPlayback(value: ActualPlayback): boolean { + const server = value?.src?.homeServer; + const stream = value?.src?.streamRef; + const refreshable = + !!value?.requiresSourceRefresh && + (!!value.sourceFingerprint || + !!stream?.infoHash || + (!!stream?.addonId && !!stream.parsedTitle && !!stream.size && stream.size > 0)); + const serverIdentity = + !!server && + [server.connectionId, server.itemId, server.versionId].every( + (id) => typeof id === "string" && !!id, + ) && + ["original", "4k-40", "1080p-20", "1080p-12", "720p-4", "480p-2", "360p-0.7"].includes( + server.quality, + ); return ( !!value && typeof value.id === "string" && !!value.id && + typeof value.completed === "boolean" && Number.isFinite(value.playedAt) && Number.isFinite(value.positionMs) && value.positionMs >= 0 && @@ -284,28 +529,126 @@ function validActualPlayback(value: ActualPlayback): boolean { !!value.actor && typeof value.actor.storageProfileId === "string" && typeof value.src?.url === "string" && - !!value.src.url && + (server ? serverIdentity : !!value.src.url || refreshable) && typeof value.src?.meta?.id === "string" && !!value.src.meta.id && typeof value.src.meta.name === "string" ); } +function persistableActualPlayback(value: ActualPlayback): ActualPlayback | undefined { + if (!validActualPlayback(value)) return undefined; + const src = persistablePlaybackSource(value.src); + const actor = persistablePlaybackFields(value.actor, [ + "profileId", + "storageProfileId", + "accountId", + "provider", + "privateCw", + ]); + if ( + typeof actor.profileId !== "string" || + typeof actor.storageProfileId !== "string" || + !(actor.accountId === null || typeof actor.accountId === "string") || + (actor.provider !== undefined && actor.provider !== "nuvio") + ) + return undefined; + const audio = value.audioTrack + ? persistablePlaybackFields(value.audioTrack, [ + "id", + "lang", + "title", + "codec", + "channels", + "channelCount", + ]) + : undefined; + const audioValid = + audio && + typeof audio.id === "string" && + (["lang", "title", "codec", "channels"] as const).every( + (key) => audio[key] === undefined || typeof audio[key] === "string", + ) && + (audio.channelCount === undefined || typeof audio.channelCount === "number"); + return { + id: persistablePlaybackIdentity(value.id), + actor, + playedAt: value.playedAt, + positionMs: value.positionMs, + durationMs: value.durationMs, + completed: value.completed, + src, + sourceFingerprint: + playbackUrlFingerprint(value.src) ?? + (typeof value.sourceFingerprint === "string" && /^[a-f0-9]{64}$/.test(value.sourceFingerprint) + ? value.sourceFingerprint + : undefined), + requiresSourceRefresh: + value.requiresSourceRefresh === true || + !!value.src.proxySessionId || + !!Object.keys(value.src.headers ?? {}).length || + !!src.streamRef?.infoHash || + !!src.homeServer || + !src.url || + undefined, + audioTrack: audioValid ? audio : undefined, + subtitle: persistablePlaybackSubtitle(value.subtitle, value.src), + subDelaySec: Number.isFinite(value.subDelaySec) ? value.subDelaySec : undefined, + audioDelaySec: Number.isFinite(value.audioDelaySec) ? value.audioDelaySec : undefined, + }; +} + +function persistableEntry(entry: PlaybackEntry, key?: string): PlaybackEntry { + const preference = persistablePlaybackPreference(entry); + const metaId = entry.actual?.src?.meta?.id; + const opaque = + key?.startsWith("playback-private:") || + (!!metaId && persistablePlaybackIdentity(metaId).startsWith("playback-private:")); + return { + ...preference, + ...(opaque ? { title: "Direct stream", parsedTitle: undefined } : {}), + ...(entry.actual ? { actual: persistableActualPlayback(entry.actual) } : {}), + ...(entry.actualByProfile + ? { + actualByProfile: Object.fromEntries( + Object.entries(entry.actualByProfile) + .filter(([, state]) => !!state?.actual) + .map(([id, state]) => [ + persistablePlaybackIdentity(id), + { + ...(state?.actual ? { actual: persistableActualPlayback(state.actual) } : {}), + ...persistablePlaybackFields( + state as ActualPlaybackState & { dismissedActualId?: string }, + ["dismissedActualId"], + ), + }, + ]), + ), + } + : {}), + // Old versions wrote this inert field. It never controls visibility here. + ...persistablePlaybackFields(entry as PlaybackEntry & { dismissedActualId?: string }, [ + "dismissedActualId", + ]), + }; +} + +function persistableEntries(entries: Record): Record { + return Object.fromEntries( + Object.entries(entries).map(([key, entry]) => [key, persistableEntry(entry, key)]), + ); +} + export function readLastActualPlayback(): ActualPlayback | null { const actor = capturePlaybackActor(); - let last: ActualPlayback | null = null; + let last: ActualPlaybackState | null = null; for (const entry of Object.values(readAll())) { - const actual = entry.actual; - if ( - !actual || - !validActualPlayback(actual) || - actual.actor.storageProfileId !== actor.storageProfileId || - actual.actor.accountId !== actor.accountId - ) - continue; - if (!last || actual.playedAt > last.playedAt) last = actual; + const state = selectedActualState(entry, actor); + const actual = state.actual; + if (!actual || Date.now() - actual.playedAt > TTL_MS) continue; + if (!last || actual.playedAt > last.actual!.playedAt) last = state; } - return last; + return last?.actual ?? null; } export function playbackSourceKey( @@ -315,19 +658,120 @@ export function playbackSourceKey( return `server:${src.homeServer.connectionId}:${src.homeServer.itemId}:${src.homeServer.versionId}`; if (src.streamRef?.infoHash) return `torrent:${src.streamRef.infoHash.toLowerCase()}:${src.streamRef.fileIdx ?? ""}`; - return src.historyUrl ?? src.url; + const url = persistablePlaybackUrl(src.historyUrl ?? src.url); + if (url) return url; + const fingerprint = playbackUrlFingerprint(src); + if (fingerprint) return `private:${fingerprint}`; + const stream = src.streamRef; + // Older redacted sources may only have their verified release fields. + return stream?.addonId && stream.parsedTitle && stream.size && stream.size > 0 + ? `release:${JSON.stringify([stream.addonId, stream.parsedTitle, stream.size, stream.fileIdx ?? null])}` + : ""; +} + +export function actualPlaybackSourceKey(playback: ActualPlayback): string { + return playback.sourceFingerprint + ? `private:${playback.sourceFingerprint}` + : playbackSourceKey(playback.src); } export function readActualPlaybackFor(src: PlayerSrc): ActualPlayback | null { - const entry = readAll()[entryKey(src.meta.id, src.episode?.season, src.episode?.episode)]?.actual; const actor = capturePlaybackActor(); - return entry && - validActualPlayback(entry) && - entry.actor.storageProfileId === actor.storageProfileId && - entry.actor.accountId === actor.accountId && - playbackSourceKey(entry.src) === playbackSourceKey(src) - ? entry - : null; + const entry = selectedActualState( + readAll()[entryKey(src.meta.id, src.episode?.season, src.episode?.episode)], + actor, + ).actual; + const sourceKey = playbackSourceKey(src); + return entry && sourceKey && actualPlaybackSourceKey(entry) === sourceKey ? entry : null; +} + +/** Capture the observed session before an asynchronous subtitle cache write starts. */ +export function readActualPlaybackForSubtitle( + mediaKey: string, + streamKey: string | undefined, + notBefore: number, +): ActualPlayback | null { + const actor = capturePlaybackActor(); + const parts = /^(.*)\|([^|]*)\|([^|]*)$/.exec(mediaKey); + if (parts) mediaKey = `${persistablePlaybackIdentity(parts[1])}|${parts[2]}|${parts[3]}`; + if (streamKey) streamKey = persistablePlaybackIdentity(streamKey); + return ( + Object.values(readAll()) + .map((entry) => ownActualState(entry, actor).actual) + .find( + (actual) => + actual && + validActualPlayback(actual) && + actual.actor.profileId === actor.profileId && + actual.actor.storageProfileId === actor.storageProfileId && + actual.actor.accountId === actor.accountId && + actual.playedAt >= notBefore && + subtitleMediaKey( + actual.src.meta.id, + actual.src.episode?.season, + actual.src.episode?.episode, + ) === mediaKey && + (actual.subtitle?.streamKey ?? subtitleStreamKey(actual.src.streamRef)) === streamKey, + ) ?? null + ); +} + +/** Cache completion may improve a source pointer, never playback recency or a newer session. */ +export function cacheActualPlaybackSubtitle( + target: ActualPlayback, + original: PlaybackSubtitleChoice, + cached: PlaybackSubtitleChoice, +): boolean { + if ( + !isPlaybackActorCurrent(target.actor) || + !cached.imported || + !cached.source || + /^(https?|blob|data|memory):/i.test(cached.source) + ) + return false; + const all = readAll(); + const key = entryKey(target.src.meta.id, target.src.episode?.season, target.src.episode?.episode); + const entry = all[key]; + const own = ownActualState(entry, target.actor); + const actual = own.actual; + const savedOriginal = persistablePlaybackSubtitle(original, target.src); + if ( + !entry || + !actual || + !validActualPlayback(actual) || + actual.id !== target.id || + actualPlaybackSourceKey(actual) !== actualPlaybackSourceKey(target) || + !actual.subtitle || + actual.subtitle.off || + !( + (actual.subtitle.source && actual.subtitle.source === savedOriginal?.source) || + (actual.subtitle.sourceFingerprint && + actual.subtitle.sourceFingerprint === savedOriginal?.sourceFingerprint) + ) || + actual.subtitle.streamKey !== savedOriginal?.streamKey || + actual.subtitle.subId !== savedOriginal?.subId || + actual.subtitle.provider !== savedOriginal?.provider + ) + return false; + const patched = { + ...actual, + subtitle: { ...actual.subtitle, ...cached, fpsOverride: actual.subtitle.fpsOverride }, + }; + all[key] = { + ...entry, + ...(entry.actual?.id === actual.id ? { actual: patched } : {}), + actualByProfile: { + ...entry.actualByProfile, + [target.actor.profileId]: { ...own, actual: patched }, + }, + }; + try { + localStorage.setItem(storeKey(), JSON.stringify(persistableEntries(all))); + } catch { + return false; + } + notifyPlayback(); + return true; } export function readPlayback( @@ -343,7 +787,13 @@ export function clearPlayback(metaId: string, season?: number, episode?: number) const all = readAll(); const key = entryKey(metaId, season, episode); const actual = all[key]?.actual; - if (actual) all[key] = { savedAt: actual.playedAt, title: actual.src.meta.name, actual }; + if (actual || all[key]?.actualByProfile) + all[key] = { + savedAt: all[key].savedAt, + title: actual?.src.meta.name ?? all[key].title, + actual, + actualByProfile: all[key].actualByProfile, + }; else delete all[key]; writeAll(all); } @@ -352,7 +802,10 @@ function offeredBy( s: { addonId?: string | null; contributors?: Array<{ id: string }> }, addonId: string, ): boolean { - return s.addonId === addonId || s.contributors?.some((c) => c.id === addonId) === true; + return ( + (s.addonId && persistablePlaybackIdentity(s.addonId) === addonId) || + s.contributors?.some((c) => persistablePlaybackIdentity(c.id) === addonId) === true + ); } /** True when the stream came from the given addon or one of its contributors. */ @@ -368,13 +821,15 @@ export function streamOfferedByAddon( * addon. Debrid library copies and aggregator duplicates share the torrent hash, * so a plain hash match would otherwise select whichever row came first. */ -export function findPreferredSourceMatch; - resolution?: string | null; - releaseGroupNormalized?: string | null; - audioLanguages?: string[] | null; -}>( +export function findPreferredSourceMatch< + T extends { + addonId?: string | null; + contributors?: Array<{ id: string }>; + resolution?: string | null; + releaseGroupNormalized?: string | null; + audioLanguages?: string[] | null; + }, +>( streams: T[], entry: PlaybackEntry, match: (stream: T, entry: PlaybackEntry) => boolean, @@ -422,6 +877,7 @@ export function streamMatchesEntry( return true; } if (e.url && s.url) return s.url === e.url; + if (e.urlFingerprint && s.url) return playbackUrlFingerprint({ url: s.url }) === e.urlFingerprint; return false; } @@ -478,6 +934,7 @@ export function playbackEntries(): Array<{ } export function readLastSeriesPlayback(metaId: string): PlaybackEntry | null { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); const prefix = `${metaId}|`; let best: PlaybackEntry | null = null; diff --git a/src/lib/player/actual-playback.ts b/src/lib/player/actual-playback.ts index b30366915..20a9dd7e8 100644 --- a/src/lib/player/actual-playback.ts +++ b/src/lib/player/actual-playback.ts @@ -4,6 +4,17 @@ import type { PlayerSnapshot } from "./bridge"; import { isLivePlaybackSrc } from "./live-src"; import { isNaturalEnd } from "./playback-end"; import { playbackAudioChoice } from "./audio-choice"; +import { playbackSubtitleChoice, type PlaybackSubtitleChoice } from "./subtitle-choice"; +import { + persistablePlaybackSource, + persistablePlaybackSubtitle, + playbackUrlFingerprint, +} from "./playback-history-source"; +import { + subtitleMediaKey, + subtitleStreamKey, + type RememberedSub, +} from "../subtitles/subtitle-memory"; export function actualPlaybackSnapshot( src: PlayerSrc, @@ -11,6 +22,7 @@ export function actualPlaybackSnapshot( positionSec: number, session: { id: string; actor: PlaybackActor }, at = Date.now(), + rememberedSubtitle?: RememberedSub | null, ): ActualPlayback | null { if ( isLivePlaybackSrc(src) || @@ -22,21 +34,29 @@ export function actualPlaybackSnapshot( !src.url ) return null; - // Runtime-only handles cannot survive restarting Harbor. Keep the original - // source identity and send proxy/torrent sources through normal preparation. - const { - continuation: _continuation, - playbackTraceId: _trace, - proxySessionId, - attempt: _attempt, - autoFired: _auto, - startPositionMs: _start, - startPaused: _paused, - startFromZero: _zero, - ...saved - } = src; - const { videos: _videos, description: _description, ...meta } = src.meta; + const saved = persistablePlaybackSource(src); const selectedAudio = snapshot.audioTracks.find((track) => track.selected); + const selectedSubtitle = snapshot.subtitleTracks.find((track) => track.selected); + const tracksClearedAtEnd = + snapshot.status === "ended" && + snapshot.audioTracks.length === 0 && + snapshot.subtitleTracks.length === 0; + const subtitle: PlaybackSubtitleChoice | undefined = selectedSubtitle + ? playbackSubtitleChoice(selectedSubtitle, rememberedSubtitle, { + mediaKey: subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode), + streamKey: subtitleStreamKey(src.streamRef), + }) + : snapshot.status === "ended" && snapshot.subtitleTracks.length === 0 + ? undefined + : { off: true }; + if (subtitle?.source) subtitle.streamKey = subtitleStreamKey(src.streamRef); + if ( + subtitle && + selectedSubtitle && + Number.isFinite(snapshot.subtitleFps) && + (snapshot.subtitleFps === 0 || (snapshot.subtitleFps! >= 1 && snapshot.subtitleFps! <= 240)) + ) + subtitle.fpsOverride = snapshot.subtitleFps; return { ...session, playedAt: at, @@ -45,8 +65,23 @@ export function actualPlaybackSnapshot( // Watched/progress thresholds do not mean the player finished. A paused // near-end session must still continue from its actual position. completed: isNaturalEnd(snapshot, positionSec), - src: { ...saved, meta, url: src.historyUrl ?? src.url }, - requiresSourceRefresh: !!proxySessionId || !!src.streamRef?.infoHash || !!src.homeServer, + src: saved, + sourceFingerprint: playbackUrlFingerprint(src), + requiresSourceRefresh: + !!src.proxySessionId || + !!Object.keys(src.headers ?? {}).length || + !!src.streamRef?.infoHash || + !!src.homeServer || + !saved.url, audioTrack: selectedAudio ? playbackAudioChoice(selectedAudio) : undefined, + subtitle: persistablePlaybackSubtitle(subtitle, src), + subDelaySec: + !tracksClearedAtEnd && Number.isFinite(snapshot.subDelaySec) + ? snapshot.subDelaySec + : undefined, + audioDelaySec: + !tracksClearedAtEnd && Number.isFinite(snapshot.audioDelaySec) + ? snapshot.audioDelaySec + : undefined, }; } diff --git a/src/lib/player/bridge.ts b/src/lib/player/bridge.ts index 65f35da74..af7aa7289 100644 --- a/src/lib/player/bridge.ts +++ b/src/lib/player/bridge.ts @@ -74,6 +74,8 @@ export type PlayerSnapshot = { chapters: Chapter[]; subDelaySec: number; audioDelaySec: number; + /** Observed native correction; undefined when the engine does not expose it. Zero means no correction. */ + subtitleFps?: number; subText: string; subStartSec: number; secondarySubText: string; diff --git a/src/lib/player/continue-playback.ts b/src/lib/player/continue-playback.ts index 2158aa8dd..c9e095a61 100644 --- a/src/lib/player/continue-playback.ts +++ b/src/lib/player/continue-playback.ts @@ -1,5 +1,6 @@ import { playbackSourceKey, + actualPlaybackSourceKey, streamMatchesEntry, type ActualPlayback, type PlaybackActor, @@ -8,6 +9,7 @@ import { import type { Meta } from "../cinemeta"; import type { PlayEpisode, PlayerSrc } from "../view"; import { isLocalUrl } from "./local-url"; +import { persistablePlaybackIdentity } from "./playback-history-source"; export type ContinuationServices = { isCurrent: (actor: PlaybackActor) => boolean; @@ -37,6 +39,37 @@ export function continuationStream 0 && + source.size === saved.size + ); +} + export function continuationFor( target: ActualPlayback, actor: PlaybackActor, @@ -47,8 +80,16 @@ export function continuationFor( actor, restart, positionMs: restart ? 0 : target.positionMs, - sourceKey: playbackSourceKey(target.src), + sourceKey: actualPlaybackSourceKey(target), + target: { + metaId: target.src.meta.id, + season: target.src.episode?.season, + episode: target.src.episode?.episode, + }, audioTrack: target.audioTrack, + subtitle: target.subtitle, + subDelaySec: target.subDelaySec, + audioDelaySec: target.audioDelaySec, source: { ...target.src.streamRef, url: target.src.historyUrl ?? target.src.url, @@ -76,8 +117,9 @@ export async function continueActualPlayback( const active = services.active(); if (active) { if ( - playbackSourceKey(active.src) !== playbackSourceKey(target.src) || - active.src.meta.id !== target.src.meta.id || + !playbackSourceKey(active.src) || + playbackSourceKey(active.src) !== actualPlaybackSourceKey(target) || + persistablePlaybackIdentity(active.src.meta.id) !== target.src.meta.id || active.src.episode?.season !== target.src.episode?.season || active.src.episode?.episode !== target.src.episode?.episode ) { @@ -87,14 +129,29 @@ export async function continueActualPlayback( return "returned"; } const continuation = continuationFor(target, actor, options.restart); - const prepared = (src: PlayerSrc): PlayerSrc => ({ - ...src, - continuation, - resume: true, - startPositionMs: continuation.positionMs, - startFromZero: options.restart === true, - startPaused: false, - }); + const prepared = (src: PlayerSrc): PlayerSrc => { + const subtitle = continuation.subtitle; + const restored = + src.homeServer && subtitle?.homeServerSubtitleId + ? { + ...continuation, + subtitle: { + ...subtitle, + // Server subtitle URLs are authenticated transport, not saved identity. + source: src.subtitles?.find((track) => track.id === subtitle.homeServerSubtitleId) + ?.url, + }, + } + : continuation; + return { + ...src, + continuation: restored, + resume: true, + startPositionMs: continuation.positionMs, + startFromZero: options.restart === true, + startPaused: false, + }; + }; const pick = () => { valid(); services.openPicker(target.src.meta, target.src.episode, { @@ -120,6 +177,10 @@ export async function continueActualPlayback( services.openPlayer(prepared(src)); return "player-opened"; } + if (target.src.meta.id.startsWith("playback-private:")) + throw new Error( + "Open this video from its source or enter the link again. Its network address was not saved.", + ); if (target.requiresSourceRefresh || target.src.streamRef?.infoHash) return pick(); if (!/^https?:\/\//i.test(target.src.url)) throw new Error("The last playback source is no longer available."); diff --git a/src/lib/player/mpv-properties.ts b/src/lib/player/mpv-properties.ts index 12c40ccaa..0dc50989c 100644 --- a/src/lib/player/mpv-properties.ts +++ b/src/lib/player/mpv-properties.ts @@ -81,8 +81,9 @@ async function waitForMpvSubtitleFpsTransitions(): Promise { export async function writeMpvSubtitleFps( choice: SubtitleFpsChoice, generation: number, + isCurrent: () => boolean = () => true, ): Promise { - await coordinator.apply(choice, () => generation === subtitleFpsGeneration); + await coordinator.apply(choice, () => generation === subtitleFpsGeneration && isCurrent()); } export async function resetMpvSubtitleFpsForTransition( diff --git a/src/lib/player/mpv.ts b/src/lib/player/mpv.ts index c48c5f479..63d57b4bb 100644 --- a/src/lib/player/mpv.ts +++ b/src/lib/player/mpv.ts @@ -983,6 +983,8 @@ export function createMpvBridge(mpvOptions?: MpvOptions): PlayerBridge { } if (name === "sub-delay" && typeof data === "number") snap.subDelaySec = data; if (name === "audio-delay" && typeof data === "number") snap.audioDelaySec = data; + if (name === "sub-fps" && typeof data === "number" && Number.isFinite(data)) + snap.subtitleFps = data; if (name === "sub-text") { snap.subText = !primarySubtitleOff && typeof data === "string" ? data : ""; } @@ -1185,6 +1187,7 @@ export function createMpvBridge(mpvOptions?: MpvOptions): PlayerBridge { snap.errorMessage = null; snap.audioTracks = []; snap.subtitleTracks = []; + snap.subtitleFps = undefined; primarySubtitleOff = false; snap.subText = ""; snap.subStartSec = 0; diff --git a/src/lib/player/playback-history-source.ts b/src/lib/player/playback-history-source.ts new file mode 100644 index 000000000..52e1d9c33 --- /dev/null +++ b/src/lib/player/playback-history-source.ts @@ -0,0 +1,344 @@ +import { persistableAddonOrigin } from "../addon-configuration-key"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; +import type { PlayerSrc } from "../view"; +import type { PlaybackSubtitleChoice } from "./subtitle-choice"; +import type { PlaybackEntry } from "../playback-history"; +import { isLocalUrl } from "./local-url"; + +const MAX_URL_ENCODING_DEPTH = 8; + +// Names and labels are not an alternative place to retain a transport URL. +function safeString(value: string): string | undefined { + if (/^file:\/\//i.test(value) && persistablePlaybackUrl(value)) return value; + if (/^local[:-]/i.test(value) && persistablePlaybackUrl(value.slice(6))) return value; + if (/^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value)) return value; + let decoded = value; + for (let depth = 0; depth < MAX_URL_ENCODING_DEPTH; depth++) { + if (/(?:https?|rtmps?|rtsp|mms|ftp|sftp|wss?):\s*[/\\]|\/\//i.test(decoded)) return undefined; + const next = decoded.replace(/%([a-f0-9]{2})/gi, (_, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); + if (next === decoded) return value; + decoded = next; + } + // Do not let deeper or deliberately layered encoding bypass the bounded scan. + return undefined; +} + +/** Select only scalar fields; nested transport objects never enter history. */ +function fields(value: T, keys: readonly K[]): Pick { + const entries: Array<[K, unknown]> = []; + for (const key of keys) { + const item = value[key]; + if (typeof item === "string") { + const safe = safeString(item); + if (safe !== undefined) entries.push([key, safe]); + } else if ( + typeof item === "boolean" || + item === null || + (typeof item === "number" && Number.isFinite(item)) + ) { + entries.push([key, item]); + } else if (Array.isArray(item) && item.every((part) => typeof part === "string")) { + entries.push([key, item.filter((part) => safeString(part) !== undefined)]); + } + } + return Object.fromEntries(entries) as Pick; +} + +export { fields as persistablePlaybackFields }; + +export function playbackTransportFingerprint(value: string | undefined): string | undefined { + return value ? bytesToHex(sha256(new TextEncoder().encode(value))) : undefined; +} + +/** URL-derived item IDs cannot double as a second copy of the transport. */ +export function persistablePlaybackIdentity(value: string): string { + if (typeof value !== "string") return ""; + return safeString(value) === undefined + ? `playback-private:${playbackTransportFingerprint(value)}` + : value; +} + +export function persistablePlaybackKey(value: string): string { + const match = /^(.*)(\|(?:s\d+(?:e\d+)?|all))$/.exec(value); + return match + ? `${persistablePlaybackIdentity(match[1])}${match[2]}` + : persistablePlaybackIdentity(value); +} + +export function persistablePlaybackUrl(value: string | undefined): string | undefined { + if (typeof value !== "string" || !isLocalUrl(value) || value.startsWith("//")) return undefined; + if (/^file:/i.test(value)) { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash) return undefined; + } catch { + return undefined; + } + } + return value; +} + +function artworkUrl(value: string | undefined): string | undefined { + if ( + value && + /^https:\/\/image\.tmdb\.org\/t\/p\/(?:original|[wh]\d+)\/[A-Za-z0-9_-]+\.(?:jpg|png|webp|svg)$/.test( + value, + ) + ) + return value; + return persistablePlaybackUrl(value); +} + +/** The source preference has the same URL policy as the observed session. */ +export function persistablePlaybackPreference(entry: PlaybackEntry): PlaybackEntry { + const textKeys = [ + "infoHash", + "addonId", + "title", + "parsedTitle", + "resolution", + "releaseGroup", + "source", + "bingeGroup", + ] as const; + const text = fields(entry, textKeys); + for (const key of textKeys) + if (text[key] != null && typeof text[key] !== "string") delete text[key]; + const numbers = fields(entry, ["fileIdx", "size", "savedAt"]); + for (const key of ["fileIdx", "size", "savedAt"] as const) + if (numbers[key] != null && typeof numbers[key] !== "number") delete numbers[key]; + return { + ...text, + ...numbers, + ...(Array.isArray(entry.audioLanguages) + ? { + audioLanguages: entry.audioLanguages.filter( + (value) => typeof value === "string" && safeString(value) !== undefined, + ), + } + : {}), + ...(Array.isArray(entry.cachedSlugs) + ? { + cachedSlugs: entry.cachedSlugs.filter( + (value) => typeof value === "string" && safeString(value) !== undefined, + ), + } + : {}), + ...(typeof entry.addonId === "string" + ? { addonId: persistablePlaybackIdentity(entry.addonId) } + : {}), + url: persistablePlaybackUrl(entry.url ?? undefined), + urlFingerprint: + typeof entry.url === "string" && entry.url && !persistablePlaybackUrl(entry.url) + ? playbackTransportFingerprint(entry.url) + : typeof entry.urlFingerprint === "string" && /^[a-f0-9]{64}$/.test(entry.urlFingerprint) + ? entry.urlFingerprint + : undefined, + }; +} + +/** Compare a redacted source with a live session without retaining its URL. */ +export function playbackUrlFingerprint( + src: Pick, +): string | undefined { + const url = src.historyUrl ?? src.url; + return !src.homeServer && !src.streamRef?.infoHash && url && !persistablePlaybackUrl(url) + ? playbackTransportFingerprint(url) + : undefined; +} + +/** Persist source identity, never a complete runtime PlayerSrc. */ +export function persistablePlaybackSource(src: PlayerSrc): Omit { + const origin = persistableAddonOrigin(src.meta.addonOrigin); + const privateIdentity = persistablePlaybackIdentity(src.meta.id); + const badge = src.meta.providerBadge + ? fields(src.meta.providerBadge, ["name", "tint"]) + : undefined; + const badgeLogo = artworkUrl(src.meta.providerBadge?.logo); + const opaque = privateIdentity !== src.meta.id || privateIdentity.startsWith("playback-private:"); + return { + ...fields(src, [ + "title", + "subtitle", + "imdbId", + "imdbIdVerified", + "notWebReady", + "isAnime", + "suppressCloudSync", + "episodeEnd", + ]), + meta: { + ...fields(src.meta, [ + "id", + "type", + "name", + "poster", + "background", + "logo", + "originalLanguage", + "country", + "malId", + "animeFormat", + "releaseInfo", + "releaseDate", + "inTheaters", + "imdbRating", + "adult", + "sourceRank", + "tmdbScore", + "pluginQuality", + "listingYear", + "runtime", + "genres", + "isCollection", + ]), + id: privateIdentity, + name: opaque ? "Direct stream" : (safeString(src.meta.name) ?? ""), + ...(src.meta.poster ? { poster: artworkUrl(src.meta.poster) } : {}), + ...(src.meta.background ? { background: artworkUrl(src.meta.background) } : {}), + ...(src.meta.logo ? { logo: artworkUrl(src.meta.logo) } : {}), + ...(origin + ? { addonOrigin: fields(origin, ["id", "name", "logo", "base", "configurationKey"]) } + : {}), + ...(badge && typeof badge.name === "string" && typeof badge.tint === "string" && badgeLogo + ? { + providerBadge: { + ...badge, + logo: badgeLogo, + }, + } + : {}), + ...(src.meta.listingExtras + ? { + listingExtras: fields(src.meta.listingExtras, [ + "rest", + "languages", + "quality", + "resolutions", + "hdr", + ]), + } + : {}), + ...(src.meta.behaviorHints + ? { behaviorHints: fields(src.meta.behaviorHints, ["defaultVideoId"]) } + : {}), + }, + url: src.homeServer ? "" : (persistablePlaybackUrl(src.historyUrl ?? src.url) ?? ""), + ...(opaque ? { title: "Direct stream", subtitle: undefined } : {}), + ...(src.episode + ? { + episode: { + ...fields(src.episode, [ + "season", + "episode", + "name", + "imdbId", + "imdbSeason", + "imdbEpisode", + "absoluteNumber", + "tvdbEpisodeId", + "kitsuStreamId", + "sourceMetaId", + "videoId", + "still", + "overview", + "rating", + "airDate", + "runtime", + ]), + ...(src.episode.still ? { still: artworkUrl(src.episode.still) } : {}), + }, + } + : {}), + ...(src.episodeSpan + ? { episodeSpan: fields(src.episodeSpan, ["season", "episode", "episodeEnd"]) } + : {}), + ...(src.streamRef + ? { + streamRef: { + ...fields(src.streamRef, [ + "resolvedFilename", + "infoHash", + "fileIdx", + "addonId", + "name", + "addonName", + "title", + "parsedTitle", + "resolution", + "quality", + "releaseGroup", + "source", + "audioLanguages", + "size", + "bingeGroup", + "cachedSlugs", + ]), + ...(src.streamRef.addonId + ? { addonId: persistablePlaybackIdentity(src.streamRef.addonId) } + : {}), + }, + } + : {}), + ...(src.homeServer + ? { homeServer: fields(src.homeServer, ["connectionId", "itemId", "versionId", "quality"]) } + : {}), + }; +} + +export function persistablePlaybackSubtitle( + choice: PlaybackSubtitleChoice | undefined, + src: PlayerSrc, +): PlaybackSubtitleChoice | undefined { + if (!choice) return undefined; + if (choice.off) return { off: true }; + const saved = fields(choice, [ + "lang", + "title", + "trackId", + "subId", + "provider", + "release", + "format", + "encoding", + "downloadAuthKind", + "providerDerived", + "localTrusted", + "matchScore", + "matchConfidence", + "streamKey", + "imported", + "external", + "codec", + "forced", + "hearingImpaired", + "fpsOverride", + "homeServerSubtitleId", + ]); + if (typeof choice.streamKey === "string") + saved.streamKey = persistablePlaybackIdentity(choice.streamKey); + const sourceFingerprint = + choice.source && !persistablePlaybackUrl(choice.source) + ? playbackTransportFingerprint(choice.source) + : typeof choice.sourceFingerprint === "string" && + /^[a-f0-9]{64}$/.test(choice.sourceFingerprint) + ? choice.sourceFingerprint + : undefined; + const retained = { ...saved, ...(sourceFingerprint ? { sourceFingerprint } : {}) }; + const serverSubtitle = + src.homeServer && choice.source + ? src.subtitles?.find((track) => track.url === choice.source) + : undefined; + const homeServerSubtitleId = saved.homeServerSubtitleId ?? serverSubtitle?.id; + if (src.homeServer) { + delete retained.trackId; + // A server track ID is stable only when supplied by its source catalogue. + if (homeServerSubtitleId) return { ...retained, homeServerSubtitleId }; + if (serverSubtitle) return retained; + } + const source = persistablePlaybackUrl(choice.source); + return source ? { ...retained, source } : retained; +} diff --git a/src/lib/player/subtitle-choice.ts b/src/lib/player/subtitle-choice.ts new file mode 100644 index 000000000..7905ade9e --- /dev/null +++ b/src/lib/player/subtitle-choice.ts @@ -0,0 +1,173 @@ +import type { TrackInfo } from "./bridge"; +import { + lookupSubtitleOrigin, + rememberedFromChoice, + type RememberedSub, +} from "../subtitles/subtitle-memory"; +import { normalizeLang } from "../subtitles/language"; +import { selectedSubtitleCacheName } from "../subtitles/selected-subtitle-cache"; +import { playbackTransportFingerprint } from "./playback-history-source"; + +export type PlaybackSubtitleChoice = Omit & { + external?: boolean; + codec?: string; + forced?: boolean; + hearingImpaired?: boolean; + fpsOverride?: number; + homeServerSubtitleId?: string; + sourceFingerprint?: string; +}; + +function durableSource(source?: string): string | undefined { + const original = source && (lookupSubtitleOrigin(source) ?? source); + return original && !/^(blob|data|memory):/i.test(original) ? original : undefined; +} + +function sameSource(source: string | undefined, saved: string): boolean { + return durableSource(source)?.replace(/\\/g, "/") === durableSource(saved)?.replace(/\\/g, "/"); +} + +type SubtitleCacheContext = { mediaKey: string; streamKey?: string }; + +function matchesCachedSubtitleOrigin( + choice: Pick, + source: string | undefined, + remembered?: RememberedSub | null, + context?: SubtitleCacheContext, +): boolean { + const file = remembered?.source?.split(/[\\/]/).pop() ?? ""; + const format = /^selected-[a-f0-9]{8}\.(srt|vtt|ass|ssa)$/i.exec(file)?.[1]?.toLowerCase() as + | "srt" + | "vtt" + | "ass" + | "ssa" + | undefined; + if ( + !context || + !remembered?.imported || + remembered.off || + !format || + !/^(?:[a-z]:[\\/]|[\\/]|file:\/\/)/i.test(remembered.source ?? "") || + remembered.streamKey !== context.streamKey || + remembered.subId !== choice.subId || + remembered.provider !== choice.provider + ) + return false; + + // An exact origin URL can verify the managed cache filename. Redacted + // histories instead prove the same origin using its SHA-256 fingerprint, + // captured when the remote subtitle was copied into the local cache. + if (source) + return ( + file === + selectedSubtitleCacheName(context.mediaKey, context.streamKey, { ...choice, source }, format) + ); + return ( + !!choice.sourceFingerprint && + /^[a-f0-9]{64}$/.test(choice.sourceFingerprint) && + remembered.sourceFingerprint === choice.sourceFingerprint + ); +} + +/** A late cache may finish after the actual session was flushed on exit. */ +export function restorablePlaybackSubtitleChoice( + saved: PlaybackSubtitleChoice, + remembered: RememberedSub | null, + context: SubtitleCacheContext, +): PlaybackSubtitleChoice { + if ( + !remembered || + saved.off || + !saved.external || + saved.streamKey !== context.streamKey || + !matchesCachedSubtitleOrigin(saved, durableSource(saved.source), remembered, context) + ) + return saved; + const { updatedAt: _cacheTime, ...cached } = remembered; + return { ...saved, ...cached, external: true, fpsOverride: saved.fpsOverride }; +} + +export function playbackSubtitleChoice( + track: TrackInfo, + remembered?: RememberedSub | null, + context?: SubtitleCacheContext, +): PlaybackSubtitleChoice { + const choice = rememberedFromChoice({ + ...track, + title: track.title ?? track.label, + source: + durableSource(track.originalUrl) ?? + durableSource(track.externalFilename) ?? + durableSource(track.url), + }); + // A prepared blob is never a persistent subtitle source, even when no origin is known. + choice.source = durableSource(choice.source); + const matchesCachedOrigin = matchesCachedSubtitleOrigin( + track, + choice.source, + remembered, + context, + ); + const sameRemembered = + remembered && + !remembered.off && + (matchesCachedOrigin || + (remembered.source && + [track.originalUrl, track.externalFilename, track.url].some((source) => + sameSource(source, remembered.source!), + )) || + (remembered.subId && + remembered.subId === track.subId && + remembered.provider === track.provider && + (!remembered.lang || normalizeLang(remembered.lang) === normalizeLang(track.lang ?? ""))) || + (!track.external && + remembered.trackId === track.id && + normalizeLang(remembered.lang ?? "") === normalizeLang(track.lang ?? "") && + remembered.title === (track.title ?? track.label))); + return { + ...(sameRemembered ? { ...remembered, updatedAt: undefined } : choice), + source: (sameRemembered ? durableSource(remembered?.source) : undefined) ?? choice.source, + external: track.external || undefined, + codec: track.codec, + forced: track.forced, + hearingImpaired: track.hearingImpaired, + }; +} + +export function selectPlaybackSubtitle( + tracks: TrackInfo[], + choice: PlaybackSubtitleChoice, +): TrackInfo | null { + if (choice.off) return null; + const norm = (value?: string) => (value ?? "").trim().toLowerCase(); + const matches = tracks.filter((track) => { + if (choice.external) { + return ( + !!track.external && + ((!!choice.source && + [track.originalUrl, track.url, track.externalFilename].some((source) => + sameSource(source, choice.source!), + )) || + (!choice.source && + !!choice.sourceFingerprint && + [track.originalUrl, track.url, track.externalFilename].some( + (source) => + playbackTransportFingerprint(durableSource(source)) === choice.sourceFingerprint, + )) || + (!!choice.subId && + choice.subId === track.subId && + choice.provider === track.provider && + (!choice.lang || normalizeLang(choice.lang) === normalizeLang(track.lang ?? "")))) + ); + } + return ( + !track.external && + normalizeLang(track.lang ?? "") === normalizeLang(choice.lang ?? "") && + norm(track.title ?? track.label) === norm(choice.title) && + (!choice.codec || norm(track.codec) === norm(choice.codec)) && + (choice.forced === undefined || !!track.forced === choice.forced) && + (choice.hearingImpaired === undefined || !!track.hearingImpaired === choice.hearingImpaired) + ); + }); + return matches.length === 1 ? matches[0] : null; +} diff --git a/src/lib/season-lock.ts b/src/lib/season-lock.ts index 175d54a56..585d8d20d 100644 --- a/src/lib/season-lock.ts +++ b/src/lib/season-lock.ts @@ -1,4 +1,9 @@ import type { PlaybackEntry } from "./playback-history"; +import { + persistablePlaybackIdentity, + persistablePlaybackKey, + persistablePlaybackPreference, +} from "./player/playback-history-source"; export type SeasonLockEntry = PlaybackEntry & { seriesWide?: boolean }; @@ -6,7 +11,18 @@ const STORAGE_KEY = "harbor.season-lock.v1"; const TTL_MS = 120 * 24 * 60 * 60 * 1000; const MAX_ENTRIES = 300; +function persistableLock(key: string, value: SeasonLockEntry): SeasonLockEntry { + return { + ...persistablePlaybackPreference(value), + ...(key.startsWith("playback-private:") + ? { title: "Direct stream", parsedTitle: undefined } + : {}), + ...(typeof value.seriesWide === "boolean" ? { seriesWide: value.seriesWide } : {}), + }; +} + function seasonKey(metaId: string, season?: number | null): string { + metaId = persistablePlaybackIdentity(metaId); return season != null ? `${metaId}|s${season}` : `${metaId}|all`; } @@ -17,11 +33,16 @@ function readAll(): Record { const parsed = JSON.parse(raw) as Record; const now = Date.now(); const fresh: Record = {}; + let scrubbed = false; for (const [k, v] of Object.entries(parsed)) { - if (!v?.savedAt) continue; - if (now - v.savedAt > TTL_MS) continue; - fresh[k] = v; + if (!v) continue; + const key = persistablePlaybackKey(k); + const safe = persistableLock(key, v); + scrubbed ||= key !== k || JSON.stringify(safe) !== JSON.stringify(v); + if (!v.savedAt || now - v.savedAt > TTL_MS) continue; + if (!fresh[key] || safe.savedAt >= fresh[key].savedAt) fresh[key] = safe; } + if (scrubbed) writeAll(fresh); return fresh; } catch { return {}; @@ -34,7 +55,17 @@ function writeAll(map: Record): void { const sorted = Object.entries(map).sort((a, b) => b[1].savedAt - a[1].savedAt); map = Object.fromEntries(sorted.slice(0, MAX_ENTRIES)); } - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify( + Object.fromEntries( + Object.entries(map).map(([key, value]) => [ + persistablePlaybackKey(key), + persistableLock(persistablePlaybackKey(key), value), + ]), + ), + ), + ); } catch {} } @@ -45,7 +76,7 @@ export function saveSeasonLock( seriesWide?: boolean, ): void { const all = readAll(); - all[seriesWide ? `${metaId}|all` : seasonKey(metaId, season)] = { + all[seasonKey(metaId, seriesWide ? null : season)] = { ...entry, seriesWide, savedAt: Date.now(), @@ -54,12 +85,14 @@ export function saveSeasonLock( } export function readSeasonLock(metaId: string, season?: number | null): SeasonLockEntry | null { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); const perSeason = season != null ? all[`${metaId}|s${season}`] : null; return perSeason ?? all[`${metaId}|all`] ?? null; } export function clearSeasonLock(metaId: string, season?: number | null): void { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); if (season != null) delete all[`${metaId}|s${season}`]; delete all[`${metaId}|all`]; diff --git a/src/lib/subtitles/selected-subtitle-cache.ts b/src/lib/subtitles/selected-subtitle-cache.ts index 7f8b24e12..4a35ad511 100644 --- a/src/lib/subtitles/selected-subtitle-cache.ts +++ b/src/lib/subtitles/selected-subtitle-cache.ts @@ -1,6 +1,7 @@ import type { SubChoiceInput } from "./subtitle-memory"; import type { SubCue } from "./parser"; import { toSrt, toVtt } from "./serialize"; +import { playbackTransportFingerprint } from "../player/playback-history-source"; const MAX_SELECTED_SUBTITLE_CHARS = 8 * 1024 * 1024; @@ -60,6 +61,17 @@ export function selectedSubtitleCacheName( return `selected-${stableHash(identity)}.${format}`; } +/** An exact, non-reversible identity links a redacted history selection to its cached bytes. */ +export function subtitleCacheOriginFingerprint(choice: SubChoiceInput): string | undefined { + if ( + typeof choice.sourceFingerprint === "string" && + /^[a-f0-9]{64}$/.test(choice.sourceFingerprint) + ) + return choice.sourceFingerprint; + const origin = choice.source ?? choice.url ?? choice.originalUrl; + return origin && /^https?:\/\//i.test(origin) ? playbackTransportFingerprint(origin) : undefined; +} + export async function cacheSelectedSubtitle(input: CacheInput): Promise { if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return null; const readablePath = localPath(input.playableUrl) ?? localPath(input.choice.source); @@ -102,6 +114,9 @@ export async function cacheSelectedSubtitle(input: CacheInput): Promise; @@ -89,7 +92,7 @@ export function subtitleStreamKey( } export function rememberedSubAppliesToStream( - remembered: RememberedSub | null | undefined, + remembered: Omit | null | undefined, streamRef: PlayerStreamRef | null | undefined, ): boolean { if (!remembered) return false; @@ -172,6 +175,11 @@ export function rememberedFromChoice(choice: SubChoiceInput): Omit | null | undefined, +): remembered is Omit & { source: string } { if (remembered?.imported !== true || !remembered.source) return false; return !/^(https?|blob|data):/i.test(remembered.source); } @@ -213,7 +221,7 @@ export function subtitleSourceIsLocal(source: string | null | undefined): boolea } export function rememberedSubtitleLoadMetadata( - remembered: RememberedSub, + remembered: Omit, downloadAuth?: SubtitleLoadMetadata["downloadAuth"], ): SubtitleLoadMetadata { return { @@ -227,8 +235,8 @@ export function rememberedSubtitleLoadMetadata( providerDerived: remembered.localTrusted === true ? false - : remembered.providerDerived ?? - (rememberedSubtitleIsLocal(remembered) ? false : Boolean(remembered.provider)), + : (remembered.providerDerived ?? + (rememberedSubtitleIsLocal(remembered) ? false : Boolean(remembered.provider))), subId: remembered.subId, matchScore: remembered.matchScore, matchConfidence: remembered.matchConfidence, diff --git a/src/views/group/post-item.tsx b/src/views/group/post-item.tsx index ae21c3de3..29efa94e6 100644 --- a/src/views/group/post-item.tsx +++ b/src/views/group/post-item.tsx @@ -142,7 +142,6 @@ export function PostItem({ id: `post:copy:${post.id}`, label: t("Copy Text"), icon: , - quickCopy: true, run: () => copyContextText(bodyRef.current?.innerText ?? post.body), }, ...(currentAuthor() diff --git a/src/views/manga/manga-reader/book-view.tsx b/src/views/manga/manga-reader/book-view.tsx index f1aef2b42..3cb3ae4ce 100644 --- a/src/views/manga/manga-reader/book-view.tsx +++ b/src/views/manga/manga-reader/book-view.tsx @@ -1,10 +1,6 @@ -import { Image } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useContextMenu, useContextTarget, type ContextMenuTarget } from "@/lib/context-menu"; -import { ContextImageViewer } from "@/components/context-image-viewer"; -import type { ContextImage } from "@/lib/context-image"; import { useT } from "@/lib/i18n"; -import { visibleReaderPages } from "./reader-context"; import { fitLevel, guardPageLoaded, @@ -183,30 +179,16 @@ export function BookFlip({ const wrapRef = useRef(null); const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); - const visiblePages = useRef>([]); - const [image, setImage] = useState(null); const t = useT(); const { open } = useContextMenu(); const contextTarget = (): ContextMenuTarget => ({ kind: "actions", id: `reader-pages:${instanceName}`, label: t("Visible pages"), - actions: () => - visiblePages.current.map((page) => ({ - id: `reader-page:${instanceName}:${page.number}`, - icon: , - label: t("View page {number}", { number: page.number }), - restoreFocus: false, - run: () => - setImage({ - src: page.src, - label: t("Page {number}", { number: page.number }), - filename: `page-${page.number}`, - }), - })), + // The reader remains a semantic boundary even without contextual commands. + actions: () => [], }); const contextRef = useContextTarget(contextTarget); - const contextOrigin = useRef(null); const applyTransform = (smooth: boolean) => { const el = wrapRef.current; @@ -266,7 +248,6 @@ export function BookFlip({ ensureStyle(); let cancelled = false; let inst: FlipInstance | null = null; - visiblePages.current = []; const origFetch = window.fetch; const origOnPopState = window.onpopstate; @@ -299,7 +280,6 @@ export function BookFlip({ const d = (e as CustomEvent).detail as { page?: string | number; name?: string } | undefined; if (!d || d.name !== instanceName) return; const spread = String(d.page); - visiblePages.current = visibleReaderPages(pages, spread); const first = Number(spread.split("-")[0]); if (!Number.isFinite(first) || first <= 0) return; report.current(first - 1, spread); @@ -484,10 +464,7 @@ export function BookFlip({ return (
{ - contextOrigin.current = node; - contextRef(node); - }} + ref={contextRef} tabIndex={0} aria-label={t("Visible pages")} className="relative h-full w-full overflow-hidden" @@ -503,13 +480,6 @@ export function BookFlip({ >
- {image && ( - setImage(null)} - /> - )}
); } diff --git a/src/views/manga/manga-reader/reader-context.ts b/src/views/manga/manga-reader/reader-context.ts deleted file mode 100644 index aa4a3e320..000000000 --- a/src/views/manga/manga-reader/reader-context.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function visibleReaderPages( - pages: string[], - spread: string, -): Array<{ number: number; src: string }> { - if (!/^\d+(?:-\d+)?$/.test(spread)) return []; - const numbers = [...new Set(spread.split("-").map(Number))]; - if ( - numbers.some((number) => number < 1 || number > pages.length) || - (numbers.length === 2 && Math.abs(numbers[0] - numbers[1]) !== 1) - ) - return []; - return numbers.map((number) => ({ number, src: pages[number - 1] })); -} diff --git a/src/views/player/hooks/use-actual-playback.ts b/src/views/player/hooks/use-actual-playback.ts index 5070cd675..3d7e33aa3 100644 --- a/src/views/player/hooks/use-actual-playback.ts +++ b/src/views/player/hooks/use-actual-playback.ts @@ -9,6 +9,7 @@ import { actualPlaybackSnapshot } from "@/lib/player/actual-playback"; import { getPlaybackPosition, subscribePlaybackClock } from "@/lib/player/playback-clock"; import type { PlayerSnapshot } from "@/lib/player/bridge"; import type { PlayerSrc } from "@/lib/view"; +import { readRememberedSub, subtitleMediaKey } from "@/lib/subtitles/subtitle-memory"; /** The player, not a menu or a metadata write, owns actual playback recency. */ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { @@ -22,9 +23,25 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { useEffect(() => { const session = { id: crypto.randomUUID(), actor: capturePlaybackActor() }; let last: ActualPlayback | null = null; + let observedPlaying = false; let lastSavedAt = 0; let previousPosition = getPlaybackPosition(); + const rememberedSubtitle = () => + readRememberedSub(subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode)); const save = () => { + const actor = capturePlaybackActor(); + if ( + last && + !!actor.privateCw !== !!session.actor.privateCw && + actor.profileId === session.actor.profileId && + actor.storageProfileId === session.actor.storageProfileId && + actor.accountId === session.actor.accountId && + actor.provider === session.actor.provider + ) { + // A privacy preference does not create another play or transfer its owner. + session.actor = actor; + last = { ...last, actor }; + } if (last && recordActualPlayback(last)) lastSavedAt = Date.now(); }; const saveCurrent = () => { @@ -37,6 +54,7 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { last.positionMs / 1000, session, last.playedAt, + rememberedSubtitle(), ) ?? last; } save(); @@ -45,31 +63,47 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { const observe = () => { const current = latest.current; if (current.key !== key || !loaded.current.armed) return; + if (current.snap.status === "playing" && current.snap.firstFrameReady) observedPlaying = true; + if (!observedPlaying) return; const position = getPlaybackPosition(); // Buffer/cache updates share the clock subscription. They are not plays. if (position === previousPosition) return; previousPosition = position; - const next = actualPlaybackSnapshot(current.src, current.snap, position, session); + const next = actualPlaybackSnapshot( + current.src, + current.snap, + position, + session, + Date.now(), + rememberedSubtitle(), + ); if (!next) return; last = next; if (!lastSavedAt || Date.now() - lastSavedAt >= 4000) save(); }; const unsubscribe = subscribePlaybackClock(observe); const onVisibility = () => { - if (document.visibilityState === "hidden") save(); + if (document.visibilityState === "hidden") saveCurrent(); }; - window.addEventListener("pagehide", save); - window.addEventListener("beforeunload", save); + window.addEventListener("pagehide", saveCurrent); + window.addEventListener("beforeunload", saveCurrent); document.addEventListener("visibilitychange", onVisibility); return () => { unsubscribe(); - window.removeEventListener("pagehide", save); - window.removeEventListener("beforeunload", save); + window.removeEventListener("pagehide", saveCurrent); + window.removeEventListener("beforeunload", saveCurrent); document.removeEventListener("visibilitychange", onVisibility); saveCurrent(); }; }, [key]); useEffect(() => { if (snap.status !== "playing") flush.current(); - }, [snap.status]); + }, [ + snap.status, + snap.subtitleTracks, + snap.audioTracks, + snap.subDelaySec, + snap.audioDelaySec, + snap.subtitleFps, + ]); } diff --git a/src/views/player/hooks/use-auto-retry.ts b/src/views/player/hooks/use-auto-retry.ts index 6b66d2f0a..7ef862095 100644 --- a/src/views/player/hooks/use-auto-retry.ts +++ b/src/views/player/hooks/use-auto-retry.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, + type SetStateAction, +} from "react"; import type { PlayerBridge, PlayerSnapshot } from "@/lib/player/bridge"; import { getPlaybackBuffered, @@ -13,7 +20,10 @@ import { registerStreamProxy } from "@/lib/stream-proxy"; import { buildTranscodedUrl, probeStremioServer } from "@/lib/stremio-server"; import type { DebridStore } from "@/lib/debrid/types"; import type { Meta } from "@/lib/cinemeta"; -import type { PlayerSrc, PlayEpisode } from "@/lib/view"; +import type { PlayerSrc, PlayEpisode, PickerOptions } from "@/lib/view"; +import { isPlaybackActorCurrent } from "@/lib/playback-history"; +import { continuationAppliesToSource } from "@/lib/player/continue-playback"; +import { playerLoadIdentity } from "@/lib/player/load-identity"; import { BLACK_SCREEN_GRACE_MS, MAX_AUTORETRY_ATTEMPTS, @@ -24,11 +34,7 @@ import { } from "../player-utils"; import { GENUINE_FAILURE_WINDOW_MS, type EngineStats } from "@/lib/torrent/engine-stats"; -type OpenPicker = ( - meta: Meta, - episode?: PlayEpisode, - opts?: { autoPlay?: boolean; attempt?: number }, -) => void; +type OpenPicker = (meta: Meta, episode?: PlayEpisode, opts?: PickerOptions) => void; export type SourceError = { status: number; host: string; exhausted?: boolean }; @@ -70,14 +76,18 @@ export function useAutoRetry(params: { } = params; const isLocal = isLocalUrl(src.url); const isLive = src.meta.id.startsWith("iptv:"); + const openingKey = JSON.stringify([ + playerLoadIdentity(src, src.url, src.episode?.season, src.episode?.episode), + src.continuation?.id, + ]); const ENGINE_FIRST_FRAME_GRACE_MS = 20_000; const ENGINE_HARD_CEILING_MS = 75_000; const ENGINE_COMPLETE_RELOAD_MS = 8_000; const ENGINE_COMPLETE_FAILURE_MS = 20_000; const urlAtRef = useRef(0); - const urlSeenRef = useRef(null); - if (urlSeenRef.current !== src.url) { - urlSeenRef.current = src.url; + const currentOpeningRef = useRef(null); + if (currentOpeningRef.current !== openingKey) { + currentOpeningRef.current = openingKey; urlAtRef.current = Date.now(); } const snapRef = useRef(snap); @@ -113,7 +123,40 @@ export function useAutoRetry(params: { const truncatedReloadedRef = useRef(false); const resumedAfterTruncatedRef = useRef(false); const [transcodedUrl, setTranscodedUrl] = useState(null); - const [sourceError, setSourceError] = useState(null); + const [sourceFailure, setSourceFailure] = useState<{ + openingKey: string; + error: SourceError; + } | null>(null); + const sourceError = sourceFailure?.openingKey === openingKey ? sourceFailure.error : null; + const setSourceError = useCallback( + (update: SetStateAction) => { + if (currentOpeningRef.current !== openingKey) return; + setSourceFailure((previous) => { + if (currentOpeningRef.current !== openingKey) return previous; + const current = previous?.openingKey === openingKey ? previous.error : null; + const error = typeof update === "function" ? update(current) : update; + return error ? { openingKey, error } : null; + }); + }, + [openingKey], + ); + const directContinuation = + !!src.continuation && + !src.homeServer && + !src.proxySessionId && + !src.streamRef?.infoHash && + !isLocal && + !isLive && + !isP2pEngine && + !inRoom && + /^https?:\/\//i.test(src.url); + const continuationDecodedRef = useRef(false); + useEffect(() => { + continuationDecodedRef.current = false; + }, [openingKey]); + useEffect(() => { + if (snap.firstFrameReady) continuationDecodedRef.current = true; + }, [snap.firstFrameReady, snap.status]); // Latch "this source actually played" instead of reading the live clock in // the error handler: mpv resets time-pos while tearing a failed file down, so // the clock can already read zero when the error arrives and the old guard @@ -121,7 +164,7 @@ export function useAutoRetry(params: { const playedPastStartRef = useRef(false); useEffect(() => { playedPastStartRef.current = false; - }, [src.url]); + }, [openingKey]); useEffect(() => { if (snap.positionSec > 5 || getPlaybackPosition() > 5) playedPastStartRef.current = true; }, [snap.positionSec, snap.status]); @@ -141,7 +184,7 @@ export function useAutoRetry(params: { resumedAfterTruncatedRef.current = false; dlRef.current = { bytes: 0, at: Date.now() }; setTranscodedUrl(null); - }, [src.url]); + }, [openingKey, setSourceError]); useEffect(() => { if (isLive && hasProgress) livePlayedRef.current = true; @@ -173,8 +216,33 @@ export function useAutoRetry(params: { }, [isLive, snap.errorCode, src.url, src.subtitles, src.notWebReady, bridgeRef]); const triggerAutoRetry = useCallback( - (reason: string) => { + (reason: string, terminalFailure = false) => { if (autoRetriedRef.current) return; + const continuation = src.continuation; + if ( + continuation && + (!isPlaybackActorCurrent(continuation.actor) || + !continuationAppliesToSource(src, continuation)) + ) + return; + if ( + directContinuation && + continuation && + !continuationDecodedRef.current && + (snap.errorCode != null || terminalFailure) + ) { + autoRetriedRef.current = true; + clearOnePickerCache(src.meta, src.episode); + bridgeRef.current?.destroy(); + bridgeRef.current = null; + openPicker(src.meta, src.episode, { + autoPlay: false, + resume: true, + continuation, + queueItemId: src.queueItemId, + }); + return; + } if (isLocal) { console.warn(`[player] local file: skipping auto-retry (${reason})`); return; @@ -230,10 +298,20 @@ export function useAutoRetry(params: { src.url, src.subtitles, src.notWebReady, + src.continuation, + src.queueItemId, + directContinuation, + snap.errorCode, + setSourceError, bridgeRef, ], ); + useEffect(() => { + if (sourceError && directContinuation && !continuationDecodedRef.current) + triggerAutoRetry("saved source could not reopen", true); + }, [sourceError, directContinuation, triggerAutoRetry]); + useEffect(() => { if (snap.errorCode == null) return; if (snap.status === "ended") return; @@ -241,6 +319,16 @@ export function useAutoRetry(params: { console.warn(`[player] live channel: ignoring "${snap.errorCode}", mpv handles reconnection`); return; } + if ( + src.continuation && + (!isPlaybackActorCurrent(src.continuation.actor) || + !continuationAppliesToSource(src, src.continuation)) + ) + return; + if (directContinuation && !continuationDecodedRef.current) { + triggerAutoRetry("saved source failed before playback"); + return; + } if (playedPastStartRef.current || getPlaybackPosition() > 5) return; if ( isP2pEngine && @@ -252,7 +340,10 @@ export function useAutoRetry(params: { if (!recoveryBridge) return; const ac = new AbortController(); // A recovery belongs to the failed load, never to a later source or bridge. - const current = () => !ac.signal.aborted && bridgeRef.current === recoveryBridge; + const current = () => + !ac.signal.aborted && + bridgeRef.current === recoveryBridge && + (!src.continuation || isPlaybackActorCurrent(src.continuation.actor)); const retry = () => { if (current()) triggerAutoRetry(`playback error "${snap.errorCode}"`); }; @@ -391,6 +482,8 @@ export function useAutoRetry(params: { src.url, src.subtitles, src.notWebReady, + src.continuation, + directContinuation, bridgeRef, isP2pEngine, engineFailure, @@ -555,15 +648,19 @@ export function useAutoRetry(params: { const t = window.setTimeout( () => { const s = snapRef.current; - if (s.status === "playing" || s.status === "ended") return; - if (s.durationSec > 0 || getPlaybackPosition() > 0) return; + if (s.status === "ended") return; + if (directContinuation) { + if (continuationDecodedRef.current || s.firstFrameReady) return; + } else if (s.status === "playing" || s.durationSec > 0 || getPlaybackPosition() > 0) { + return; + } console.warn(`[player] source never started within ${NEVER_STARTED_CEILING_MS}ms`); setSourceError((prev) => prev ?? { status: 0, host: hostOf(src.url) }); }, Math.max(0, remaining), ); return () => window.clearTimeout(t); - }, [src.url, isLive, isLocal, inRoom, isP2pEngine]); + }, [openingKey, directContinuation, isLive, isLocal, inRoom, isP2pEngine, setSourceError]); useEffect(() => { if (startedPlaying) setSourceError(null); diff --git a/src/views/player/hooks/use-playback-controls.ts b/src/views/player/hooks/use-playback-controls.ts index 7d7dabdd8..259c595dc 100644 --- a/src/views/player/hooks/use-playback-controls.ts +++ b/src/views/player/hooks/use-playback-controls.ts @@ -14,6 +14,12 @@ import { hasImportedSubTitle } from "@/lib/player/imported-subs"; import type { RoomCommand } from "@/lib/together/protocol"; import { cacheSelectedSubtitle } from "@/lib/subtitles/selected-subtitle-cache"; import { notifyMediaSeeked } from "@/lib/media-session"; +import { + capturePlaybackActor, + isPlaybackActorCurrent, + readActualPlaybackForSubtitle, + cacheActualPlaybackSubtitle, +} from "@/lib/playback-history"; const SEEK_ACCUM_WINDOW_MS = 700; @@ -58,7 +64,12 @@ export function usePlaybackControls(params: { } = params; const pendingSubtitleCacheRef = useRef<(() => void) | null>(null); - const subtitleCacheContextRef = useRef({ mediaKey, subtitleStreamKey, revision: 0 }); + const subtitleCacheContextRef = useRef({ + mediaKey, + subtitleStreamKey, + revision: 0, + startedAt: Date.now(), + }); if ( subtitleCacheContextRef.current.mediaKey !== mediaKey || subtitleCacheContextRef.current.subtitleStreamKey !== subtitleStreamKey @@ -68,6 +79,7 @@ export function usePlaybackControls(params: { mediaKey, subtitleStreamKey, revision: subtitleCacheContextRef.current.revision + 1, + startedAt: Date.now(), }; } useEffect(() => () => pendingSubtitleCacheRef.current?.(), []); @@ -76,6 +88,7 @@ export function usePlaybackControls(params: { (choice: SubChoiceInput | null | undefined) => { pendingSubtitleCacheRef.current?.(); const revision = ++subtitleCacheContextRef.current.revision; + const actor = capturePlaybackActor(); if (choice) { writePlayerPrefs( metaId, @@ -127,6 +140,11 @@ export function usePlaybackControls(params: { // still be displaying the preceding selection when this arrives. const playableUrl = bridge.getSelectedTrackUrl(); const cues = bridge.getSelectedTrackCues(); + const actual = readActualPlaybackForSubtitle( + mediaKey, + subtitleStreamKey, + subtitleCacheContextRef.current.startedAt, + ); stopWaiting(); void cacheSelectedSubtitle({ mediaKey, @@ -137,11 +155,18 @@ export function usePlaybackControls(params: { }) .then((cached) => { // The copy may finish after the player was closed and reopened. - if (!cached || readRememberedSub(mediaKey) !== remembered) return; - writeRememberedSub( + if ( + !cached || + !isPlaybackActorCurrent(actor) || + readRememberedSub(mediaKey) !== remembered + ) + return; + const saved = writeRememberedSub( mediaKey, rememberedFromChoice({ ...cached, streamKey: subtitleStreamKey }), ); + if (actual && saved) + cacheActualPlaybackSubtitle(actual, remembered, { ...saved, external: true }); }) .catch(() => {}); }); diff --git a/src/views/player/hooks/use-player-bridge.ts b/src/views/player/hooks/use-player-bridge.ts index 715a1e22f..6efab9e3d 100644 --- a/src/views/player/hooks/use-player-bridge.ts +++ b/src/views/player/hooks/use-player-bridge.ts @@ -37,6 +37,7 @@ function snapChangedIgnoringClock(a: PlayerSnapshot, b: PlayerSnapshot): boolean a.chapters !== b.chapters || a.subDelaySec !== b.subDelaySec || a.audioDelaySec !== b.audioDelaySec || + a.subtitleFps !== b.subtitleFps || a.subText !== b.subText || a.subStartSec !== b.subStartSec || a.secondarySubText !== b.secondarySubText || diff --git a/src/views/player/hooks/use-track-autoload.ts b/src/views/player/hooks/use-track-autoload.ts index ead3ba747..820d5e322 100644 --- a/src/views/player/hooks/use-track-autoload.ts +++ b/src/views/player/hooks/use-track-autoload.ts @@ -7,8 +7,21 @@ import { subtitleStreamDescriptor } from "@/lib/subtitles/provider-label"; import { publishSubtitleSearch } from "@/components/player/subtitle-menu/subtitle-search-store"; import { publishSubtitleContext } from "@/components/player/subtitle-menu/subtitle-context-store"; import { readPlayerPrefs, type PerShowPrefs } from "@/lib/player-prefs"; -import { playbackSourceKey, readActualPlaybackFor } from "@/lib/playback-history"; +import { + capturePlaybackActor, + isPlaybackActorCurrent, + playbackSourceKey, + readActualPlaybackFor, +} from "@/lib/playback-history"; import { playbackAudioChoice, selectPlaybackAudio } from "@/lib/player/audio-choice"; +import { continuationAppliesToSource } from "@/lib/player/continue-playback"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "@/lib/player/subtitle-choice"; +import { emitListToast } from "@/components/lists/list-toast"; +import { t } from "@/lib/i18n"; +import { getMpvSubtitleFpsGeneration, writeMpvSubtitleFps } from "@/lib/player/mpv-properties"; import { tmdbImdbId } from "@/lib/providers/tmdb"; import type { Addon } from "@/lib/addons"; import type { GeneratedSubtitleGroup } from "@/lib/subtitles/types"; @@ -38,6 +51,7 @@ import { rememberedSubtitleLoadMetadata, rememberedSubtitleIsLocal, subtitleMediaKey, + subtitleStreamKey, } from "@/lib/subtitles/subtitle-memory"; export function useTrackAutoload(params: { @@ -49,14 +63,28 @@ export function useTrackAutoload(params: { authKey: string | null; }) { const { bridgeRef, src, snap, engine, settings, authKey } = params; + const playbackActor = capturePlaybackActor(); + const actorKey = JSON.stringify(playbackActor); const sourceKey = playbackSourceKey(src); - const rememberedAudio = useMemo( - () => - src.continuation?.sourceKey === sourceKey - ? src.continuation.audioTrack - : readActualPlaybackFor(src)?.audioTrack, - [sourceKey, src.meta.id, src.episode?.season, src.episode?.episode, src.continuation], - ); + const rememberedSession = useMemo(() => { + const session = + src.continuation && continuationAppliesToSource(src, src.continuation) + ? isPlaybackActorCurrent(src.continuation.actor) + ? src.continuation + : null + : readActualPlaybackFor(src); + // A new picker choice replaces recalled subtitle intent, while audio and timing remain valid. + return src.subtitlePreselect && session ? { ...session, subtitle: undefined } : session; + }, [ + sourceKey, + src.meta.id, + src.episode?.season, + src.episode?.episode, + src.continuation, + src.subtitlePreselect, + actorKey, + ]); + const rememberedAudio = rememberedSession?.audioTrack; const snapRef = useRef(snap); snapRef.current = snap; const selectedSubtitleId = snap.subtitleTracks.find((track) => track.selected)?.id ?? null; @@ -262,7 +290,9 @@ export function useTrackAutoload(params: { autoSubStagesRef.current.add(stageKey); const runLease = autoSubRunRef.current.begin(key); const isActive = () => - autoSubLoadKeyRef.current === key && autoSubRunRef.current.isCurrent(runLease, key); + isPlaybackActorCurrent(playbackActor) && + autoSubLoadKeyRef.current === key && + autoSubRunRef.current.isCurrent(runLease, key); setInitialPreflight({ mediaUrl: src.url, settled: false }); void (async () => { const coords = await resolveAnimeSearchCoords({ @@ -369,6 +399,7 @@ export function useTrackAutoload(params: { isActive() && !keepEmbedded && !src.subtitlePreselect && + !rememberedSession?.subtitle && !subsOffFor(prefs, settings) && !rememberedSubAppliesToStream( readRememberedSub( @@ -499,7 +530,7 @@ export function useTrackAutoload(params: { const preselectAppliedRef = useRef(null); useEffect(() => { const choice = src.subtitlePreselect; - if (!choice) return; + if (!choice || rememberedSession?.subtitle) return; if (snap.audioTracks.length === 0 && snap.subtitleTracks.length === 0 && snap.durationSec === 0) return; if (preselectAppliedRef.current === src.url) return; @@ -523,6 +554,7 @@ export function useTrackAutoload(params: { snap.subtitleTracks, snap.durationSec, bridgeRef, + rememberedSession, ]); const subRestoreWaitRef = useRef<{ key: string; startedAt: number } | null>(null); const subRestoreLogRef = useRef(null); @@ -551,16 +583,26 @@ export function useTrackAutoload(params: { subRestoreTimerRef.current = null; } }; - }, [src.url]); + }, [src.url, actorKey]); useEffect(() => { - if (src.subtitlePreselect) return; - const remembered = readRememberedSub( - subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode), - ); + if (!isPlaybackActorCurrent(playbackActor)) return; + if (src.continuation && !isPlaybackActorCurrent(src.continuation.actor)) return; + if (src.subtitlePreselect && !rememberedSession?.subtitle) return; + const mediaKey = subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode); + const memory = readRememberedSub(mediaKey); + const remembered = rememberedSession?.subtitle + ? restorablePlaybackSubtitleChoice(rememberedSession.subtitle, memory, { + mediaKey, + streamKey: subtitleStreamKey(src.streamRef), + }) + : memory; if (!remembered) return; if (!rememberedSubAppliesToStream(remembered, src.streamRef)) return; const mediaReady = - snap.audioTracks.length > 0 || snap.subtitleTracks.length > 0 || snap.durationSec > 0; + snap.firstFrameReady || + snap.audioTracks.length > 0 || + snap.subtitleTracks.length > 0 || + snap.durationSec > 0; if (!mediaReady) return; const bridge = bridgeRef.current; if (!bridge) return; @@ -574,6 +616,18 @@ export function useTrackAutoload(params: { return; } + if (rememberedSession?.subtitle?.external && !remembered.source) { + const track = selectPlaybackSubtitle(snap.subtitleTracks, rememberedSession.subtitle); + if (track) { + if (!track.selected) bridge.setSubtitleTrack(track.id, "restore"); + autoSubIdRef.current = track.id; + } else if (snap.firstFrameReady && subRestoreLogRef.current !== "unavailable-subtitle") { + subRestoreLogRef.current = "unavailable-subtitle"; + emitListToast(t("The saved subtitle could not be restored. Choose a subtitle."), "error"); + } + return; + } + if (remembered.source) { const source = remembered.source; const restoreKey = [ @@ -705,7 +759,12 @@ export function useTrackAutoload(params: { remembered.downloadAuthKind, rememberedApiKey, ); - if (subRestoreAddRef.current?.key !== restoreKey) return false; + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return false; return bridge.addSubtitle( source, remembered.lang, @@ -714,37 +773,74 @@ export function useTrackAutoload(params: { rememberedSubtitleLoadMetadata(remembered, downloadAuth), "restore", ); - })().then((ok) => { - if (subRestoreAddRef.current?.key !== restoreKey) return; - subRestoreAddRef.current = { key: restoreKey, pending: false }; - if (!ok) return; - if (remembered.imported && remembered.title) markImportedSub(remembered.title); - else markAddedSub(source); - setSubRestoreTick((tick) => tick + 1); - }); + })() + .then((ok) => { + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return; + subRestoreAddRef.current = { key: restoreKey, pending: false }; + if (!ok) { + if (rememberedSession?.subtitle) + emitListToast( + t("The saved subtitle could not be restored. Choose a subtitle."), + "error", + ); + return; + } + if (remembered.imported && remembered.title) markImportedSub(remembered.title); + else markAddedSub(source); + setSubRestoreTick((tick) => tick + 1); + }) + .catch(() => { + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return; + subRestoreAddRef.current = { key: restoreKey, pending: false }; + if (rememberedSession?.subtitle) + emitListToast( + t("The saved subtitle could not be restored. Choose a subtitle."), + "error", + ); + }); return; } - if (snap.subtitleTracks.length === 0) return; + if (snap.subtitleTracks.length === 0 && !snap.firstFrameReady) return; if (embeddedRestoreRef.current === src.url) return; - embeddedRestoreRef.current = src.url; const byTrackId = remembered.trackId ? snap.subtitleTracks.find( (t) => !t.external && t.id === remembered.trackId && sameLang(t.lang, remembered.lang), ) : undefined; - const want = - byTrackId ?? - snap.subtitleTracks.find( - (t) => - !t.external && - sameLang(t.lang, remembered.lang) && - (!remembered.title || t.title === remembered.title), - ) ?? - snap.subtitleTracks.find((t) => !t.external && sameLang(t.lang, remembered.lang)); + const want = rememberedSession?.subtitle + ? selectPlaybackSubtitle(snap.subtitleTracks, rememberedSession.subtitle) + : (byTrackId ?? + snap.subtitleTracks.find( + (t) => + !t.external && + sameLang(t.lang, remembered.lang) && + (!remembered.title || t.title === remembered.title), + ) ?? + snap.subtitleTracks.find((t) => !t.external && sameLang(t.lang, remembered.lang))); if (want) { + embeddedRestoreRef.current = src.url; if (!want.selected) bridge.setSubtitleTrack(want.id, "restore"); autoSubIdRef.current = want.id; + } else if ( + rememberedSession?.subtitle && + snap.firstFrameReady && + subRestoreLogRef.current !== "unavailable-embedded-subtitle" + ) { + subRestoreLogRef.current = "unavailable-embedded-subtitle"; + if (snap.subtitleTracks.some((track) => track.selected)) + bridge.setSubtitleTrack(null, "restore"); + emitListToast(t("The saved subtitle could not be restored. Choose a subtitle."), "error"); } }, [ src.url, @@ -756,8 +852,11 @@ export function useTrackAutoload(params: { snap.durationSec, bridgeRef, subRestoreTick, + snap.firstFrameReady, settings.subsourceApiKey, settings.subdlApiKey, + rememberedSession, + actorKey, ]); useEffect(() => { if (engine !== "mpv") return; @@ -835,7 +934,9 @@ export function useTrackAutoload(params: { } } } - const subsOff = subsOffFor(prefs, settings); + const subsOff = rememberedSession?.subtitle + ? rememberedSession.subtitle.off === true + : subsOffFor(prefs, settings); if (subsOff) { if (snap.subtitleTracks.some((t) => t.selected)) bridgeRef.current?.setSubtitleTrack(null, "automatic"); @@ -846,6 +947,7 @@ export function useTrackAutoload(params: { src.streamRef, ) && !src.subtitlePreselect && + !rememberedSession?.subtitle && snap.subtitleTracks.length > 0 && subLangs.length > 0 ) { @@ -894,17 +996,115 @@ export function useTrackAutoload(params: { snap.rate, settings, rememberedAudio, + rememberedSession, + ]); + + const audioRestoreWarningRef = useRef(null); + useEffect(() => { + if (!rememberedAudio || !snap.firstFrameReady) return; + const current = snap.audioTracks.find((track) => track.selected); + if (current && autoAudioIdRef.current && current.id !== autoAudioIdRef.current) return; + if (selectPlaybackAudio(snap.audioTracks, rememberedAudio)) return; + if (audioRestoreWarningRef.current === src.url) return; + audioRestoreWarningRef.current = src.url; + emitListToast( + t("The saved audio track could not be restored. Choose an audio track."), + "error", + ); + }, [src.url, snap.firstFrameReady, snap.audioTracks, rememberedAudio]); + + const delaysAppliedRef = useRef<{ url: string; bridge: PlayerBridge } | null>(null); + useEffect(() => { + if (snap.status === "idle" || snap.status === "loading") { + delaysAppliedRef.current = null; + return; + } + if (src.continuation && !isPlaybackActorCurrent(src.continuation.actor)) return; + if (snap.durationSec === 0 && snap.audioTracks.length === 0 && snap.subtitleTracks.length === 0) + return; + const bridge = bridgeRef.current; + if ( + !bridge || + (delaysAppliedRef.current?.url === src.url && delaysAppliedRef.current.bridge === bridge) + ) + return; + delaysAppliedRef.current = { url: src.url, bridge }; + bridge.setSubDelay( + rememberedSession?.subDelaySec ?? readPlayerPrefs(src.meta.id)?.subDelaySec ?? 0, + ); + if (rememberedSession?.audioDelaySec != null) + bridge.setAudioDelay(rememberedSession.audioDelaySec); + }, [ + src.url, + src.meta.id, + src.continuation, + snap.status, + snap.durationSec, + snap.audioTracks.length, + snap.subtitleTracks.length, + rememberedSession, + bridgeRef, ]); + const subtitleFpsAppliedRef = useRef(null); useEffect(() => { - bridgeRef.current?.setSubDelay(readPlayerPrefs(src.meta.id)?.subDelaySec ?? 0); - }, [src.url, src.meta.id]); + if (snap.status === "loading" || snap.status === "idle") { + subtitleFpsAppliedRef.current = null; + return; + } + const saved = rememberedSession?.subtitle; + const mediaKey = subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode); + const choice = + saved && + restorablePlaybackSubtitleChoice(saved, readRememberedSub(mediaKey), { + mediaKey, + streamKey: subtitleStreamKey(src.streamRef), + }); + if (engine !== "mpv" || choice?.fpsOverride == null || choice.off) return; + if ( + !Number.isFinite(choice.fpsOverride) || + (choice.fpsOverride !== 0 && (choice.fpsOverride < 1 || choice.fpsOverride > 240)) + ) + return; + const selected = snap.subtitleTracks.find((track) => track.selected); + if (!selected || selectPlaybackSubtitle(snap.subtitleTracks, choice)?.id !== selected.id) + return; + const bridge = bridgeRef.current; + if (!bridge || bridge.canAutoSelectSubtitle?.() === false) return; + const key = `${src.url}|${selected.id}|${choice.fpsOverride}`; + if (subtitleFpsAppliedRef.current === key) return; + subtitleFpsAppliedRef.current = key; + const isCurrent = () => { + const currentSelected = snapRef.current.subtitleTracks.find((track) => track.selected); + return ( + !!currentSelected && + isPlaybackActorCurrent(playbackActor) && + bridgeRef.current === bridge && + bridge.canAutoSelectSubtitle?.() !== false && + selectPlaybackSubtitle(snapRef.current.subtitleTracks, choice)?.id === currentSelected.id + ); + }; + if (!isCurrent()) return; + void writeMpvSubtitleFps( + choice.fpsOverride === 0 ? "default" : choice.fpsOverride, + getMpvSubtitleFpsGeneration(), + isCurrent, + ).catch(() => { + if (isCurrent()) + emitListToast(t("The saved subtitle timing could not be restored."), "error"); + }); + }, [engine, src.url, snap.status, snap.subtitleTracks, rememberedSession, actorKey, bridgeRef]); useEffect(() => { - if (!subsOffFor(readPlayerPrefs(src.meta.id), settings)) return; + if ( + !(rememberedSession?.subtitle + ? rememberedSession.subtitle.off === true + : subsOffFor(readPlayerPrefs(src.meta.id), settings)) + ) + return; const selected = snap.subtitleTracks.find((t) => t.selected); if (selected) bridgeRef.current?.setSubtitleTrack(null, "automatic"); - }, [src.meta.id, snap.subtitleTracks, settings]); + }, [src.meta.id, snap.subtitleTracks, settings, rememberedSession]); return { resolvedImdbId, diff --git a/src/views/profile/comment-item.tsx b/src/views/profile/comment-item.tsx index de5f1cfec..0000cc39c 100644 --- a/src/views/profile/comment-item.tsx +++ b/src/views/profile/comment-item.tsx @@ -148,7 +148,6 @@ export function CommentItem({ id: "comment:copy", label: t("Copy Text"), icon: , - quickCopy: true, run: () => copyContextText(text.trim()), }, ...(signedIn && onToggleLike diff --git a/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx b/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx index dec582d01..86b3a83a4 100644 --- a/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx +++ b/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx @@ -88,7 +88,6 @@ export function CommentItem({ id: `theme-comment:copy:${comment.id}`, label: t("Copy Text"), icon: , - quickCopy: true, run: async () => { if (!(await copyText(comment.body))) throw new Error(t("Could not copy comment text.")); }, diff --git a/tests/actual-playback-recorder.test.ts b/tests/actual-playback-recorder.test.ts index 6d303b4f7..5bdb8904b 100644 --- a/tests/actual-playback-recorder.test.ts +++ b/tests/actual-playback-recorder.test.ts @@ -7,6 +7,7 @@ import * as React from "react"; import { createRoot } from "react-dom/client"; import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import * as history from "../src/lib/playback-history"; test("actual recorder follows decoded clock activity, ignores buffer updates and stale source frames", async () => { const dom = new JSDOM("
"); @@ -29,6 +30,10 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and }, }, "@/lib/player/actual-playback": { actualPlaybackSnapshot }, + "@/lib/subtitles/subtitle-memory": { + readRememberedSub: () => null, + subtitleMediaKey: () => "fixture", + }, "@/lib/player/playback-clock": { getPlaybackPosition: () => position, subscribePlaybackClock: (fn: () => void) => { @@ -78,11 +83,14 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and await render(source("a"), initialPlayerSnapshot()); await tick(0); assert.equal(writes.length, 0); - await render(source("a"), ready); + await render(source("a"), { ...ready, status: "paused" }); await tick(2); + assert.equal(writes.length, 0, "opening and pre-seeking a paused source is not an actual play"); + await render(source("a"), ready); + await tick(3); assert.equal(writes.at(-1)?.src.title, "a"); const count = writes.length; - await tick(2); + await tick(3); assert.equal(writes.length, count); await render(source("b"), ready); // previous source's last rendered snapshot await tick(3); @@ -93,6 +101,149 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and await tick(1); assert.equal(writes.at(-1)?.src.title, "b"); assert.equal(writes.at(-1)?.positionMs, 1000); + await render(source("b"), { ...ready, status: "paused", audioDelaySec: 0.1 }); + await render(source("b"), { + ...ready, + status: "paused", + audioDelaySec: 0.7, + subDelaySec: -1.5, + }); + await React.act(async () => window.dispatchEvent(new dom.window.Event("beforeunload"))); + assert.equal(writes.at(-1)?.audioDelaySec, 0.7, "refresh captures late paused settings"); + assert.equal(writes.at(-1)?.subDelaySec, -1.5); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); + +test("a live recorder survives a same-owner CW privacy change and ignores obsolete hide flags", async () => { + const dom = new JSDOM("
"); + const values = new Map(); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + const profile = (activeId: string) => + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId, + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); + profile("one"); + values.set("harbor.auth.one", JSON.stringify({ user: { _id: "fixture-account" } })); + let position = 0; + const clock = new Set<() => void>(); + const dependencies: Record = { + react: React, + "@/lib/playback-history": history, + "@/lib/player/actual-playback": { actualPlaybackSnapshot }, + "@/lib/subtitles/subtitle-memory": { + readRememberedSub: () => null, + subtitleMediaKey: () => "fixture", + }, + "@/lib/player/playback-clock": { + getPlaybackPosition: () => position, + subscribePlaybackClock: (fn: () => void) => { + clock.add(fn); + return () => clock.delete(fn); + }, + }, + }; + const code = ts.transpileModule( + readFileSync( + new URL("../src/views/player/hooks/use-actual-playback.ts", import.meta.url), + "utf8", + ), + { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }, + ).outputText; + const exports: any = {}; + new Function("require", "exports", code)((id: string) => { + assert.ok(id in dependencies, id); + return dependencies[id]; + }, exports); + const src = { + meta: { id: "local:fixture", name: "Fixture", type: "movie" }, + url: "C:/fixture/movie.mp4", + }; + const ready = { + ...initialPlayerSnapshot(), + status: "playing", + firstFrameReady: true, + durationSec: 60, + }; + function Fixture({ snap }: { snap: unknown }) { + exports.useActualPlayback(src, snap); + return null; + } + const root = createRoot(document.querySelector("#app")!); + const render = (snap: unknown) => + React.act(async () => root.render(React.createElement(Fixture, { snap }))); + const tick = (value: number) => + React.act(async () => { + position = value; + clock.forEach((listener) => listener()); + }); + try { + await render(initialPlayerSnapshot()); + await render(ready); + await tick(3); + const first = history.readLastActualPlayback()!; + assert.equal(first.positionMs, 3000); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + await tick(5); + await render({ ...ready, status: "paused", audioDelaySec: 0.7 }); + const continued = history.readLastActualPlayback()!; + assert.equal(continued.id, first.id); + assert.equal(continued.positionMs, 5000); + assert.equal(continued.audioDelaySec, 0.7); + const key = "harbor.playback-history.v1.one"; + const stored = JSON.parse(values.get(key)!); + stored["local:fixture"].dismissedActualId = continued.id; + stored["local:fixture"].actualByProfile.one.dismissedActualId = continued.id; + values.set(key, JSON.stringify(stored)); + await render({ ...ready, status: "paused", audioDelaySec: 0.7, subDelaySec: 1.2 }); + assert.equal(history.readLastActualPlayback()?.id, continued.id); + assert.equal(history.readLastActualPlayback()?.positionMs, 5000); + assert.equal(history.readActualPlaybackFor(src)?.subDelaySec, 1.2); + profile("two"); + await render(ready); + await tick(8); + await render({ ...ready, status: "paused" }); + assert.equal( + history.readLastActualPlayback(), + null, + "the old recorder cannot claim the sibling profile", + ); + profile("one"); + assert.equal(history.readActualPlaybackFor(src)?.positionMs, 5000); + values.set("harbor.account-provider", "nuvio"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 1, session: { user: { id: "fixture-nuvio" } } }), + ); + await render({ ...ready, status: "paused", subDelaySec: 4 }); + assert.equal( + history.readLastActualPlayback(), + null, + "the old recorder cannot claim another provider", + ); } finally { await React.act(async () => root.unmount()); dom.window.close(); diff --git a/tests/actual-playback-snapshot.test.ts b/tests/actual-playback-snapshot.test.ts index 9e96a65fd..6818537b2 100644 --- a/tests/actual-playback-snapshot.test.ts +++ b/tests/actual-playback-snapshot.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; import { initialPlayerSnapshot } from "../src/lib/player/bridge"; import type { PlayerSrc } from "../src/lib/view"; +import { selectedSubtitleCacheName } from "../src/lib/subtitles/selected-subtitle-cache"; const src: PlayerSrc = { meta: { id: "local:short", type: "movie", name: "Short video" }, @@ -40,7 +41,8 @@ test("a proxied source retains original identity but cannot replay a stale proxy session, 100, ); - assert.equal(result?.src.url, "https://synthetic.invalid/video"); + assert.equal(result?.src.url, ""); + assert.ok(result?.sourceFingerprint, "retain exact identity without the network transport"); assert.equal(result?.requiresSourceRefresh, true); assert.equal(result?.src.proxySessionId, undefined); assert.equal(result?.src.playbackTraceId, undefined); @@ -72,3 +74,129 @@ test("near-end progress is resumable until natural EOF, independent of watched t "a truncated stream EOF is not a completed playback", ); }); + +test("an observed session includes exact subtitle intent and both delays without temporary blob references", () => { + const playing = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, + subDelaySec: 1.25, + audioDelaySec: -0.2, + subtitleTracks: [ + { + id: "3", + kind: "subtitle" as const, + label: "Arabic", + lang: "ara", + selected: true, + external: true, + url: "blob:temporary", + originalUrl: "C:/fixture/subtitle.srt", + localTrusted: true, + }, + ], + }; + const result = actualPlaybackSnapshot(src, playing, 30, session, 100); + assert.equal(result?.subtitle?.source, "C:/fixture/subtitle.srt"); + assert.equal(JSON.stringify(result?.subtitle).includes("blob:"), false); + assert.equal(result?.subDelaySec, 1.25); + assert.equal(result?.audioDelaySec, -0.2); + const off = actualPlaybackSnapshot(src, { ...playing, subtitleTracks: [] }, 30, session, 101); + assert.deepEqual(off?.subtitle, { off: true }); +}); + +test("an old initial subtitle override cannot replace the user's observed selection after restart", () => { + const result = actualPlaybackSnapshot( + { ...src, subtitlePreselect: { url: "blob:old-choice", lang: "eng" } }, + { ...initialPlayerSnapshot(), status: "playing", firstFrameReady: true, durationSec: 400 }, + 20, + session, + ); + assert.equal(result?.src.subtitlePreselect, undefined); + assert.deepEqual(result?.subtitle, { off: true }); +}); + +test("EOF teardown cannot overwrite the remembered track delays with cleared native defaults", () => { + const ended = actualPlaybackSnapshot( + src, + { ...initialPlayerSnapshot(), status: "ended", firstFrameReady: true, durationSec: 100 }, + 100, + session, + ); + assert.equal(ended?.subDelaySec, undefined); + assert.equal(ended?.audioDelaySec, undefined); +}); + +test("an observed external subtitle is bound to the actual release even when subtitle memory is absent", () => { + const result = actualPlaybackSnapshot( + { ...src, streamRef: { infoHash: "ABC", fileIdx: 4 } }, + { + ...initialPlayerSnapshot(), + status: "playing", + firstFrameReady: true, + durationSec: 100, + subtitleTracks: [ + { + id: "1", + kind: "subtitle", + selected: true, + external: true, + label: "Arabic", + url: "https://fixture.invalid/a.srt", + }, + ], + }, + 20, + session, + ); + assert.equal(result?.subtitle?.streamKey, "torrent:abc:4"); +}); + +test("a later seed-track snapshot without provider IDs retains the proven cached subtitle pointer", () => { + const source = "https://fixture.invalid/arabic.srt"; + const track = { + id: "404", + kind: "subtitle" as const, + external: true, + selected: true, + label: "Arabic", + lang: "ara", + url: source, + originalUrl: source, + }; + const mediaKey = "local:short||"; + const streamKey = "torrent:abc:4"; + const file = selectedSubtitleCacheName(mediaKey, streamKey, { ...track, source }, "srt"); + const cached = { + source: `C:/fixture/selected/${file}`, + format: "srt" as const, + streamKey, + imported: true, + lang: "ara", + updatedAt: Date.now(), + }; + const actualSrc = { ...src, streamRef: { infoHash: "ABC", fileIdx: 4 } }; + const snapshot = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 100, + subtitleTracks: [track], + }; + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 20, session, Date.now(), cached)?.subtitle?.source, + cached.source, + ); + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 24, session, Date.now(), cached)?.subtitle?.source, + cached.source, + ); + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 24, session, Date.now(), { + ...cached, + source: "C:/fixture/unrelated.srt", + })?.subtitle?.source, + undefined, + ); +}); diff --git a/tests/actual-playback.test.ts b/tests/actual-playback.test.ts index 9fd75fe86..cbc96c3b8 100644 --- a/tests/actual-playback.test.ts +++ b/tests/actual-playback.test.ts @@ -51,6 +51,18 @@ function fixture(id: string, path: string, at: number, captured = actor()) { }; } +function markLegacyDismissals(key: string): string { + const stored = JSON.parse(values.get(key)!); + for (const entry of Object.values(stored) as any[]) { + if (entry.actual) entry.dismissedActualId = entry.actual.id; + for (const state of Object.values(entry.actualByProfile ?? {}) as any[]) + if (state.actual) state.dismissedActualId = state.actual.id; + } + const raw = JSON.stringify(stored); + values.set(key, raw); + return raw; +} + test("actual playback does not derive recency from source selections or manual history", () => { values.clear(); actor(); @@ -180,7 +192,376 @@ test("an invalid prior snapshot cannot supply EOF audio or prevent the new snaps const key = "harbor.playback-history.v1.one"; const stored = JSON.parse(values.get(key)!); delete stored["local:fixture"].actual.actor; + delete stored["local:fixture"].actualByProfile.one.actual.actor; values.set(key, JSON.stringify(stored)); assert.equal(history.recordActualPlayback({ ...first, completed: true }), true); assert.equal(history.readLastActualPlayback()?.audioTrack, undefined); }); + +test("legacy dismissal flags cannot hide the newest shared or private playback or replace it with an older item", () => { + for (const privateCw of [false, true]) { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: privateCw })); + actor("one", "account-one", "one"); + const older = fixture("old", "C:/fixture/old.mp4", Date.now(), history.capturePlaybackActor()); + older.src.meta.id = "local:older"; + history.recordActualPlayback(older); + const latest = fixture( + "new", + "C:/fixture/new.mp4", + Date.now() + 1, + history.capturePlaybackActor(), + ); + history.recordActualPlayback(latest); + const key = "harbor.playback-history.v1.one"; + const persisted = markLegacyDismissals(key); + assert.equal(history.readLastActualPlayback()?.id, latest.id); + assert.equal(values.get(key), persisted, "reading an old flag never rewrites stored history"); + assert.equal(history.readActualPlaybackFor(latest.src)?.id, latest.id); + assert.equal(history.readPlayback("local:older")?.actual?.id, older.id); + values.set(key, JSON.stringify(JSON.parse(persisted))); + assert.equal(history.readLastActualPlayback()?.id, latest.id, "fresh reads ignore old flags"); + history.recordActualPlayback({ ...latest, playedAt: latest.playedAt + 1, positionMs: 13000 }); + assert.equal(history.readLastActualPlayback()?.positionMs, 13000); + history.savePlayback(latest.src.meta.id, { title: "A metadata edit" }); + assert.equal(history.readLastActualPlayback()?.id, latest.id); + history.recordActualPlayback({ + ...latest, + id: "genuine-new-session", + playedAt: Date.now() + 3, + }); + assert.equal(history.readLastActualPlayback()?.id, "genuine-new-session"); + } +}); + +test("ignoring legacy dismissal keeps account isolation and failed-write protection", () => { + values.clear(); + const captured = actor(); + const target = fixture("one", "C:/fixture/a.mp4", Date.now(), captured); + history.recordActualPlayback(target); + markLegacyDismissals("harbor.playback-history.v1.one"); + assert.equal(history.readLastActualPlayback()?.id, target.id); + const next = { ...target, id: "two", playedAt: target.playedAt + 1 }; + failWrite = true; + try { + assert.equal(history.recordActualPlayback(next), false); + } finally { + failWrite = false; + } + assert.equal(history.readLastActualPlayback()?.id, target.id); + actor("two", "other-account"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.recordActualPlayback(next), false); + actor(); + assert.equal(history.readLastActualPlayback()?.id, target.id); +}); + +test("existing history retention accepts a four-day-old session but not an expired actual session refreshed by metadata", () => { + values.clear(); + actor(); + const day = 24 * 60 * 60 * 1000; + history.recordActualPlayback(fixture("four-days", "C:/fixture/a.mp4", Date.now() - 4 * day)); + assert.equal(history.readLastActualPlayback()?.id, "four-days"); + values.clear(); + actor(); + const stale = fixture("expired", "C:/fixture/a.mp4", Date.now() - 31 * day); + history.recordActualPlayback(stale); + history.savePlayback(stale.src.meta.id, { title: "Unplayed source selection" }); + assert.equal(history.readLastActualPlayback(), null); +}); + +test("natural EOF preserves the last subtitle intent and delays from its exact session", () => { + values.clear(); + const first = { + ...fixture("same", "C:/fixture/a.mp4", Date.now()), + subtitle: { source: "C:/fixture/a.srt", external: true }, + subDelaySec: 2, + audioDelaySec: -0.3, + }; + history.recordActualPlayback(first); + history.recordActualPlayback({ + ...first, + completed: true, + playedAt: first.playedAt + 1, + subtitle: undefined, + subDelaySec: undefined, + audioDelaySec: undefined, + }); + const ended = history.readLastActualPlayback(); + assert.deepEqual(ended?.subtitle, first.subtitle); + assert.equal(ended?.subDelaySec, 2); + assert.equal(ended?.audioDelaySec, -0.3); +}); + +test("late subtitle caching upgrades only the captured actual session without changing recency or honoring old dismissal", () => { + values.clear(); + const now = Date.now(); + const original = { + source: "https://fixture.invalid/a.srt", + external: true, + streamKey: "torrent:abc:2", + subId: "a", + provider: "Fixture", + }; + const playback = { + ...fixture("played", "C:/fixture/a.mp4", now), + subtitle: original, + subDelaySec: 1.2, + }; + playback.src.streamRef = { infoHash: "abc", fileIdx: 2 }; + history.recordActualPlayback(playback); + const captured = history.readActualPlaybackForSubtitle( + "local:fixture||", + "torrent:abc:2", + now - 1, + ); + assert.equal(captured?.id, "played"); + assert.equal( + history.readActualPlaybackForSubtitle("local:fixture||", "torrent:abc:2", now + 1), + null, + ); + markLegacyDismissals("harbor.playback-history.v1.one"); + const cached = { ...original, source: "C:/fixture/cached.srt", imported: true }; + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), true); + const saved = history.readActualPlaybackFor(playback.src)!; + assert.equal(saved.subtitle?.source, cached.source); + assert.equal(saved.playedAt, now); + assert.equal(saved.subDelaySec, 1.2); + assert.equal(history.readLastActualPlayback()?.id, playback.id); + history.recordActualPlayback({ ...playback, id: "new-session", playedAt: now + 1 }); + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), false); + actor("two", "other-account"); + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), false); +}); + +test("private CW keeps same-title actual playback independent and ignores old flags without changing shared source history", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const first = fixture("private-one", "C:/fixture/one.mp4", Date.now(), one); + history.savePlayback(first.src.meta.id, { addonId: "shared-addon", title: "Fixture" }); + assert.equal(history.recordActualPlayback(first), true); + const two = actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); + assert.equal(history.readPlayback(first.src.meta.id)?.addonId, "shared-addon"); + const second = fixture("private-two", "C:/fixture/two.mp4", first.playedAt + 1, two); + assert.equal(history.recordActualPlayback(second), true); + markLegacyDismissals("harbor.playback-history.v1.one"); + assert.equal(history.readLastActualPlayback()?.id, second.id); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + history.savePlayback(first.src.meta.id, { title: "Metadata only" }); + history.clearPlayback(first.src.meta.id); + assert.equal(history.readLastActualPlayback()?.id, first.id); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, second.id); + assert.equal(history.readActualPlaybackFor(second.src)?.id, second.id); + actor("one", "account-one", "one"); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: false })); + assert.equal( + history.readLastActualPlayback()?.id, + second.id, + "shared suggestion keeps the latest shared actual session", + ); +}); + +test("private legacy adoption requires the actual playback owner's identity", () => { + values.clear(); + const one = actor("one", "account-one", "one"); + const first = fixture("legacy-one", "C:/fixture/one.mp4", Date.now(), one); + values.set( + "harbor.playback-history.v1.one", + JSON.stringify({ "local:fixture": { actual: first, savedAt: first.playedAt } }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.readLastActualPlayback()?.id, first.id); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); + assert.equal(history.recordActualPlayback(first), false); +}); + +test("private EOF and late cache keep their own session without replacing a newer shared session", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const original = { source: "https://fixture.invalid/one.srt", external: true }; + const first = { + ...fixture("private-one", "C:/fixture/one.mp4", Date.now(), one), + subtitle: original, + audioTrack: { id: "2", lang: "ara" }, + subDelaySec: 1.2, + }; + history.recordActualPlayback(first); + const two = actor("two", "account-one", "one"); + const second = fixture("private-two", "C:/fixture/two.mp4", first.playedAt + 2, two); + history.recordActualPlayback(second); + actor("one", "account-one", "one"); + const cached = { ...original, source: "C:/fixture/cached-one.srt", imported: true }; + assert.equal(history.cacheActualPlaybackSubtitle(first, original, cached), true); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal(history.readPlayback(first.src.meta.id)?.actual?.id, second.id); + history.recordActualPlayback({ + ...first, + completed: true, + playedAt: first.playedAt + 1, + audioTrack: undefined, + subtitle: undefined, + subDelaySec: undefined, + }); + assert.deepEqual(history.readLastActualPlayback()?.audioTrack, first.audioTrack); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal(history.readLastActualPlayback()?.subDelaySec, 1.2); + assert.equal(history.readPlayback(first.src.meta.id)?.actual?.id, second.id); +}); + +test("a fresh shared entry cannot extend an expired private actual session", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const first = fixture("private-one", "C:/fixture/one.mp4", Date.now(), one); + history.recordActualPlayback(first); + const two = actor("two", "account-one", "one"); + history.recordActualPlayback(fixture("private-two", "C:/fixture/two.mp4", Date.now() + 1, two)); + const key = "harbor.playback-history.v1.one"; + const stored = JSON.parse(values.get(key)!); + stored["local:fixture"].actualByProfile.one.actual.playedAt = + Date.now() - 31 * 24 * 60 * 60 * 1000; + values.set(key, JSON.stringify(stored)); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); +}); + +test("privacy policy changes invalidate captured writes without hiding the existing session", () => { + values.clear(); + const one = actor("one", "account-one", "one"); + const first = fixture("shared-one", "C:/fixture/one.mp4", Date.now(), one); + history.recordActualPlayback(first); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + markLegacyDismissals("harbor.playback-history.v1.one"); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.recordActualPlayback({ ...first, id: "late" }), false); + assert.equal( + history.readLastActualPlayback()?.id, + first.id, + "private suggestion remains available from its own observed session", + ); +}); + +test("Nuvio account, provider, and slot changes isolate last actual playback and reject stale actors", () => { + values.clear(); + actor(); + history.recordActualPlayback(fixture("old-stremio", "C:/fixture/stremio.mp4", Date.now())); + values.set("harbor.account-provider", "nuvio"); + const nuvio = (accountId: string, slot: number) => { + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: slot, session: { user: { id: accountId } } }), + ); + return history.capturePlaybackActor(); + }; + const one = nuvio("nuvio-account-a", 1); + assert.equal( + history.readLastActualPlayback(), + null, + "Nuvio cannot adopt an unattributed legacy Stremio session", + ); + const first = fixture("nuvio-one", "C:/fixture/one.mp4", Date.now(), one); + assert.equal(history.recordActualPlayback(first), true); + const two = nuvio("nuvio-account-a", 2); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.readLastActualPlayback(), null); + const second = fixture("nuvio-two", "C:/fixture/two.mp4", first.playedAt + 1, two); + assert.equal(history.recordActualPlayback(second), true); + markLegacyDismissals("harbor.playback-history.v1.nuvio.nuvio-account-a.nuvio-2"); + nuvio("nuvio-account-a", 1); + assert.equal(history.readLastActualPlayback()?.id, first.id); + nuvio("nuvio-account-b", 1); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.recordActualPlayback({ ...first, id: "late" }), false); + nuvio("nuvio-account-a", 2); + assert.equal(history.readLastActualPlayback()?.id, second.id); + values.set("harbor.account-provider", "stremio"); + assert.equal(history.isPlaybackActorCurrent(two), false); + assert.equal(history.readLastActualPlayback()?.id, "old-stremio"); + const stremio = fixture("stremio", "C:/fixture/stremio.mp4", Date.now()); + history.recordActualPlayback(stremio); + values.set("harbor.account-provider", "nuvio"); + nuvio("nuvio-account-a", 1); + assert.equal(history.readLastActualPlayback()?.id, first.id); +}); + +test("scope events notify only for account, provider, profile, slot, or privacy changes", () => { + values.clear(); + actor("one", "account-one", "one"); + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + const events = new EventTarget(); + Object.defineProperty(globalThis, "window", { configurable: true, value: events }); + let notifications = 0; + const revision = history.getPlaybackRevision(); + const unsubscribe = history.subscribePlayback(() => notifications++); + const unsubscribeOther = history.subscribePlayback(() => {}); + try { + events.dispatchEvent(new Event("harbor:settings-changed")); + assert.equal(notifications, 0); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + events.dispatchEvent(new Event("harbor:settings-changed")); + assert.equal(notifications, 1); + actor("two", "account-one", "one"); + events.dispatchEvent(new Event("harbor:active-profile-changed")); + assert.equal(notifications, 2); + values.set("harbor.account-provider", "nuvio"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 1, session: { user: { id: "account-a" } } }), + ); + events.dispatchEvent(new Event("harbor:account-provider-changed")); + assert.equal(notifications, 3); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ + activeProfileIndex: 2, + session: { user: { id: "account-a" }, access_token: "fixture-refresh" }, + }), + ); + events.dispatchEvent(new Event("harbor:nuvio-profile-changed")); + assert.equal(notifications, 4); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ + activeProfileIndex: 2, + session: { user: { id: "account-a" }, access_token: "fixture-next-refresh" }, + }), + ); + events.dispatchEvent(new Event("harbor:nuvio-state-changed")); + assert.equal(notifications, 4, "token refresh cannot invalidate a same-owner session"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 2, session: { user: { id: "account-b" } } }), + ); + events.dispatchEvent(new Event("harbor:nuvio-state-changed")); + assert.equal(notifications, 5); + assert.equal( + history.getPlaybackRevision(), + revision + 5, + "revision advances once per scope change, not per subscriber", + ); + unsubscribe(); + unsubscribeOther(); + values.set("harbor.account-provider", "stremio"); + events.dispatchEvent(new Event("harbor:account-provider-changed")); + assert.equal(notifications, 5, "scope listeners are cleaned up after the last subscriber"); + } finally { + unsubscribe(); + unsubscribeOther(); + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + } +}); diff --git a/tests/context-content-actions.test.ts b/tests/context-content-actions.test.ts index a5a865edf..a68f61ba6 100644 --- a/tests/context-content-actions.test.ts +++ b/tests/context-content-actions.test.ts @@ -4,39 +4,60 @@ import assert from "node:assert/strict"; import test from "node:test"; import { contentActions } from "../src/components/context-menu/content-actions.tsx"; -test("a linked poster keeps distinct title and image links but does not duplicate the same image URL", () => { - const image = { - src: "https://images.example.org/poster.png", - publicUrl: "https://images.example.org/poster.png", - }; - const independent = contentActions( - { image, link: "https://games.example.org/game" }, - () => {}, - false, - ); +test("a linked poster keeps its independent page link without any image commands", () => { + const actions = contentActions({ + image: { + src: "https://images.example.org/poster.png", + publicUrl: "https://images.example.org/poster.png", + }, + link: "https://games.example.org/game", + }); assert.deepEqual( - independent.map((a) => a.id), - ["link:open", "link:copy", "image"], + actions.map((action) => action.id), + ["link:open", "link:copy"], ); - assert.ok(independent[2].children?.some((a) => a.id === "image:copy-link")); - const same = contentActions({ image, link: image.src }, () => {}, true); - assert.equal( - same.some((a) => a.id === "link:copy"), - false, +}); + +test("an image alone or a link to that same image has no contextual commands", () => { + for (const image of [ + { src: "https://images.example.org/poster.png" }, + { + src: "https://images.example.org/poster.png", + publicUrl: "https://images.example.org/poster.png", + }, + { + src: "https://images.example.org/small.png", + originalSrc: "https://images.example.org/full.png", + }, + { src: "blob:https://example.org/fixture" }, + { src: "data:image/png;base64,iVBORw0KGgo=" }, + ]) { + assert.deepEqual(contentActions({ image }), []); + assert.deepEqual(contentActions({ image, link: image.src }), []); + if (image.originalSrc) assert.deepEqual(contentActions({ image, link: image.originalSrc }), []); + } +}); + +test("selected text keeps its own command even when an image has no commands", () => { + const actions = contentActions({ + selection: "selected words", + image: { src: "blob:https://example.org/fixture" }, + }); + assert.deepEqual( + actions.map((action) => action.id), + ["selection:copy"], ); - assert.equal(same.filter((a) => a.id === "image:copy-link").length, 1); }); -test("selected text remains distinct, and private image URLs do not become share links", () => { - const actions = contentActions( - { selection: "selected words", image: { src: "blob:https://example.org/fixture" } }, - () => {}, - true, +test("ordinary links preserve opening and safe copying without adding image tools", () => { + const actions = contentActions({ link: "https://site.example.org/page" }); + assert.deepEqual( + actions.map((action) => action.id), + ["link:open", "link:copy"], ); - assert.equal(actions[0].id, "selection:copy"); - assert.equal( - actions.some((a) => a.id === "image:copy-link"), - false, + const privateLink = contentActions({ link: "https://site.example.org/page?token=fixture" }); + assert.deepEqual( + privateLink.map((action) => action.id), + ["link:open"], ); - assert.equal(actions.find((a) => a.id === "image:view")?.restoreFocus, false); }); diff --git a/tests/context-image-exclusion.test.tsx b/tests/context-image-exclusion.test.tsx new file mode 100644 index 000000000..d5c421a8d --- /dev/null +++ b/tests/context-image-exclusion.test.tsx @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import { JSDOM } from "jsdom"; +import * as React from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; + +// Exercise the production builders/components while isolating music providers, +// native saving, and the third-party WebGL reader from these menu contracts. +function load(relative: string, dependencies: Record) { + const output = ts.transpileModule(readFileSync(new URL(relative, import.meta.url), "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const module = { exports: {} as any }; + new Function("require", "module", "exports", output)( + (id: string) => { + assert.ok(id in dependencies, `unmocked boundary ${id}`); + return dependencies[id]; + }, + module, + module.exports, + ); + return module.exports; +} + +const icons = new Proxy({}, { get: () => () => null }); +const nothing = () => {}; +const navigation = { goToArtist: nothing, goToAlbum: nothing }; +const translate = (key: string) => key; +const common = { + react: React, + "react/jsx-runtime": jsx, + "@/components/icons/music-icons": icons, + "lucide-react": icons, + "@/components/anchored-menu": { AnchoredMenu: () => null }, + "@/lib/i18n": { useT: () => translate }, + "@/lib/music/artwork-save": { + saveArtwork: () => assert.fail("no image saving from a context menu"), + }, +}; + +test("music track menus retain playback and text copying without a save-artwork command", async () => { + const { useMusicTrackMenuItems } = load("../src/components/music/music-track-menu.tsx", { + ...common, + "@/components/icons/more-like-this-icon": { MoreLikeThisIcon: () => null }, + "@/lib/music/navigation": {}, + "@/lib/music/playback-origin": { + getMusicPlaybackOrigin: () => null, + musicOriginName: () => "", + musicTitleTarget: () => ({ kind: "album" }), + }, + "@/lib/music/player": { getMusicState: () => ({ current: null }) }, + "@/lib/music/use-track-liked": { useMusicTrackLiked: () => false }, + "./use-artist-credits": { useArtistCredits: () => [] }, + "./music-navigate": { useMusicNavigate: () => navigation }, + }); + let played = 0; + const items = useMusicTrackMenuItems( + { + id: "fixture", + title: "Fixture", + artist: "Artist", + artwork: "https://images.example.org/art.jpg", + }, + { onPlay: () => played++ }, + ); + assert.deepEqual( + items.map((item: any) => item.id), + ["play", "similar", "copy"], + ); + await items[0].run(); + assert.equal(played, 1); +}); + +test("music album, artist and playlist menus omit image saving without losing their normal actions", async () => { + const { musicItemMenuItems } = load("../src/components/music/music-item-menu.tsx", { + ...common, + "@/components/music/music-cover-card": { + coverCardTitle: (item: any) => item.title || item.name, + }, + "@/components/music/music-navigate": { useMusicNavigate: () => navigation }, + "@/components/music/music-playlist-picker": {}, + "@/components/music/music-track-menu": {}, + "@/lib/music/player": {}, + "@/lib/music/pinned-playlists": {}, + }); + for (const kind of ["album", "artist", "playlist"]) { + let opened = 0; + const items = musicItemMenuItems({ + t: translate, + item: { + kind, + id: "fixture", + name: "Fixture", + title: "Fixture", + artwork: "https://images.example.org/art.jpg", + }, + onOpen: () => opened++, + }); + assert.deepEqual( + items.map((item: any) => item.id), + ["open"], + ); + await items[0].run(); + assert.equal(opened, 1); + } +}); + +test("book-view reader pages remain a trusted reader target without image actions or a viewer portal", async () => { + const dom = new JSDOM("
", { url: "http://localhost/" }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Node: dom.window.Node, + Element: dom.window.Element, + IS_REACT_ACT_ENVIRONMENT: true, + }); + let targetFactory: any; + const opened: any[] = []; + let viewerMounts = 0; + (window as any).FlipBook = class { + toggleSound() {} + dispose() {} + destroy() {} + }; + const { BookFlip } = load("../src/views/manga/manga-reader/book-view.tsx", { + react: React, + "react/jsx-runtime": jsx, + "lucide-react": icons, + "@/lib/context-menu": { + useContextMenu: () => ({ open: (_event: any, target: any) => opened.push(target) }), + useContextTarget: (factory: any) => { + targetFactory = factory; + return nothing; + }, + }, + "@/components/context-image-viewer": { + ContextImageViewer: () => { + viewerMounts++; + return null; + }, + }, + "@/lib/i18n": { useT: () => translate }, + "./flipbook": { guardPageLoaded: nothing, hasNativeZoom: () => false }, + "./reader-utils": { measureAspect: async () => 1.4 }, + }); + const root = createRoot(document.querySelector("#root")!); + try { + await React.act(() => + root.render( + jsx.jsx(BookFlip, { + pages: ["https://images.example.org/page.png"], + rtl: false, + bg: "black", + resumePage: 0, + soundEnabled: false, + onProgress: nothing, + }), + ), + ); + await React.act(() => + window.dispatchEvent( + new dom.window.CustomEvent("r3d-pagechange", { + detail: { page: "1", name: "harborBook" }, + }), + ), + ); + assert.equal(targetFactory().kind, "actions"); + assert.deepEqual(targetFactory().actions(), []); + const reader = document.querySelector("[tabindex='0']")!; + await React.act(() => + reader.dispatchEvent( + new dom.window.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }), + ), + ); + assert.deepEqual(opened.at(-1).actions(), []); + assert.equal(viewerMounts, 0); + } finally { + await React.act(() => root.unmount()); + dom.window.close(); + } +}); diff --git a/tests/context-menu-actor-scope.test.ts b/tests/context-menu-actor-scope.test.ts index 57c48d89f..e16d3e219 100644 --- a/tests/context-menu-actor-scope.test.ts +++ b/tests/context-menu-actor-scope.test.ts @@ -13,6 +13,7 @@ import * as actions from "../src/lib/context-actions.ts"; import * as content from "../src/lib/context-content.ts"; import * as background from "../src/lib/context-page-background.ts"; import * as navigationPolicy from "../src/chrome/navigation-policy.ts"; +import { contentActions } from "../src/components/context-menu/content-actions.tsx"; import * as back from "../src/lib/app-back.ts"; import * as backHandlers from "../src/lib/back-intercept.ts"; @@ -55,7 +56,7 @@ async function fixture({ toggleFullscreen() {}, liveSync() {}, }; - const last = { id: "playback:last:a", label: "Continue last watched", run() {} }; + const last = { id: "playback:last:a", label: "Resume last watched", run() {} }; const navigation = [ { id: "page:back", label: "Back", run() {} }, { id: "page:my-profile", label: "Open my profile", run() {} }, @@ -190,18 +191,14 @@ async function fixture({ LastPlaybackCommand: ({ children, enabled, - variant, }: { - children: (action: actions.ContextAction | null) => React.ReactNode; + children: (action: actions.ContextAction | null, row: React.ReactNode) => React.ReactNode; enabled: boolean; - variant?: string; }) => children( + presentation && enabled ? last : null, presentation && enabled - ? { - ...last, - label: variant === "quick" ? "Resume last" : last.label, - } + ? React.createElement(rows, { source: { actions: () => [last] } }) : null, ), }, @@ -228,7 +225,8 @@ async function fixture({ React.createElement( "section", { "data-test-menu": true }, - React.createElement("header", { "data-test-quick": true }, quickActions), + quickActions && + React.createElement("header", { "data-test-quick": true }, quickActions), React.createElement("main", { "data-test-rows": true }, children), ) : childrenOnly, @@ -240,7 +238,7 @@ async function fixture({ QuickActions: presentation ? rows : empty, MenuIcon: empty, }, - "./context-menu/content-actions": { contentActions: () => [] }, + "./context-menu/content-actions": { contentActions }, "./context-image-viewer": { ContextImageViewer: empty }, "@/lib/social/external-link-policy": {}, "@/chrome/context-page-navigation": { @@ -479,9 +477,16 @@ test("changing the Stremio account invalidates a still-open menu in the same loc } }); -test("non-player root menus place global last playback once above contextual rows, outside the strip", async () => { +test("only the background root places last playback and global navigation above its footer", async () => { const h = await fixture({ presentation: true }); try { + const pageTarget: menu.ContextMenuTarget = { + kind: "actions", + id: "page:profile", + scope: "page-background", + label: "Profile background", + actions: () => h.navigation, + }; const targets: menu.ContextMenuTarget[] = [ { kind: "meta", meta: { id: "b", name: "Poster B", type: "movie" } }, { @@ -490,19 +495,13 @@ test("non-player root menus place global last playback once above contextual row label: "Comment B", actions: () => [{ id: "comment:reply", label: "Reply", run() {} }], }, - { - kind: "actions", - id: "page:profile", - scope: "page-background", - label: "Profile background", - actions: () => h.navigation, - }, + pageTarget, { kind: "meta", meta: { id: "b", name: "Continue B", type: "series" }, primary: { actions: () => [ - { id: "continue:b", label: "Continue watching", run() {} }, + { id: "sources:b", label: "Choose another source", run() {} }, { id: "remove:b", label: "Remove from Continue Watching", run() {} }, ], }, @@ -511,16 +510,22 @@ test("non-player root menus place global last playback once above contextual row for (const target of targets) { await h.openTarget(target); const last = document.querySelectorAll('[data-context-action="playback:last:a"]'); - assert.equal(last.length, 1); - assert.equal(last[0].textContent, "Continue last watched"); - assert.ok(last[0].closest("[data-test-rows]")); - assert.equal(last[0].closest("[data-test-quick]"), null); - assert.equal(document.querySelector("[data-test-rows] [data-context-action]"), last[0]); - assert.equal(document.querySelectorAll('[data-context-action="page:my-profile"]').length, 1); - assert.equal(document.querySelectorAll('[data-context-action="page:go-to"]').length, 1); - assert.equal(document.querySelectorAll('[data-context-action="page:refresh"]').length, 1); + const background = target === pageTarget; + assert.equal(last.length, background ? 1 : 0); + if (background) { + assert.equal(last[0].textContent, "Resume last watched"); + assert.ok(last[0].closest("[data-test-rows]")); + assert.equal(last[0].closest("[data-test-quick]"), null); + assert.equal(document.querySelector("[data-test-rows] [data-context-action]"), last[0]); + } + for (const id of ["page:my-profile", "page:go-to", "page:refresh"]) + assert.equal( + document.querySelectorAll(`[data-context-action='${id}']`).length, + background ? 1 : 0, + ); + assert.equal(!!document.querySelector("[data-test-quick]"), background); } - assert.ok(document.querySelector('[data-context-action="continue:b"]')); + assert.ok(document.querySelector('[data-context-action="sources:b"]')); assert.ok(document.querySelector('[data-context-action="remove:b"]')); } finally { await h.close(); @@ -539,16 +544,18 @@ test("player menu excludes browsing navigation and global last playback while re "page:go-to", "page:refresh", "page:back", + "player:settings", "playback:last:a", ]) assert.equal(!!document.querySelector(`[data-context-action='${id}']`), false, id); const quick = [...document.querySelectorAll("[data-test-quick] [data-context-action]")].map( (node) => node.getAttribute("data-context-action"), ); - assert.deepEqual( - quick, - magnet ? ["player:copy-magnet", "player:settings"] : ["player:settings"], - ); + assert.deepEqual(quick, []); + assert.equal(document.querySelector("[data-test-quick]"), null); + const magnetRow = document.querySelector('[data-context-action="player:copy-magnet"]'); + assert.equal(!!magnetRow, !!magnet); + if (magnetRow) assert.ok(magnetRow.closest("[data-test-rows]")); for (const id of [ "player:fullscreen", "player:download-video", @@ -563,7 +570,32 @@ test("player menu excludes browsing navigation and global last playback while re } }); -test("page sharing stays behind native editing, registered cards and clicked content", async () => { +test("non-film entity sharing remains separate from the exclusive background menu", async () => { + const h = await fixture({ presentation: true, pageShare: true }); + try { + for (const target of [ + { kind: "person", id: 12 }, + { kind: "ebook", id: "book-12" }, + { kind: "manga", id: "manga-12", title: "Manga" }, + { kind: "meta", meta: { id: "anime-12", name: "Anime", type: "anime" } }, + ] satisfies menu.ContextMenuTarget[]) { + await h.openTarget(target); + assert.equal( + [...document.querySelectorAll("button")].filter( + (button) => button.textContent === "Share as link", + ).length, + 1, + `${target.kind} retains its own sharing command`, + ); + assert.equal(document.querySelector("[data-test-quick]"), null); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + } + } finally { + await h.close(); + } +}); + +test("person background stays exclusive while editing, registered cards and clicked content keep ownership", async () => { const h = await fixture({ presentation: true, pageShare: true }); const main = document.createElement("main"); main.innerHTML = ` @@ -584,17 +616,26 @@ test("page sharing stays behind native editing, registered cards and clicked con try { await summon(main); assert.equal(h.state?.target.kind, "actions"); - assert.ok(document.querySelector('[data-context-action="page:share:person:12"]')); + assert.equal(document.querySelector('[data-context-action="page:share:person:12"]'), null); + assert.ok(document.querySelector('[data-context-action="page:go-to"]')); + assert.ok(document.querySelector('[data-context-action="page:refresh"]')); for (const [selector, kind] of [ ["#card span", "actions"], - ["#image", "content"], + ["#image", null], ["#link", "content"], - ]) { - await summon(main.querySelector(selector)!); - assert.equal(h.state?.target.kind, kind); + ] as const) { + const event = await summon(main.querySelector(selector)!); + assert.equal(event.defaultPrevented, true, selector); + if (kind) assert.equal(h.state?.target.kind, kind); + else assert.equal(h.state, null, "an image without commands releases its menu state"); + if (selector === "#link") { + assert.ok(document.querySelector('[data-context-action="link:open"]')); + assert.ok(document.querySelector('[data-context-action="link:copy"]')); + } assert.equal(document.querySelector('[data-context-action="page:share:person:12"]'), null); - assert.ok(document.querySelector('[data-context-action="page:go-to"]')); - assert.ok(document.querySelector('[data-context-action="page:refresh"]')); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + assert.equal(document.querySelector('[data-context-action="page:refresh"]'), null); + assert.equal(document.querySelector("[data-test-quick]"), null); } const fieldEvent = await summon(main.querySelector("#field")!); assert.equal(fieldEvent.defaultPrevented, false, "editing keeps its native context menu"); @@ -609,7 +650,7 @@ test("page sharing stays behind native editing, registered cards and clicked con } }); -test("the explicit eBook details background shares without changing reader or descendant menus", async () => { +test("the explicit eBook background stays exclusive without changing reader or descendant ownership", async () => { const h = await fixture({ presentation: true, ebookDetails: true }); const main = document.getElementById("ebook-details")!; main.innerHTML = ` @@ -631,16 +672,25 @@ test("the explicit eBook details background shares without changing reader or de try { await summon(main); assert.equal(h.state?.target.kind, "actions"); - assert.ok(document.querySelector(shareSelector)); + assert.equal(document.querySelector(shareSelector), null); assert.ok(document.querySelector('[data-context-action="page:go-to"]')); + assert.ok(document.querySelector('[data-context-action="page:refresh"]')); for (const [selector, kind] of [ ["#card span", "actions"], - ["#image", "content"], + ["#image", null], ["#link", "content"], - ]) { - await summon(main.querySelector(selector)!); - assert.equal(h.state?.target.kind, kind); + ] as const) { + const event = await summon(main.querySelector(selector)!); + assert.equal(event.defaultPrevented, true, selector); + if (kind) assert.equal(h.state?.target.kind, kind); + else assert.equal(h.state, null, "an image without commands releases its menu state"); + if (selector === "#link") { + assert.ok(document.querySelector('[data-context-action="link:open"]')); + assert.ok(document.querySelector('[data-context-action="link:copy"]')); + } assert.equal(document.querySelector(shareSelector), null); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + assert.equal(document.querySelector('[data-context-action="page:refresh"]'), null); } const fieldEvent = await summon(main.querySelector("#field")!); assert.equal(fieldEvent.defaultPrevented, false); diff --git a/tests/context-menu-simplification.test.tsx b/tests/context-menu-simplification.test.tsx new file mode 100644 index 000000000..8d063357b --- /dev/null +++ b/tests/context-menu-simplification.test.tsx @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import { JSDOM } from "jsdom"; +import * as React from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; +import * as icons from "lucide-react"; +import * as context from "../src/lib/context-menu.tsx"; +import * as content from "../src/lib/context-content.ts"; +import * as partition from "../src/lib/context-quick-actions.ts"; +import * as backgroundPolicy from "../src/lib/context-page-background.ts"; +import * as surface from "../src/components/context-menu/menu-surface.tsx"; +import * as actionItems from "../src/components/context-menu/action-items.tsx"; +import * as contentActions from "../src/components/context-menu/content-actions.tsx"; +import * as playerSources from "../src/components/context-menu/player-actions.tsx"; + +function harness() { + const dom = new JSDOM('
', { + url: "https://fixture.invalid", + pretendToBeVisual: true, + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + Element: dom.window.Element, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + HTMLInputElement: dom.window.HTMLInputElement, + innerWidth: dom.window.innerWidth, + innerHeight: dom.window.innerHeight, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + MutationObserver: dom.window.MutationObserver, + HTMLTextAreaElement: dom.window.HTMLTextAreaElement, + MouseEvent: dom.window.MouseEvent, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }); + dom.window.matchMedia = (() => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + })) as any; + const empty = () => {}; + let api!: ReturnType; + let lastReads = 0; + let copied = 0; + let queueSurface = false; + const queued: string[] = []; + let player: any = null; + let watched: any = { loading: true, summary: null, refresh: empty }; + const nav = [ + { id: "page:back", label: "Back", run: empty }, + { id: "page:my-profile", label: "Open my profile", run: empty }, + { + id: "page:go-to", + label: "Go to", + children: [ + { id: "page:go:home", label: "Home", run: empty }, + { id: "page:go:settings", label: "Settings", run: empty }, + ], + }, + { id: "page:refresh", label: "Refresh", run: empty }, + ]; + const page: context.ContextMenuTarget = { + kind: "actions", + id: "page:home", + label: "Page actions", + scope: "page-background", + actions: () => nav, + }; + const view = { + topKind: "home", + topPath: "/home", + openMeta: empty, + openManga: empty, + setView: empty, + openQueue: empty, + openPicker: empty, + openPerson: empty, + openService: empty, + openAddonDetail: empty, + openSettings: empty, + }; + const deps: Record = { + react: React, + "react/jsx-runtime": jsx, + "lucide-react": icons, + "@/lib/context-menu": context, + "@/lib/context-content": content, + "@/lib/context-quick-actions": partition, + "@/lib/context-page-background": backgroundPolicy, + "@/lib/context-actions": {}, + "@/lib/i18n": { t: (s: string) => s, useT: () => (s: string) => s }, + "@/lib/active-addon": { useActiveAddon: () => null }, + "@/components/player/copy-link-button": { copyText: async () => true }, + "@/components/lists/list-toast": { emitListToast: empty }, + "@/lib/deep-link": { shareDeepLink: (type: string, id: string) => `${type}:${id}` }, + "@/lib/auth": { useAuth: () => ({ user: null }) }, + "@/lib/queue": { + queueAdd: (meta: { id: string }) => queued.push(meta.id), + queueIndexOf: (meta: { id: string }) => queued.indexOf(meta.id), + }, + "@/lib/membership-operations": {}, + "@/lib/stremio": {}, + "@/lib/queue-resolve-episode": {}, + "@/hooks/use-queue-surface": { useQueueSurface: () => queueSurface }, + "@/lib/player-actions": { usePlayerActions: () => player, currentPlayerActions: () => player }, + "@/lib/playback-history": { + capturePlaybackActor: () => "fixture", + isPlaybackActorCurrent: () => true, + }, + "@/lib/together/provider": { + useTogether: () => ({ snapshot: { state: "idle" }, sendSummon: empty }), + }, + "@/lib/view": { useView: () => ({ ...view, player }) }, + "@/lib/watchlist": { useInWatchlist: () => false }, + "@/lib/media-context-actions": { + setContextWatchlist: empty, + setContextFavorite: empty, + setContextWatched: empty, + requireMediaActionSuccess: empty, + }, + "@/lib/context-watched-state": { useContextWatchedState: () => watched }, + "@/lib/providers/tmdb": { useTmdbImdbId: () => undefined }, + "@/lib/media-favorites": { useIsFavorite: () => false }, + "@/lib/title-backdrop": { getTitleBackdrop: () => undefined }, + "./context-menu/my-list-submenu": { + MyListSubmenu: ({ children }: any) => ( + <> + + {children} + + ), + }, + "@/lib/settings": { + useSettings: () => ({ + settings: { navCustomization: { order: [], hidden: [] } }, + update: empty, + }), + }, + "@/chrome/nav-items": {}, + "@/chrome/nav-edit-mode": { useNavEditMode: () => false }, + "@/lib/manga-favorites": { + useMangaFavorites: () => ({ toggle: empty }), + useIsMangaFavorite: () => false, + }, + "@/lib/manga-progress": { + useMangaProgressEntry: () => null, + useReadMangaChapterIds: () => new Set(), + }, + "@/lib/manga-bookmarks": { useMangaBookmarks: () => [] }, + "@/lib/manga-downloads": {}, + "@/lib/manga/read-intent": {}, + "@/lib/manga-lists": {}, + "@/lib/manga/api": {}, + "@/lib/manga/chapter-identity": {}, + "@/lib/profiles": { + useProfiles: () => ({ activeProfile: { id: "fixture" }, activeId: "fixture" }), + }, + "@/lib/theme-auth": { currentAuthor: () => null, subscribeAuthor: () => empty }, + "./context-menu/menu-surface": surface, + "./context-menu/action-items": actionItems, + "./context-menu/content-actions": contentActions, + "@/components/context-image-viewer": { + ContextImageViewer: () =>
, + }, + "./context-image-viewer": { ContextImageViewer: () =>
}, + "@/lib/social/external-link-policy": {}, + "@/chrome/context-page-navigation": { + usePageContextTarget: ({ includePlayer = false } = {}) => + player && !includePlayer ? null : page, + }, + "./context-menu/use-manual-download": { + useManualDownload: () => ({ beginManualDownload: empty, manualDownloadDialog: null }), + }, + "./context-menu/last-playback-command": { + LastPlaybackCommand: ({ enabled, children }: any) => { + if (enabled) lastReads++; + const action = enabled + ? { id: "playback:last:fixture", label: "Resume last watched", run: empty } + : null; + return children( + action, + action ? ( + [action] }} onClose={empty} /> + ) : null, + ); + }, + }, + "./context-menu/menu-brand-footer": { + MenuBrandFooter: () =>
, + }, + "./context-menu/player-actions": playerSources, + }; + const output = ts.transpileModule( + readFileSync(new URL("../src/components/context-menu.tsx", import.meta.url), "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText; + const module = { exports: {} as any }; + new Function("require", "module", "exports", output)( + (name: string) => { + assert.ok(name in deps, `unexpected dependency ${name}`); + return deps[name]; + }, + module, + module.exports, + ); + function App() { + api = context.useContextMenu(); + const emptyCard = context.useContextTarget(() => ({ + kind: "actions", + id: "empty-card", + label: "Card", + actions: () => [], + })); + return ( + <> +
+ +
+ Occupied card space +
+
+ + + + + ); + } + const root = createRoot(document.getElementById("root")!); + const render = () => + React.act(() => + root.render( + + + , + ), + ); + const open = async (target: context.ContextMenuTarget) => { + await React.act(() => api.openAt({ x: 20, y: 20 }, target)); + }; + const labels = () => + [...document.querySelectorAll('[role="menu"] > .context-menu-motion [role="menuitem"]')].map( + (e) => e.textContent?.trim(), + ); + return { + dom, + root, + render, + open, + page, + labels, + get reads() { + return lastReads; + }, + get state() { + return api.state; + }, + copy: () => copied++, + get copied() { + return copied; + }, + set watched(value: any) { + watched = value; + }, + set player(value: any) { + player = value; + }, + set queueSurface(value: boolean) { + queueSurface = value; + }, + get queued() { + return queued; + }, + dispose: async () => { + await React.act(() => root.unmount()); + dom.window.close(); + }, + }; +} + +test("actual menus reserve navigation/last playback for declared background and keep entity Copy as a row", async () => { + const h = harness(); + await h.render(); + try { + await h.open(h.page); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + assert.ok(document.querySelector("[data-context-quick]")); + const before = h.reads; + await h.open({ + kind: "actions", + id: "collection", + label: "Collection", + actions: () => [{ id: "collection:copy", label: "Copy share link", run: h.copy }], + }); + assert.deepEqual(h.labels(), ["Copy share link"]); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal(h.reads, before, "item menus never resolve last playback"); + await React.act(() => + ( + document.querySelector('[data-context-action="collection:copy"]') as HTMLButtonElement + ).click(), + ); + assert.equal(h.copied, 1); + await h.open({ kind: "content", image: { src: "https://fixture.invalid/image.png" } }); + assert.equal( + document.querySelector('[role="menu"]') === null, + true, + "empty image target does not become a page menu", + ); + assert.equal( + h.state === null, + true, + "an empty target releases the menu ownership, not only its surface", + ); + } finally { + await h.dispose(); + } +}); + +test("rendered movie menu has no toolbar, image/sharing/waiting tools, or orphan watched separator", async () => { + const h = harness(); + await h.render(); + try { + await h.open({ + kind: "meta", + meta: { id: "fixture", type: "movie", name: "Movie", poster: "fixture.png" }, + }); + assert.deepEqual(h.labels(), [ + "View details", + "Download", + "Add to watchlist", + "Add to favorites", + "Add to list", + ]); + assert.equal(document.querySelector('[role="separator"]') === null, true); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal(h.reads, 0); + const download = [...document.querySelectorAll("button")].find( + (e) => e.textContent === "Download", + )!; + download.focus(); + h.watched = { loading: false, summary: { status: "watched", unavailable: [] }, refresh() {} }; + await h.render(); + assert.ok(h.labels().includes("Mark as unwatched")); + assert.equal(document.activeElement, download, "late watched status preserves focused command"); + } finally { + await h.dispose(); + } +}); + +test("the official queue command remains available on queue-enabled surfaces", async () => { + const h = harness(); + h.queueSurface = true; + await h.render(); + const target: context.ContextMenuTarget = { + kind: "meta", + meta: { id: "queue-movie", type: "movie", name: "Queue movie" }, + }; + try { + await h.open(target); + assert.ok(h.labels().includes("Add to queue")); + const add = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "Add to queue", + )!; + await React.act(async () => add.click()); + assert.deepEqual(h.queued, ["queue-movie"]); + await h.open(target); + const existing = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "In your queue", + )!; + assert.ok(existing); + assert.equal(existing.disabled, true); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + } finally { + await h.dispose(); + } +}); + +test("managed image context is consumed and native editing remains untouched", async () => { + const h = harness(); + await h.render(); + try { + const imageEvent = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("image")!.dispatchEvent(imageEvent)); + assert.equal(imageEvent.defaultPrevented, true); + assert.equal(document.querySelector('[role="menu"]') === null, true); + const editEvent = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("editor")!.dispatchEvent(editEvent)); + assert.equal(editEvent.defaultPrevented, false); + assert.equal(document.querySelector('[role="menu"]') === null, true); + } finally { + await h.dispose(); + } +}); + +test("empty cards and independent controls are not background, while true page space is", async () => { + const h = harness(); + await h.render(); + try { + for (const id of ["empty-card", "control"]) { + const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById(id)!.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true); + assert.equal(document.querySelector('[role="menu"]') === null, true, id); + } + const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("background")!.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + } finally { + await h.dispose(); + } +}); + +test("a docked live player keeps page-background navigation without adding it to the player menu", async () => { + const h = harness(); + const meta = { id: "playing", type: "movie", name: "Playing" }; + h.player = { meta, src: {}, toggleFullscreen() {} }; + await h.render(); + try { + await h.open(h.page); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + await h.open({ kind: "meta", player: true, meta }); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal( + h + .labels() + .some((label) => /Back|Settings|last watched|my profile|Go to|Refresh/.test(label ?? "")), + false, + ); + assert.ok(h.labels().includes("Full screen")); + } finally { + await h.dispose(); + } +}); diff --git a/tests/context-menu-surface.test.ts b/tests/context-menu-surface.test.ts index d0bcbc6cd..527c2c043 100644 --- a/tests/context-menu-surface.test.ts +++ b/tests/context-menu-surface.test.ts @@ -250,7 +250,24 @@ test("quick navigation, closing guards, and destination search preserve keyboard point: { x: 20, y: 20 }, onClose() {}, quickActions: createElement(QuickActions, { source: quickSource, onClose() {} }), - children: createElement(Commands), + children: createElement( + Fragment, + null, + createElement(Commands), + createElement(ActionItems, { + source: { + actions: () => [ + { + id: "described", + label: "Described command", + reason: "Extra details", + run() {}, + }, + ], + }, + onClose() {}, + }), + ), }), ), ); @@ -273,10 +290,20 @@ test("quick navigation, closing guards, and destination search preserve keyboard await act(async () => { await new Promise((resolve) => setTimeout(resolve, 280)); }); - const settingsTip = document.querySelector("[role='tooltip']")!; - assert.equal(settingsTip.textContent, "Settings"); - assert.equal(settings.getAttribute("aria-describedby"), settingsTip.id); - const tipLayer = settingsTip.closest("[data-harbor-context-layer]")!; + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null); + assert.equal(settings.getAttribute("aria-describedby"), null); + const described = document.querySelector( + "[data-context-action='described']", + )!; + await act(() => described.focus()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 280)); + }); + const descriptionTip = document.querySelector("[role='tooltip']")!; + assert.ok(descriptionTip, "explicit row descriptions still appear"); + assert.equal(descriptionTip.textContent, "Extra details"); + assert.equal(described.getAttribute("aria-describedby"), descriptionTip.id); + const tipLayer = descriptionTip.closest("[data-harbor-context-layer]")!; assert.equal( tipLayer.parentElement, document.body, diff --git a/tests/context-page-background.test.ts b/tests/context-page-background.test.ts index 39ad175f1..5de0a651a 100644 --- a/tests/context-page-background.test.ts +++ b/tests/context-page-background.test.ts @@ -29,8 +29,10 @@ test("page markers cannot override editor, reader, player, or modal ownership",
+ +
`); - for (const id of ["dialog", "editor", "player", "reader", "custom", "frame"]) + for (const id of ["dialog", "editor", "player", "reader", "custom", "frame", "image", "backdrop"]) assert.equal(isPageContextBackground(dom.window.document.getElementById(id)), false, id); assert.equal(isPageContextBackground(null), false); dom.window.close(); diff --git a/tests/context-quick-actions.test.ts b/tests/context-quick-actions.test.ts index 12a7286e3..4670988ff 100644 --- a/tests/context-quick-actions.test.ts +++ b/tests/context-quick-actions.test.ts @@ -1,120 +1,75 @@ -// @ts-expect-error Node test types are outside browser config. import assert from "node:assert/strict"; -// @ts-expect-error Node test types are outside browser config. import test from "node:test"; import { contextNavigationFooter, partitionContextActions, } from "../src/lib/context-quick-actions.ts"; -test("unsupported image copy is omitted from the strip without inventing paste", () => { - const result = partitionContextActions( - [], - [{ id: "image:copy", label: "Copy Image", disabled: true }], - [], - ); - assert.deepEqual(result.quick, []); -}); +const back = { id: "page:back", label: "Back", disabled: true }; +const settings = { id: "page:go:settings", label: "Settings", run: () => {} }; +const nav = [ + back, + { id: "page:go-to", label: "Go to", children: [settings, { id: "page:go:home", label: "Home" }] }, + { id: "page:my-profile", label: "Open my profile" }, + { id: "page:refresh", label: "Refresh" }, +]; +const background = { + kind: "actions", + id: "page:home", + label: "Page actions", + scope: "page-background", + actions: () => nav, +}; +const partition = partitionContextActions as ( + ...args: any[] +) => ReturnType; -test("root navigation keeps own profile above Go to then Refresh without duplicating Settings", () => { - const settings = { id: "page:go:settings", label: "Settings" }; - const footer = contextNavigationFooter( - [ - { id: "page:back", label: "Back" }, - { id: "page:my-profile", label: "Open my profile", group: "pages" }, - { id: "page:refresh", label: "Refresh", shortcut: "Ctrl+R", group: "reload" }, - { - id: "page:go-to", - label: "Go to", - children: [settings, { id: "page:go:home", label: "Home" }], - }, - ], - [settings], - ); - assert.deepEqual( - footer.map((action) => action.id), - ["page:my-profile", "page:go-to", "page:refresh"], - ); - assert.deepEqual( - footer[1].children?.map((action) => action.id), - ["page:go:home"], - ); +test("only explicit unoccupied background gets Back and Settings; copy stays a row", () => { + const copy = { id: "collection:copy", label: "Copy share link" }; + for (const target of [ + undefined, + { kind: "actions" }, + { kind: "meta", player: true }, + { ...background, image: { src: "fixture.png" } }, + { ...background, link: "https://fixture.invalid" }, + { ...background, selection: "Selected" }, + { ...background, navigation: false }, + ]) { + const result = partition([copy], [], nav, target); + assert.deepEqual(result.quick, [], JSON.stringify(target)); + assert.deepEqual(result.entity, [copy], "copying is retained as a normal command"); + } + assert.deepEqual(partition(nav, [], nav, background).quick, [back, settings]); }); -test("the strip moves an explicit copy command without conflating image bytes and image link", () => { - const entity = [{ id: "comment:copy", label: "Copy comment text", quickCopy: true }]; - const content = [ - { - id: "image", - label: "Image", - children: [ - { id: "image:copy", label: "Copy image" }, - { id: "image:copy-link", label: "Copy image link" }, - ], - }, - ]; - const result = partitionContextActions(entity, content, []); +test("background navigation order is stable and Settings is removed from Go to", () => { + const result = partition(nav, [], nav, background); + const footer = contextNavigationFooter(nav, result.quick); assert.deepEqual( - result.quick.map((a) => a.id), - ["comment:copy"], - ); - assert.deepEqual(result.entity, []); - assert.equal(result.content[0].children?.length, 2); - const poster = partitionContextActions([], content, []); - assert.deepEqual( - poster.quick.map((a) => a.id), - ["image:copy"], + footer.map((a) => a.id), + ["page:my-profile", "page:go-to", "page:refresh"], ); assert.deepEqual( - poster.content[0].children?.map((a) => a.id), - ["image:copy-link"], - ); -}); - -test("navigation commands preserve their original capability and avoid duplicates", () => { - const back = { id: "page:back", label: "Back", disabled: true }; - const settings = { id: "page:go:settings", label: "Settings", run: () => {} }; - const nav = [ - back, - { - id: "page:go-to", - label: "Go to", - children: [settings, { id: "page:go:home", label: "Home" }], - }, - ]; - const result = partitionContextActions(nav, [], nav); - assert.deepEqual(result.quick, [back, settings]); - assert.deepEqual( - result.entity[0].children?.map((a) => a.id), + footer[1].children?.map((a) => a.id), ["page:go:home"], ); - assert.equal( - result.quick.some((a) => /paste/i.test(a.id)), - false, - ); + assert.deepEqual(result.entity, [], "background does not duplicate its own navigation rows"); }); -test("selection never silently replaces a poster copy and empty groups are removed", () => { - const result = partitionContextActions( - [], - [ - { id: "selection:copy", label: "Copy selected text" }, - { id: "image", label: "Image", children: [{ id: "image:copy", label: "Copy image" }] }, - ], - [], - ); - assert.deepEqual( - result.quick.map((a) => a.id), - ["image:copy"], - ); - assert.deepEqual( - result.content.map((a) => a.id), - ["selection:copy"], - ); +test("empty items never inherit global navigation and selected text remains a row", () => { + const selected = { id: "selection:copy", label: "Copy selected text" }; + const result = partition([], [selected], nav, { kind: "content", selection: "fixture" }); + assert.deepEqual(result.quick, []); + assert.deepEqual(result.content, [selected]); + assert.deepEqual(partition([], [], nav, { kind: "actions" }), { + quick: [], + entity: [], + content: [], + }); }); -test("moving a child command preserves its parent's disabled capability", () => { - const result = partitionContextActions( +test("extracting background Settings preserves its inherited disabled/PIN capability", () => { + const result = partition( [], [], [ @@ -122,11 +77,12 @@ test("moving a child command preserves its parent's disabled capability", () => id: "page:go-to", label: "Go to", disabled: true, - reason: "Navigation unavailable", - children: [{ id: "page:go:settings", label: "Settings", run: () => {} }], + reason: "PIN required", + children: [settings], }, ], + background, ); assert.equal(result.quick[0].disabled, true); - assert.equal(result.quick[0].reason, "Navigation unavailable"); + assert.equal(result.quick[0].reason, "PIN required"); }); diff --git a/tests/context-reader-pages.test.ts b/tests/context-reader-pages.test.ts index b92008a9b..ea82cec1d 100644 --- a/tests/context-reader-pages.test.ts +++ b/tests/context-reader-pages.test.ts @@ -2,24 +2,8 @@ import assert from "node:assert/strict"; // @ts-expect-error Node test types are intentionally outside the browser-only tsconfig. import test from "node:test"; -import { visibleReaderPages } from "../src/views/manga/manga-reader/reader-context.ts"; import { fetchImageObjectUrl } from "../src/views/manga/manga-reader/reader-utils.ts"; -test("reader image choices use the renderer's exact reported page numbers", () => { - const pages = ["blob:one", "blob:two", "blob:three", "blob:four"]; - assert.deepEqual(visibleReaderPages(pages, "2-3"), [ - { number: 2, src: "blob:two" }, - { number: 3, src: "blob:three" }, - ]); - assert.deepEqual(visibleReaderPages(pages, "3-2"), [ - { number: 3, src: "blob:three" }, - { number: 2, src: "blob:two" }, - ]); - assert.deepEqual(visibleReaderPages(pages, "1"), [{ number: 1, src: "blob:one" }]); - for (const spread of ["", "0", "5", "2-5", "1-4", "undefined", "1.5"]) - assert.deepEqual(visibleReaderPages(pages, spread), []); -}); - test("header-fetched manga image blobs preserve the actual format when the source omits content type", async () => { Object.defineProperty(globalThis, "window", { configurable: true, diff --git a/tests/context-tooltip-intent.test.ts b/tests/context-tooltip-intent.test.ts index d9446f7bb..e77336c97 100644 --- a/tests/context-tooltip-intent.test.ts +++ b/tests/context-tooltip-intent.test.ts @@ -4,7 +4,62 @@ import { JSDOM } from "jsdom"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { MenuSurface } from "../src/components/context-menu/menu-surface.tsx"; -import { ActionItems } from "../src/components/context-menu/action-items.tsx"; +import { ActionItems, QuickActions } from "../src/components/context-menu/action-items.tsx"; + +test("labeled navigation quick actions omit tooltips and remain actionable", async () => { + const dom = new JSDOM("
", { url: "https://fixture.invalid" }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + KeyboardEvent: dom.window.KeyboardEvent, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const calls: string[] = []; + const root = createRoot(document.querySelector("#root")!); + const wait = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 290)); + }); + try { + await act(() => + root.render( + createElement(QuickActions, { + source: { + actions: () => [ + { id: "page:back", label: "Back", run: () => void calls.push("back") }, + { id: "page:go:settings", label: "Settings", run: () => void calls.push("settings") }, + ], + }, + onClose() {}, + }), + ), + ); + for (const [id, label] of [ + ["page:back", "Back"], + ["page:go:settings", "Settings"], + ]) { + const button = document.querySelector(`[data-context-action='${id}']`)!; + assert.equal(button.getAttribute("aria-label"), label); + await act(() => + button.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true })), + ); + await wait(); + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null, label); + await act(() => button.focus()); + await wait(); + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null, label); + await act(async () => button.click()); + } + assert.deepEqual(calls, ["back", "settings"]); + } finally { + await act(() => root.unmount()); + dom.window.close(); + } +}); test("initial focus and a stationary pointer do not reveal last-playback details; exploration does", async () => { const dom = new JSDOM("
", { url: "https://fixture.invalid" }); diff --git a/tests/continue-card-actions.test.ts b/tests/continue-card-actions.test.ts index cd4c2e191..5bc3d8645 100644 --- a/tests/continue-card-actions.test.ts +++ b/tests/continue-card-actions.test.ts @@ -28,7 +28,7 @@ const item = (id: string, upNext = false) => ({ }, }); -test("older and Up Next entries each expose contextual continuation and removal independently from latest playback", async () => { +test("older and Up Next menus expose sources then removal while original playback remains independent", async () => { const { createContinueCardActions } = api(); let globalLatest = "newest-a"; const continued: any[] = []; @@ -50,10 +50,12 @@ test("older and Up Next entries each expose contextual continuation and removal }, }); const actions = commands.primary.actions(); - assert.equal(actions[0].label, "Continue watching"); - assert.equal(actions.length, 2, "primary continuation and alternate sources remain distinct"); - await actions[0].run(); - const remove = commands.extra.actions()[0]; + assert.deepEqual( + actions.map((action: any) => action.label), + ["Choose another source", "Remove from Continue Watching"], + ); + await commands.play(); + const remove = actions[1]; assert.equal(remove.label, "Remove from Continue Watching"); await remove.run(); assert.equal(commands.isValid(), false); @@ -96,10 +98,10 @@ test("a pending primary operation blocks original controls and menu commands; ac starts++; }, }); - const pending = commands.primary.actions()[0].run(); - assert.equal(commands.primary.actions()[0].disabled, true); + const pending = commands.play(); + assert.ok(commands.primary.actions().every((action: any) => action.disabled)); await assert.rejects(commands.play(), /already running/); - await assert.rejects(commands.extra.actions()[0].run(), /already running/); + await assert.rejects(commands.primary.actions()[1].run(), /already running/); current = false; finish(); await assert.rejects(pending, /no longer available/); @@ -129,7 +131,11 @@ test("harmless metadata rerender preserves a menu but replacement entry invalida assert.equal(commands.isValid(), false); await assert.rejects(commands.play(), /no longer available/); assert.equal(starts, 1); - assert.deepEqual(commands.extra.actions(), [], "no unsupported removal is invented"); + assert.deepEqual( + commands.primary.actions().map((action: any) => action.label), + ["Choose another source"], + "no unsupported removal or contextual play command is invented", + ); }); test("removal failure remains retryable while unavailable cards cannot continue or use another source", async () => { @@ -148,7 +154,12 @@ test("removal failure remains retryable while unavailable cards cannot continue if (fails) throw new Error("Storage unavailable"); }, }); - assert.ok(commands.primary.actions().every((action: any) => action.disabled)); + assert.equal(commands.primary.actions()[0].disabled, true); + assert.equal( + commands.primary.actions()[1].disabled, + false, + "future entries can still be removed", + ); await assert.rejects(commands.play()); await assert.rejects(commands.dismiss(), /Storage/); assert.equal(commands.isValid(), true); diff --git a/tests/continue-card-dismissal.test.ts b/tests/continue-card-dismissal.test.ts index db8083ea8..b5a542bf0 100644 --- a/tests/continue-card-dismissal.test.ts +++ b/tests/continue-card-dismissal.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFileSync } from "node:fs"; import ts from "typescript"; +import * as playbackHistorySource from "../src/lib/player/playback-history-source"; function harness() { const data = new Map(); @@ -71,6 +72,7 @@ function harness() { }, persistCritical() {}, }; + if (file === "player/playback-history-source") return playbackHistorySource; const mod = { exports: {} }; cache.set(file, mod.exports); const output = ts.transpileModule( diff --git a/tests/continue-card-interaction.test.ts b/tests/continue-card-interaction.test.ts index 782e71aad..92699a4ac 100644 --- a/tests/continue-card-interaction.test.ts +++ b/tests/continue-card-interaction.test.ts @@ -107,12 +107,15 @@ function harness() { animeCwIdsVersion: () => 0, subscribeAnimeCwIds: () => () => {}, }, - "@/lib/anime-tracker-entry": { resolveTrackerAnimeEntry: async () => null }, "@/lib/anime-detect": { isDetectedAnime: () => false }, "@/lib/streams/anime-identity": { resolveAnimeIdentity: async () => assert.fail("ordinary series must not resolve anime identity"), }, + "@/lib/anime-tracker-entry": { + resolveTrackerAnimeEntry: async () => + assert.fail("ordinary series must not resolve anime tracker identity"), + }, "@/lib/cw-anime-episode": { aniZipLookupKey: () => null, applyAniZipEpisode: empty, @@ -214,7 +217,7 @@ function harness() { }; } -test("real ContinueCard poster, title and play controls resolve the older entry and reuse primary continuation instead of the body source picker", async () => { +test("real ContinueCard keeps original playback while its menu groups sources and removal for the clicked entry", async () => { const h = harness(); try { await h.render(); @@ -227,16 +230,20 @@ test("real ContinueCard poster, title and play controls resolve the older entry ]) { await h.context(selector); assert.equal(h.target.meta.id, "older-b"); - assert.equal(h.target.primary.actions()[0].label, "Continue watching"); + assert.deepEqual( + h.target.primary.actions().map((action: any) => action.label), + ["Choose another source", "Remove from Continue Watching"], + ); + assert.equal(h.target.extra, undefined, "removal is not duplicated below the title actions"); await React.act(() => h.target.primary.actions()[0].run()); const request = h.starts.at(-1); assert.equal(request.meta.id, "older-b"); assert.deepEqual(request.episode, { season: 2, episode: 3 }); - assert.deepEqual(request.mode, { autoPlay: true, resume: true }); + assert.deepEqual(request.mode, { autoPlay: false, resume: false }); } await h.click("button[title='Play']"); assert.deepEqual(h.starts.at(-1).mode, { autoPlay: true, resume: true }); - await React.act(() => h.target.extra.actions()[0].run()); + await React.act(() => h.target.primary.actions()[1].run()); assert.equal(h.removed[0], h.item); assert.equal(h.target.isValid(), false); } finally { @@ -254,7 +261,7 @@ test("an open older-card menu cannot execute after a profile change", async () = await h.render(); assert.equal(target.isValid(), false); await assert.rejects(target.primary.actions()[0].run(), /no longer available/); - await assert.rejects(target.extra.actions()[0].run(), /no longer available/); + await assert.rejects(target.primary.actions()[1].run(), /no longer available/); assert.equal(h.starts.length, 0); assert.equal(h.removed.length, 0); } finally { @@ -273,7 +280,7 @@ test("original removal invalidates an already-open card menu and matches removal directItem = original.removed[0]; assert.equal(openMenu.isValid(), false); await assert.rejects(openMenu.primary.actions()[0].run(), /no longer available/); - await assert.rejects(openMenu.extra.actions()[0].run(), /no longer available/); + await assert.rejects(openMenu.primary.actions()[1].run(), /no longer available/); assert.equal(original.removed.length, 1); assert.equal(original.starts.length, 0); } finally { @@ -283,7 +290,7 @@ test("original removal invalidates an already-open card menu and matches removal try { await contextual.render(); await contextual.context("button[title='Play']"); - await React.act(() => contextual.target.extra.actions()[0].run()); + await React.act(() => contextual.target.primary.actions()[1].run()); assert.deepEqual(contextual.removed, [directItem]); } finally { await contextual.dispose(); diff --git a/tests/helpers/mpv-bridge-harness.ts b/tests/helpers/mpv-bridge-harness.ts index 27623947c..6fcf3c10f 100644 --- a/tests/helpers/mpv-bridge-harness.ts +++ b/tests/helpers/mpv-bridge-harness.ts @@ -9,6 +9,7 @@ import * as failures from "../../src/lib/player/mpv-failure.ts"; import * as translationJobs from "../../src/lib/subtitles/translation-jobs.ts"; import * as addedSubs from "../../src/lib/subtitles/added-subs.ts"; import * as language from "../../src/lib/subtitles/language.ts"; +import * as pauseTrace from "../../src/lib/player/pause-trace.ts"; import { SubtitlePreparationError as RealSubtitlePreparationError } from "../../src/lib/subtitles/prepare.ts"; import type { PlayerBridge, PlayerSnapshot } from "../../src/lib/player/bridge.ts"; import type { MpvOptions } from "../../src/lib/player/mpv.ts"; @@ -103,7 +104,6 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options isBlockedDiskCacheOptionLine: () => false, }, "@/lib/native-memory": { getTotalPhysBytes: () => totalPhysBytes }, - "./pause-trace": { traceFrameDrops() {}, tracePlayback() {} }, "@/lib/subtitles/prepare": { prepareSubtitle: (input: { url: string }) => prepare(input), SubtitlePreparationError: RealSubtitlePreparationError, @@ -120,6 +120,7 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options "@/lib/subtitles/added-subs": addedSubs, "@/lib/subtitles/language": language, "./mpv-failure": failures, + "./pause-trace": pauseTrace, "@/lib/platform": { isWindowsDesktop: () => true, isLinuxDesktop: () => false, diff --git a/tests/helpers/subtitle-memory-harness.ts b/tests/helpers/subtitle-memory-harness.ts index ea28cd8bc..2c6f44b4a 100644 --- a/tests/helpers/subtitle-memory-harness.ts +++ b/tests/helpers/subtitle-memory-harness.ts @@ -4,6 +4,10 @@ import ts from "typescript"; import * as memory from "../../src/lib/subtitles/subtitle-memory.ts"; import { normalizeLang } from "../../src/lib/subtitles/language.ts"; import { emptySnapshot, type PlayerSnapshot, type TrackInfo } from "../../src/lib/player/bridge.ts"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "../../src/lib/player/subtitle-choice"; export function subtitleTrack(id: string): TrackInfo { return { @@ -47,6 +51,14 @@ export function restoreHarness(remembered: memory.RememberedSub, tracks: TrackIn const scope = { ...memory, normalizeLang, + selectPlaybackSubtitle, + restorablePlaybackSubtitleChoice, + rememberedSession: null, + playbackActor: {}, + isPlaybackActorCurrent: () => true, + embeddedRestoreRef: { current: null }, + emitListToast() {}, + t: (key: string) => key, src: { url: "fixture.mkv", meta: { id: "fixture" }, @@ -90,6 +102,8 @@ export function restoreHarness(remembered: memory.RememberedSub, tracks: TrackIn } export function memoryControlsHarness() { + let actual: unknown = null; + const actualCachePatches: unknown[][] = []; const source = readFileSync( new URL("../../src/views/player/hooks/use-playback-controls.ts", import.meta.url), "utf8", @@ -133,6 +147,15 @@ export function memoryControlsHarness() { }, "@/lib/player/playback-clock": {}, "@/lib/media-session": { notifyMediaSeeked() {} }, + "@/lib/playback-history": { + capturePlaybackActor: () => ({}), + isPlaybackActorCurrent: () => true, + readActualPlaybackForSubtitle: () => actual, + cacheActualPlaybackSubtitle(...args: unknown[]) { + actualCachePatches.push(args); + return true; + }, + }, "@/lib/player-prefs": { writePlayerPrefs() {} }, "@/lib/subtitles/subtitle-memory": { ...memory, @@ -208,6 +231,10 @@ export function memoryControlsHarness() { }; } return { + actualCachePatches, + actualPlayback(value: unknown) { + actual = value; + }, mount, saved, cacheRequests, diff --git a/tests/last-playback-command.test.ts b/tests/last-playback-command.test.ts index b52b5e4fb..5fdf29792 100644 --- a/tests/last-playback-command.test.ts +++ b/tests/last-playback-command.test.ts @@ -158,11 +158,27 @@ async function fixture(target: ActualPlayback | null = last) { return { dom, state, calls, render, action, click, dispose }; } -test("empty actual history renders no last-playback command or separator", async () => { +test("empty actual history keeps a disabled last-playback command without a hide control", async () => { const f = await fixture(null); try { - assert.equal(document.querySelector("[data-context-action]"), null); + assert.equal(f.action()?.textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, true); + assert.equal(f.action().getAttribute("aria-disabled"), "true"); + assert.ok(f.action().querySelector("svg")); + assert.equal(f.action().querySelector("img"), null); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); assert.equal(document.querySelector("[role='separator']"), null); + await f.click(f.action()); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.state.closes, []); + f.state.target = last; + await f.render(); + assert.equal(f.action().disabled, false); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + f.state.target = null; + f.state.enabled = false; + await f.render(); + assert.equal(document.querySelector("[data-context-action]"), null); } finally { await f.dispose(); } @@ -173,7 +189,7 @@ test("a menu on B continues the actual A target and preserves its payload across try { f.state.clicked = "Different B"; await f.render(); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); await f.click(f.action()); assert.equal(f.calls.length, 1); assert.equal(f.calls[0].target, last); @@ -234,6 +250,24 @@ test("pending state disables activation and the shared runner rejects duplicate } }); +test("losing the target clears restart confirmation and leaves only the disabled command", async () => { + const f = await fixture({ ...last, completed: true }); + try { + await f.click(f.action()); + assert.ok(document.querySelector("[data-context-confirm]")); + f.state.target = null; + await f.render(); + assert.equal(f.action()?.textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, true); + assert.equal(document.querySelectorAll("[data-context-action]").length, 1); + assert.equal(document.querySelector("[data-context-confirm]"), null); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + assert.deepEqual(f.calls, []); + } finally { + await f.dispose(); + } +}); + test("missing, loading and broken artwork retain the real SVG fallback without changing the command", async () => { const f = await fixture({ ...last, @@ -254,7 +288,7 @@ test("missing, loading and broken artwork retain the real SVG fallback without c await React.act(async () => image.dispatchEvent(new f.dom.window.Event("error"))); assert.equal(f.action().querySelector("img"), null); assert.ok(f.action().querySelector(".context-menu-icon svg")); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); } finally { await f.dispose(); } @@ -296,7 +330,7 @@ test("standalone last-playback row preserves A and explicit EOF restart without const f = await fixture({ ...last, completed: true }); try { await f.render(); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); assert.equal(document.querySelectorAll("[data-context-action]").length, 1); assert.equal(document.querySelectorAll("[role=separator]").length, 0); assert.equal(f.action().closest("[data-context-quick]"), null); @@ -315,3 +349,19 @@ test("standalone last-playback row preserves A and explicit EOF restart without await f.dispose(); } }); + +test("a valid last-playback target has no dismissal command", async () => { + const f = await fixture(); + try { + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + assert.equal(document.querySelectorAll("[role='menuitem']").length, 1); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, false); + await f.click(f.action()); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].target, last); + assert.deepEqual(f.state.closes, [false]); + } finally { + await f.dispose(); + } +}); diff --git a/tests/last-playback-continuation-hook.test.ts b/tests/last-playback-continuation-hook.test.ts index 2147bafb0..1f62233f7 100644 --- a/tests/last-playback-continuation-hook.test.ts +++ b/tests/last-playback-continuation-hook.test.ts @@ -17,6 +17,7 @@ test("continuation runner owns pending work across actor changes and rejects sta let result: any; const releases: Array<() => void> = []; const opened: string[] = []; + let subscriptions = 0; const capture = () => ({ profileId: actorId, storageProfileId: actorId, accountId: actorId }); const dependencies: Record = { react: React, @@ -26,8 +27,12 @@ test("continuation runner owns pending work across actor changes and rejects sta "@/lib/playback-history": { capturePlaybackActor: capture, isPlaybackActorCurrent: (actor: any) => actor.profileId === actorId, + getPlaybackRevision: () => 0, readLastActualPlayback: () => ({ id: actorId }), - subscribePlayback: () => () => {}, + subscribePlayback: () => { + subscriptions += 1; + return () => subscriptions--; + }, }, "@/lib/player-actions": { currentPlayerActions: () => null }, "@/lib/player/continue-playback": { @@ -51,6 +56,7 @@ test("continuation runner owns pending work across actor changes and rejects sta if (!(id in dependencies)) throw new Error(id); return dependencies[id]; }, exports); + assert.equal(subscriptions, 0, "loading the hook module must not subscribe to history"); function Fixture() { result = exports.useLastPlaybackContinuation(); return null; @@ -59,6 +65,7 @@ test("continuation runner owns pending work across actor changes and rejects sta let unmounted = false; try { await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(subscriptions, 1); let first!: Promise; await React.act(async () => { first = result.continuePlayback(result.target).catch((error: Error) => error.message); @@ -89,6 +96,7 @@ test("continuation runner owns pending work across actor changes and rejects sta }); await React.act(async () => root.unmount()); unmounted = true; + assert.equal(subscriptions, 0, "unmounting the hook releases its history subscription"); releases[2](); assert.equal(await third, "stale"); assert.deepEqual(opened, ["two"]); @@ -97,3 +105,58 @@ test("continuation runner owns pending work across actor changes and rejects sta dom.window.close(); } }); + +test("target refreshes for scope identity changes while Stremio React identities stay unchanged", async () => { + const dom = new JSDOM("
"); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + let actor = { profileId: "one", storageProfileId: "one", accountId: "one" }; + let result: any; + const dependencies: Record = { + react: React, + "@/lib/auth": { useAuth: () => ({ user: { _id: "unchanged-stremio" } }) }, + "@/lib/profiles": { useProfiles: () => ({ activeProfile: { id: "unchanged-profile" } }) }, + "@/lib/view": { useView: () => ({}) }, + "@/lib/playback-history": { + capturePlaybackActor: () => actor, + getPlaybackRevision: () => 0, + readLastActualPlayback: () => ({ id: JSON.stringify(actor) }), + subscribePlayback: () => () => {}, + }, + "@/lib/player-actions": {}, + "@/lib/player/continue-playback": {}, + }; + const source = readFileSync( + new URL("../src/hooks/use-last-playback-continuation.ts", import.meta.url), + "utf8", + ); + const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports: any = {}; + new Function("require", "exports", code)((id: string) => { + assert.ok(id in dependencies, id); + return dependencies[id]; + }, exports); + function Fixture() { + result = exports.useLastPlaybackContinuation(); + return null; + } + const root = createRoot(document.querySelector("#app")!); + try { + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + actor = { ...actor, accountId: "two" }; + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + actor = { ...actor, profileId: "nuvio-2", storageProfileId: "nuvio-2" }; + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); diff --git a/tests/membership-actions.test.ts b/tests/membership-actions.test.ts index ae8c77921..a34f2d005 100644 --- a/tests/membership-actions.test.ts +++ b/tests/membership-actions.test.ts @@ -160,7 +160,7 @@ test("creating a list belongs to Add and opening creation does not change existi false, ); const add = findAction(actions, "membership:add"); - assert.equal(add.label, "Add to"); + assert.equal(add.label, "Add to list"); const createIndex = add.children.findIndex((action) => action.id === "membership:create-list"); assert.equal(add.children[createIndex].sectionLabel, "Lists"); assert.equal(add.children[createIndex].group, "create"); diff --git a/tests/membership-controls.test.ts b/tests/membership-controls.test.ts index a96046e41..ca3346a17 100644 --- a/tests/membership-controls.test.ts +++ b/tests/membership-controls.test.ts @@ -135,6 +135,7 @@ test("membership creation cancels and saves back into the same stable destinatio }); try { await React.act(() => root.render(render())); + assert.equal(action("membership:add").textContent?.trim(), "Add to list"); await click(action("membership:add")); const chooser = document.querySelector("[data-context-submenu]"); const initial = localStorage.getItem(key); diff --git a/tests/menu-brand-footer.test.ts b/tests/menu-brand-footer.test.ts index d85a19fb7..8681b5510 100644 --- a/tests/menu-brand-footer.test.ts +++ b/tests/menu-brand-footer.test.ts @@ -8,7 +8,13 @@ import ts from "typescript"; import { HarborMark } from "../src/components/icons/harbor-mark.tsx"; test("root branding uses the real mark, preserves custom assets, and adds no command", async () => { - const dom = new JSDOM("
"); + const dom = new JSDOM("
"); + const style = dom.window.document.createElement("style"); + style.textContent = readFileSync( + new URL("../src/components/context-menu/menu-brand-footer.css", import.meta.url), + "utf8", + ); + dom.window.document.head.append(style); Object.assign(globalThis, { window: dom.window, document: dom.window.document, @@ -47,11 +53,26 @@ test("root branding uses the real mark, preserves custom assets, and adds no com ); const { createRoot } = await import("react-dom/client"); const root = createRoot(document.getElementById("root")!); + const otherRoot = createRoot(document.getElementById("other")!); const render = () => React.act(() => root.render(React.createElement(module.exports.MenuBrandFooter))); try { await render(); const footer = document.querySelector("[data-context-brand-footer]")!; + await React.act(() => otherRoot.render(React.createElement(HarborMark))); + for (const layout of ["classic", "dracula", "rail", "stremio"]) { + document.documentElement.setAttribute("data-theme-layout", layout); + assert.equal( + dom.window.getComputedStyle(footer).color, + "rgb(235, 160, 24)", + `menu branding keeps its approved color in ${layout}`, + ); + } + assert.notEqual( + dom.window.getComputedStyle(document.querySelector("#other svg")!).color, + "rgb(235, 160, 24)", + "the shared Harbor mark outside a menu is not recolored", + ); assert.equal(footer.getAttribute("aria-hidden"), "true"); assert.equal(footer.querySelector("svg")?.getAttribute("viewBox"), "0 0 700 642.88"); assert.equal(footer.textContent, ""); @@ -68,18 +89,25 @@ test("root branding uses the real mark, preserves custom assets, and adds no com for (const img of imgs) { assert.equal(img.alt, ""); assert.equal(img.draggable, false); - assert.equal(img.style.filter, ""); assert.equal(img.style.maskImage, ""); } + const filterId = /^url\(["']?#([^"')]+)["']?\)$/.exec(imgs[0].style.filter)?.[1]; + assert.ok(filterId, "the custom mark uses the footer's alpha-preserving color filter"); + const tint = document.getElementById(filterId)!; + assert.equal(tint.querySelector("feFlood")?.getAttribute("flood-color"), "#EBA018"); + assert.equal(tint.querySelector("feComposite")?.getAttribute("in2"), "SourceAlpha"); + assert.equal(tint.querySelector("feComposite")?.getAttribute("operator"), "in"); + assert.equal(imgs[1].style.filter, "", "the wordmark preserves its own colors"); await React.act(() => imgs[0].dispatchEvent(new dom.window.Event("error"))); - assert.ok(footer.querySelector("svg")); + assert.ok(footer.querySelector("svg.context-menu-brand-mark")); assert.equal(footer.querySelectorAll("img").length, 1); logo = { mark: "data:image/svg+xml,next-fixture-mark", wordmark: null }; await render(); - assert.equal(footer.querySelector("svg"), null); + assert.equal(footer.querySelector("svg.context-menu-brand-mark"), null); assert.equal(footer.querySelector("img")?.getAttribute("src"), logo.mark); } finally { await React.act(() => root.unmount()); + await React.act(() => otherRoot.unmount()); dom.window.close(); } }); diff --git a/tests/playback-audio-autoload.test.ts b/tests/playback-audio-autoload.test.ts index b22277b2d..b44d3c2da 100644 --- a/tests/playback-audio-autoload.test.ts +++ b/tests/playback-audio-autoload.test.ts @@ -6,6 +6,9 @@ import { JSDOM } from "jsdom"; import * as React from "react"; import { createRoot } from "react-dom/client"; import * as audioChoice from "../src/lib/player/audio-choice"; +import * as playbackHistory from "../src/lib/playback-history"; +import * as continuation from "../src/lib/player/continue-playback"; +import * as subtitleChoice from "../src/lib/player/subtitle-choice"; import * as language from "../src/lib/subtitles/language"; import { initialPlayerSnapshot, type TrackInfo } from "../src/lib/player/bridge"; @@ -43,6 +46,7 @@ async function fixture(remembered = true) { IS_REACT_ACT_ENVIRONMENT: true, }); const calls: string[] = []; + const errors: string[] = []; const bridgeRef = { current: { setAudioTrack: (id: string) => { @@ -69,10 +73,17 @@ async function fixture(remembered = true) { "@/components/player/subtitle-menu/subtitle-context-store": { publishSubtitleContext() {} }, "@/lib/player-prefs": { readPlayerPrefs: () => ({ audioLang: "eng", subsOff: true }) }, "@/lib/playback-history": { - playbackSourceKey: (src: { url: string }) => src.url, + ...playbackHistory, readActualPlaybackFor: () => null, }, "@/lib/player/audio-choice": audioChoice, + "@/lib/player/continue-playback": continuation, + "@/lib/player/subtitle-choice": subtitleChoice, + "@/components/lists/list-toast": { + emitListToast: (message: string) => errors.push(message), + }, + "@/lib/i18n": { t: (message: string) => message }, + "@/lib/player/mpv-properties": {}, "@/lib/providers/tmdb": {}, "@/lib/subtitles/addon-source": { gatherSubtitleAddons: async () => [] }, "@/lib/streams/stream-ids": { buildStreamIds: () => [] }, @@ -112,7 +123,11 @@ async function fixture(remembered = true) { url: "C:/synthetic/audio.mkv", meta: { id: "local:fixture", type: "movie", name: "Fixture" }, continuation: remembered - ? { sourceKey: "C:/synthetic/audio.mkv", audioTrack: saved } + ? { + sourceKey: "C:/synthetic/audio.mkv", + actor: playbackHistory.capturePlaybackActor(), + audioTrack: saved, + } : undefined, }; const settings = { @@ -123,25 +138,33 @@ async function fixture(remembered = true) { audioNormalize: false, audioProfile: "off", }; - function Fixture({ audioTracks }: { audioTracks: TrackInfo[] }) { + function Fixture({ + audioTracks, + firstFrameReady, + }: { + audioTracks: TrackInfo[]; + firstFrameReady: boolean; + }) { exports.useTrackAutoload({ bridgeRef, src, settings, engine: "mpv", authKey: null, - snap: { ...initialPlayerSnapshot(), durationSec: 360, audioTracks }, + snap: { ...initialPlayerSnapshot(), durationSec: 360, audioTracks, firstFrameReady }, }); return null; } const root = createRoot(document.getElementById("root")!); - const render = async (audioTracks: TrackInfo[]) => - React.act(async () => root.render(React.createElement(Fixture, { audioTracks }))); + const render = async (audioTracks: TrackInfo[], firstFrameReady = false) => + React.act(async () => + root.render(React.createElement(Fixture, { audioTracks, firstFrameReady })), + ); const dispose = async () => { await React.act(async () => root.unmount()); dom.window.close(); }; - return { render, dispose, calls }; + return { render, dispose, calls, errors }; } test("an exact remembered Commentary choice overrides automatic block words", async () => { @@ -214,3 +237,30 @@ test("late metadata cannot overwrite a newer manual audio selection", async () = await f.dispose(); } }); + +test("reordered numeric IDs restore the remembered semantic audio track", async () => { + const f = await fixture(); + try { + await f.render(tracks.map((track) => ({ ...track, id: track.id === "1" ? "2" : "1" }))); + assert.deepEqual(f.calls, ["1"]); + assert.deepEqual(f.errors, []); + } finally { + await f.dispose(); + } +}); + +test("unavailable remembered audio reports once after decoding, without claiming restoration", async () => { + const f = await fixture(); + try { + await f.render([tracks[0]]); + assert.deepEqual(f.errors, []); + await f.render([tracks[0]], true); + await f.render([tracks[0]], true); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.errors, [ + "The saved audio track could not be restored. Choose an audio track.", + ]); + } finally { + await f.dispose(); + } +}); diff --git a/tests/playback-continuation.test.ts b/tests/playback-continuation.test.ts index 2c7301da1..67d17214f 100644 --- a/tests/playback-continuation.test.ts +++ b/tests/playback-continuation.test.ts @@ -4,6 +4,7 @@ import { continueActualPlayback, continuationFor, continuationStream, + continuationAppliesToSource, type ContinuationServices, } from "../src/lib/player/continue-playback"; import type { ActualPlayback } from "../src/lib/playback-history"; @@ -138,3 +139,117 @@ test("home-server preparation receives actual position and explicit restart zero await continueActualPlayback(target, { restart: true }, api); assert.deepEqual(positions, [45000, 0]); }); + +test("home-server continuation rebinds a saved subtitle ID to fresh authenticated transport", async () => { + const target = fixture(); + target.src.url = ""; + target.src.homeServer = { + connectionId: "server", + itemId: "item", + versionId: "version", + quality: "original", + }; + target.subtitle = { external: true, homeServerSubtitleId: "arabic", lang: "ara" }; + target.audioTrack = { lang: "eng", title: "English" }; + target.subDelaySec = 1.25; + target.audioDelaySec = -0.2; + const { api, calls } = services(target); + const freshSubtitle = "https://server.test/subtitle.srt?api_key=fixture-fresh-token"; + api.prepareServer = async (src) => ({ + ...src, + url: "https://server.test/fresh-video", + headers: { Authorization: "fixture-fresh-authorization" }, + subtitles: [{ id: "arabic", lang: "ara", url: freshSubtitle }], + }); + await continueActualPlayback(target, {}, api); + const restored = (calls[0] as [string, any])[1]; + assert.equal(restored.continuation.subtitle.source, freshSubtitle); + assert.deepEqual(restored.headers, { Authorization: "fixture-fresh-authorization" }); + assert.equal(restored.startPositionMs, target.positionMs); + assert.deepEqual(restored.continuation.audioTrack, target.audioTrack); + assert.equal(restored.continuation.subDelaySec, 1.25); + assert.equal(restored.continuation.audioDelaySec, -0.2); + assert.equal(target.subtitle.source, undefined, "transport renewal never mutates saved history"); +}); + +test("a missing saved server subtitle is not replaced with another subtitle URL", async () => { + const target = fixture(); + target.src.url = ""; + target.src.homeServer = { + connectionId: "server", + itemId: "item", + versionId: "version", + quality: "original", + }; + target.subtitle = { external: true, homeServerSubtitleId: "missing", lang: "ara" }; + const { api, calls } = services(target); + api.prepareServer = async (src) => ({ + ...src, + url: "https://server.test/fresh-video", + subtitles: [{ id: "different", lang: "ara", url: "https://server.test/another.srt" }], + }); + await continueActualPlayback(target, {}, api); + const restored = (calls[0] as [string, any])[1]; + assert.equal(restored.continuation.subtitle.homeServerSubtitleId, "missing"); + assert.equal(restored.continuation.subtitle.source, undefined); +}); + +test("continuation carries scoped subtitle choice and delays through source preparation", async () => { + const target: ActualPlayback = { + ...fixture(), + subtitle: { off: true }, + subDelaySec: 1.5, + audioDelaySec: -0.25, + }; + const { api, calls } = services(target); + await continueActualPlayback(target, {}, api); + const continued = (calls[0] as [string, { continuation: ReturnType }])[1] + .continuation; + assert.deepEqual(continued.subtitle, { off: true }); + assert.equal(continued.subDelaySec, 1.5); + assert.equal(continued.audioDelaySec, -0.25); +}); + +test("refreshed URLs restore exact session state only for the same title, episode, and verified file metadata", () => { + const target = fixture(); + target.src.url = "https://fixture.invalid/old-token"; + target.src.meta.type = "series"; + target.src.episode = { season: 2, episode: 3 }; + target.src.streamRef = { + addonId: "fixture-addon", + parsedTitle: "Series.S02E03.1080p.RELEASE", + size: 1234567, + resolution: "1080p", + source: "WEB", + fileIdx: 2, + }; + const context = continuationFor(target, target.actor); + const renewed = { ...target.src, url: "https://fixture.invalid/new-token" }; + assert.equal(continuationAppliesToSource(renewed, context), true); + assert.equal( + continuationAppliesToSource({ ...renewed, meta: { ...renewed.meta, id: "other" } }, context), + false, + ); + assert.equal( + continuationAppliesToSource({ ...renewed, episode: { season: 2, episode: 4 } }, context), + false, + ); + assert.equal( + continuationAppliesToSource( + { ...renewed, streamRef: { ...renewed.streamRef, fileIdx: 4 } }, + context, + ), + false, + ); + assert.equal( + continuationAppliesToSource( + { ...renewed, streamRef: { ...renewed.streamRef, size: undefined } }, + context, + ), + false, + ); + assert.equal( + continuationAppliesToSource({ ...renewed, streamRef: { addonId: "fixture-addon" } }, context), + false, + ); +}); diff --git a/tests/playback-history-credentials.test.ts b/tests/playback-history-credentials.test.ts new file mode 100644 index 000000000..c95e6e09d --- /dev/null +++ b/tests/playback-history-credentials.test.ts @@ -0,0 +1,561 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import * as history from "../src/lib/playback-history"; +import type { PlayerSrc } from "../src/lib/view"; +import { + continueActualPlayback, + continuationAppliesToSource, + continuationFor, + type ContinuationServices, +} from "../src/lib/player/continue-playback"; + +const values = new Map(); +let failWrite = false; +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + if (failWrite) throw new Error("synthetic storage failure"); + values.set(key, value); + }, + removeItem: (key: string) => values.delete(key), + }, +}); + +const storageKey = "harbor.playback-history.v1.one"; +const actor = { profileId: "one", storageProfileId: "one", accountId: null }; +const snapshot = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, + audioTracks: [{ id: "2", kind: "audio" as const, label: "Arabic", lang: "ara", selected: true }], + subDelaySec: 1.25, + audioDelaySec: -0.2, + subtitleFps: 23.976, +}; + +function configure(privateCw = false): void { + values.clear(); + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId: "one", + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: privateCw })); +} + +function serverSource(provider: string, query: string): PlayerSrc { + const credential = `synthetic-${provider}-credential`; + const url = `https://${provider}.invalid/video?${query}=${credential}`; + return { + meta: { + id: "tt-synthetic", + type: "series", + name: "Synthetic series", + poster: `https://${provider}.invalid/poster?${query}=${credential}`, + background: `https://synthetic-user:${credential}@${provider}.invalid/background`, + logo: "https://public.invalid/logo.png", + adult: true, + addonOrigin: { id: "synthetic-addon", name: "Synthetic addon", base: url, logo: url }, + videos: [{ season: 2, episode: 3, thumbnail: url, streams: [{ url }] }], + }, + episode: { + season: 2, + episode: 3, + name: "Synthetic episode", + still: url, + sourceMetaId: "tt-source", + }, + url, + historyUrl: url, + title: "Synthetic video", + headers: { Authorization: credential, "X-Emby-Token": credential, "X-Plex-Token": credential }, + homeServer: { + connectionId: `synthetic-${provider}-connection`, + itemId: "synthetic-item", + versionId: "synthetic-version", + quality: "original", + playbackSessionId: `synthetic-${provider}-temporary-session`, + }, + subtitles: [{ id: "stable-subtitle-9", url: `${url}&subtitle=9`, lang: "ara" }], + subtitlePreselect: { off: false, url: `${url}&subtitle=old` }, + playbackTraceId: "synthetic-temporary-trace", + proxySessionId: "synthetic-temporary-proxy", + streamRef: { infoHash: "abc", fileIdx: 4, resolvedFilename: "Synthetic.S02E03.mkv" }, + }; +} + +function observed(src: PlayerSrc, at = Date.now()): history.ActualPlayback { + return actualPlaybackSnapshot( + src, + { + ...snapshot, + subtitleTracks: [ + { + id: "temporary-native-7", + kind: "subtitle", + label: "Arabic", + lang: "ara", + selected: true, + external: true, + url: src.subtitles?.[0]?.url, + }, + ], + }, + 30, + { id: "synthetic-observed-session", actor: history.capturePlaybackActor() }, + at, + )!; +} + +for (const [provider, query] of [ + ["jellyfin", "api_key"], + ["emby", "X-Emby-Token"], + ["plex", "X-Plex-Token"], +]) { + test(`${provider} observed playback persists identity and choices without authenticated transport`, () => { + configure(); + const src = serverSource(provider, query); + const before = JSON.stringify(src); + const playback = observed(src); + assert.equal(playback.src.url, ""); + assert.deepEqual(playback.src.homeServer, { + connectionId: `synthetic-${provider}-connection`, + itemId: "synthetic-item", + versionId: "synthetic-version", + quality: "original", + }); + assert.equal(playback.subtitle?.homeServerSubtitleId, "stable-subtitle-9"); + assert.equal(playback.subtitle?.source, undefined); + assert.equal(playback.subtitle?.trackId, undefined); + assert.equal(playback.subtitle?.fpsOverride, 23.976); + assert.equal(playback.subDelaySec, 1.25); + assert.equal(playback.audioDelaySec, -0.2); + assert.equal(playback.audioTrack?.lang, "ara"); + assert.equal(playback.src.episode?.sourceMetaId, "tt-source"); + assert.equal(playback.src.meta.logo, undefined, "an arbitrary host is not public provenance"); + assert.equal(playback.src.meta.adult, true); + assert.equal(playback.src.meta.addonOrigin?.id, "synthetic-addon"); + assert.match(playback.src.meta.addonOrigin?.configurationKey ?? "", /^[a-f0-9]{64}$/); + assert.equal(history.recordActualPlayback(playback), true); + const serialized = values.get(storageKey)!; + assert.doesNotMatch( + serialized, + /credential|temporary-|Authorization|X-Emby-Token|X-Plex-Token|playbackSessionId|subtitlePreselect/, + ); + assert.equal(history.readLastActualPlayback()?.id, playback.id); + assert.equal(history.readActualPlaybackFor(src)?.id, playback.id); + assert.equal(JSON.stringify(src), before, "snapshot must not mutate runtime transport"); + }); +} + +test("read boundaries scrub legacy shared and private actual slots without changing progress or ownership", () => { + for (const legacyKey of ["harbor.playback-history.v1", storageKey]) { + configure(true); + const src = serverSource("legacy", "access_token"); + const at = Date.now() - 1000; + const base = { + id: "legacy-one", + actor: { ...actor, privateCw: true }, + playedAt: at, + positionMs: 31000, + durationMs: 400000, + completed: false, + src, + requiresSourceRefresh: true, + subtitle: { + source: src.subtitles![0].url, + external: true, + trackId: "synthetic-temporary-native", + fpsOverride: 25, + }, + subDelaySec: 2, + }; + const other = { + ...base, + id: "legacy-two", + actor: { ...base.actor, profileId: "two" }, + playedAt: at + 1, + }; + values.set( + legacyKey, + JSON.stringify({ + "tt-synthetic|s2e3": { + savedAt: at + 2, + title: "Existing title", + actual: other, + actualByProfile: { one: { actual: base }, two: { actual: other } }, + }, + unrelated: { savedAt: at, title: "Keep unrelated history" }, + }), + ); + const saved = history.readLastActualPlayback(); + assert.equal(saved?.id, "legacy-one"); + assert.equal(saved?.positionMs, 31000); + assert.equal(saved?.playedAt, at); + assert.equal(saved?.subtitle?.homeServerSubtitleId, "stable-subtitle-9"); + const serialized = values.get(storageKey)!; + assert.doesNotMatch( + serialized, + /credential|temporary-|playbackSessionId|Authorization|subtitlePreselect/, + ); + const entries = JSON.parse(serialized); + assert.equal(entries["tt-synthetic|s2e3"].actual.id, "legacy-two"); + assert.equal(entries["tt-synthetic|s2e3"].actualByProfile.two.actual.playedAt, at + 1); + assert.equal(entries["tt-synthetic|s2e3"].savedAt, at + 2); + assert.equal(entries.unrelated.title, "Keep unrelated history"); + } +}); + +test("credentialed generic sources refresh through a known release instead of persisting credentials", () => { + configure(); + const src: PlayerSrc = { + meta: { + id: "tt-public", + type: "movie", + name: "Public", + poster: "https://art.invalid/image?token=synthetic-generic-credential", + }, + title: "Public", + url: "https://synthetic-user:synthetic-generic-credential@video.invalid/movie", + streamRef: { addonId: "addon", parsedTitle: "Public.2026", size: 42 }, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "generic", actor })!; + assert.equal(history.recordActualPlayback(playback), true); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "generic"); + assert.equal(history.actualPlaybackSourceKey(playback), history.playbackSourceKey(src)); + const context = continuationFor(playback, actor); + assert.equal( + continuationAppliesToSource({ ...src, url: "https://public.invalid/renewed" }, context), + true, + ); + assert.equal( + continuationAppliesToSource( + { ...src, url: "https://public.invalid/renewed", streamRef: { ...src.streamRef, size: 43 } }, + context, + ), + false, + ); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-generic-credential|synthetic-user/); + assert.equal( + history.recordActualPlayback({ + ...playback, + sourceFingerprint: undefined, + src: { ...playback.src, streamRef: undefined }, + }), + false, + "empty transport without recoverable identity is invalid", + ); +}); + +test("unclassified direct sources retain fingerprints while local sources and subtitle choices remain durable", () => { + for (const url of ["https://public.invalid/movie.mp4?quality=1080", "C:/synthetic/movie.mp4"]) { + configure(); + const src: PlayerSrc = { + meta: { id: "local:synthetic", type: "movie", name: "Synthetic" }, + title: "Synthetic", + url, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "session", actor })!; + assert.equal(history.recordActualPlayback({ ...playback, subtitle: { off: true } }), true); + assert.equal(history.readLastActualPlayback()?.src.url, url.startsWith("https:") ? "" : url); + assert.equal(history.readActualPlaybackFor(src)?.id, "session"); + assert.deepEqual(history.readLastActualPlayback()?.subtitle, { off: true }); + for (const source of ["C:/synthetic/sub.srt", "https://public.invalid/sub.vtt?lang=en"]) { + assert.equal( + history.recordActualPlayback({ + ...playback, + subtitle: { external: true, source, fpsOverride: 25 }, + }), + true, + ); + assert.equal( + history.readLastActualPlayback()?.subtitle?.source, + source.startsWith("https:") ? undefined : source, + ); + if (source.startsWith("https:")) + assert.ok(history.readLastActualPlayback()?.subtitle?.sourceFingerprint); + } + } +}); + +test("history write boundary sanitizes raw snapshots and a failed security rewrite still returns safe data", () => { + configure(); + const src = serverSource("write", "token"); + const raw = { + id: "raw", + actor, + playedAt: Date.now(), + positionMs: 30000, + durationMs: 400000, + completed: false, + src, + subtitle: { external: true, source: src.subtitles![0].url }, + }; + assert.equal(history.recordActualPlayback(raw), true); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); + values.set( + storageKey, + JSON.stringify({ "tt-synthetic|s2e3": { savedAt: raw.playedAt, actual: raw } }), + ); + failWrite = true; + try { + const safe = history.readLastActualPlayback(); + assert.equal(safe?.id, "raw"); + assert.doesNotMatch( + JSON.stringify(safe), + /credential|temporary-|playbackSessionId|Authorization/, + ); + assert.equal(history.recordActualPlayback({ ...raw, id: "failed" }), false); + } finally { + failWrite = false; + } + assert.equal(history.readLastActualPlayback()?.id, "raw"); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); +}); + +test("expired actual records cannot leave old credentials behind when history is read", () => { + configure(); + const expired = { + id: "expired", + actor, + playedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + positionMs: 30000, + durationMs: 400000, + completed: false, + src: serverSource("expired", "api_key"), + }; + values.set( + storageKey, + JSON.stringify({ "tt-synthetic|s2e3": { savedAt: expired.playedAt, actual: expired } }), + ); + assert.equal(history.readLastActualPlayback(), null); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); +}); + +test("generic credential query spellings cannot survive in artwork, episode fields, or subtitle choices", () => { + for (const query of [ + "Api_Key", + "access_token", + "auth", + "X-Plex-Token", + "X-Amz-Credential", + "signature", + ]) { + configure(); + const secret = `https://synthetic.invalid/resource?${query}=synthetic-query-credential`; + const src: PlayerSrc = { + meta: { + id: "tt-public", + type: "movie", + name: "Public", + poster: secret, + providerBadge: { name: "Public", logo: secret, tint: "blue" }, + }, + title: "Public", + url: "https://public.invalid/movie.mp4", + episode: { season: 1, episode: 2, still: secret }, + }; + const playback = actualPlaybackSnapshot( + src, + { + ...snapshot, + subtitleTracks: [ + { + id: "1", + kind: "subtitle", + label: "English", + selected: true, + external: true, + originalUrl: secret, + }, + ], + }, + 20, + { id: "public", actor }, + )!; + assert.equal(history.recordActualPlayback(playback), true); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-query-credential/); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, undefined); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + } +}); + +test("known credentialed source preference URLs are scrubbed on both saves and existing history reads", () => { + configure(); + history.savePlayback("tt-secret", { + addonId: "synthetic-addon", + url: "https://synthetic.invalid/video?api_key=synthetic-preference-credential", + title: "Keep source identity", + }); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-preference-credential/); + assert.equal(history.readPlayback("tt-secret")?.addonId, "synthetic-addon"); + values.set( + storageKey, + JSON.stringify({ + "tt-secret": { + addonId: "synthetic-addon", + url: "https://synthetic-user:synthetic-legacy-preference-credential@synthetic.invalid/video", + savedAt: Date.now(), + }, + "tt-public": { url: "https://public.invalid/video", savedAt: Date.now() }, + }), + ); + assert.equal(history.readPlayback("tt-secret")?.url, undefined); + assert.doesNotMatch( + values.get(storageKey)!, + /synthetic-legacy-preference-credential|synthetic-user/, + ); + assert.equal(history.readPlayback("tt-public")?.url, undefined); + assert.ok(history.readPlayback("tt-public")?.urlFingerprint); +}); + +test("a normal URL requiring runtime headers is refreshed instead of replayed without authentication", () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-header", type: "movie", name: "Header source" }, + title: "Header source", + url: "https://synthetic.invalid/video", + headers: { Authorization: "synthetic-header-credential" }, + streamRef: { addonId: "synthetic-addon", parsedTitle: "Synthetic.2026", size: 42 }, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "headers", actor })!; + assert.equal(playback.requiresSourceRefresh, true); + assert.equal(history.recordActualPlayback(playback), true); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-header-credential|Authorization/); + assert.equal( + history.recordActualPlayback({ ...playback, requiresSourceRefresh: false, src }), + true, + "write boundary must also recognize raw headers", + ); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); +}); + +test("an opaque signed source remains the latest session without retaining its credentialed URL", () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-opaque", type: "movie", name: "Opaque source" }, + title: "Opaque source", + url: "https://synthetic.invalid/video?token=synthetic-opaque-credential", + }; + const at = Date.now(); + const previous = actualPlaybackSnapshot( + { ...src, meta: { ...src.meta, id: "tt-older" }, url: "https://public.invalid/older" }, + snapshot, + 10, + { id: "older", actor }, + at - 1000, + )!; + assert.equal(history.recordActualPlayback(previous), true); + const playback = actualPlaybackSnapshot(src, snapshot, 45, { id: "opaque", actor }, at)!; + assert.equal(history.recordActualPlayback(playback), true); + const saved = history.readLastActualPlayback()!; + assert.equal(saved.id, "opaque"); + assert.equal(saved.src.url, ""); + assert.equal(saved.requiresSourceRefresh, true); + assert.equal(saved.positionMs, 45000); + assert.equal(history.readActualPlaybackFor(src)?.id, "opaque"); + const context = continuationFor(saved, actor); + assert.equal(continuationAppliesToSource(src, context), true); + assert.equal( + continuationAppliesToSource({ ...src, url: src.url + "-different" }, context), + false, + ); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-opaque-credential|https:\/\/synthetic/); + assert.equal(history.readLastActualPlayback()?.id, "opaque", "sanitized rereads keep identity"); +}); + +test("empty source keys never establish that an unknown source matches a continuation", () => { + const src: PlayerSrc = { + meta: { id: "tt-empty", type: "movie", name: "Empty" }, + title: "Empty", + url: "", + }; + assert.equal( + continuationAppliesToSource(src, { + id: "empty", + actor, + positionMs: 45000, + sourceKey: "", + source: { savedAt: Date.now() }, + target: { metaId: src.meta.id }, + }), + false, + ); +}); + +test("a redacted source returns to its live session or opens the picker with its saved choices", async () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-return", type: "movie", name: "Return" }, + title: "Return", + url: "https://synthetic.invalid/video?token=synthetic-return-credential", + }; + assert.equal( + history.recordActualPlayback( + actualPlaybackSnapshot(src, snapshot, 45, { id: "return", actor })!, + ), + true, + ); + const target = history.readLastActualPlayback()!; + let returned = 0; + let picked = 0; + const services: ContinuationServices = { + isCurrent: history.isPlaybackActorCurrent, + latest: history.readLastActualPlayback, + active: () => ({ + src, + resume: async () => { + returned++; + }, + }), + localFileExists: async () => { + throw new Error("not a local source"); + }, + prepareServer: async () => { + throw new Error("not a server source"); + }, + openPlayer: () => { + throw new Error("redacted URL must not reopen"); + }, + openPicker: (_meta, _episode, options) => { + picked++; + assert.equal(options.autoPlay, false); + assert.equal(options.continuation.positionMs, 45000); + assert.deepEqual(options.continuation.audioTrack, target.audioTrack); + assert.deepEqual(options.continuation.subtitle, { off: true }); + assert.equal(options.continuation.subDelaySec, 1.25); + assert.equal(options.continuation.audioDelaySec, -0.2); + assert.doesNotMatch(JSON.stringify(options), /synthetic-return-credential/); + }, + }; + assert.equal(await continueActualPlayback(target, {}, services), "returned"); + assert.equal(returned, 1); + assert.equal( + await continueActualPlayback(target, {}, { ...services, active: () => null }), + "picker-opened", + ); + assert.equal(picked, 1); +}); diff --git a/tests/playback-history-provenance.test.ts b/tests/playback-history-provenance.test.ts new file mode 100644 index 000000000..a28fd7ce1 --- /dev/null +++ b/tests/playback-history-provenance.test.ts @@ -0,0 +1,514 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as history from "../src/lib/playback-history"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import { persistablePlaybackUrl } from "../src/lib/player/playback-history-source"; +import { + continueActualPlayback, + continuationAppliesToSource, + continuationFor, + type ContinuationServices, +} from "../src/lib/player/continue-playback"; +import { subtitleMediaKey, subtitleStreamKey } from "../src/lib/subtitles/subtitle-memory"; +import type { PlayerSrc } from "../src/lib/view"; +import { readSeasonLock, saveSeasonLock } from "../src/lib/season-lock"; +import { selectPlaybackSubtitle } from "../src/lib/player/subtitle-choice"; + +const values = new Map(); +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, +}); +const storageKey = "harbor.playback-history.v1.one"; +const actor = { profileId: "one", storageProfileId: "one", accountId: null }; +const snap = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, +}; +const urls = [ + "https://media.example/v1/key/PATHSECRET123/play.m3u8", + "https://media.example/watch?accessCode=QUERYSECRET123", + "https://proxy.example/watch?url=https%3A%2F%2Fcdn.example%2Fvideo%3Ftoken%3DINNERSECRET", +]; +function configure() { + values.clear(); + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId: "one", + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); +} +function source(url = urls[0]): PlayerSrc { + return { + meta: { id: "tt-provenance", type: "movie", name: "A real title" }, + title: "A real title", + url, + streamRef: { addonId: "synthetic-addon", parsedTitle: "A.Real.Title.2026", size: 42 }, + }; +} +function recorded(src = source(), id = "observed") { + return actualPlaybackSnapshot(src, snap, 45, { id, actor })!; +} + +for (const [index, url] of urls.entries()) { + test(`unclassified transport case ${index + 1} cannot enter history through URL or text fields`, () => { + configure(); + assert.equal(persistablePlaybackUrl(url), undefined); + const src = source(url); + src.meta.poster = url; + src.meta.providerBadge = { name: "Provider", logo: url }; + src.episode = { season: 1, episode: 2, name: url, still: url }; + assert.equal( + history.recordActualPlayback({ + ...recorded(src), + subtitle: { external: true, source: url, title: url }, + }), + true, + ); + history.savePlayback(src.meta.id, { + url, + title: url, + parsedTitle: encodeURIComponent(url), + unexpectedTransport: url, + } as any); + const actual = history.readLastActualPlayback()!; + assert.equal(actual.positionMs, 45000); + assert.equal(actual.src.url, ""); + assert.equal(actual.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); + assert.doesNotMatch( + values.get(storageKey)!, + /PATHSECRET123|QUERYSECRET123|INNERSECRET|unexpectedTransport/, + ); + }); +} + +test("an ordinary-looking HTTP URL is not evidence of public provenance", () => { + configure(); + const src = source("https://media.example/movies/a-film.mp4"); + assert.equal(history.recordActualPlayback(recorded(src)), true); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); +}); + +test("scoped legacy history scrubs transport copies, unknown properties and URL-bearing map keys", () => { + configure(); + const url = urls[1]; + const id = `url:${url}`; + const src = source(url); + src.meta.id = id; + src.meta.name = "QUERYSECRET123"; + src.title = "QUERYSECRET123"; + values.set( + storageKey, + JSON.stringify({ + [id]: { + savedAt: Date.now(), + url, + title: url, + extra: { url }, + actual: { ...recorded(src), src, unexpectedTransport: url }, + actualByProfile: { one: { actual: { ...recorded(src), src }, unexpectedTransport: url } }, + }, + }), + ); + const saved = history.readLastActualPlayback()!; + assert.ok(saved); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123|https:|unexpectedTransport/); + assert.equal(history.readActualPlaybackFor(src)?.id, saved.id); + assert.equal(continuationAppliesToSource(src, continuationFor(saved, actor)), true); + assert.equal(history.readPlayback(id)?.actual?.id, saved.id); + assert.equal( + history.readLastActualPlayback()?.src.meta.id, + saved.src.meta.id, + "ID normalization is idempotent", + ); +}); + +test("encoded provider-page IDs do not retain a hidden network URL", () => { + configure(); + const src = source(urls[2]); + src.meta.id = `capstan:synthetic:${encodeURIComponent(urls[2])}`; + assert.equal(history.recordActualPlayback(recorded(src)), true); + const target = history.readLastActualPlayback()!; + assert.doesNotMatch(values.get(storageKey)!, /INNERSECRET|https%3A/i); + assert.equal(continuationAppliesToSource(src, continuationFor(target, actor)), true); +}); + +test("explicit public image routes retain artwork without allowing arbitrary network images", () => { + configure(); + const src = source(); + src.meta.poster = "https://image.tmdb.org/t/p/w500/1E5baAaEse26fej7uHcjOgEE2t2.jpg"; + src.meta.background = urls[0]; + assert.equal(history.recordActualPlayback(recorded(src)), true); + assert.equal(history.readLastActualPlayback()?.src.meta.poster, src.meta.poster); + assert.equal(history.readLastActualPlayback()?.src.meta.background, undefined); +}); + +test("unknown history fields cannot bypass the retained entry, actor, track, or profile schemas", () => { + configure(); + const raw: any = recorded(); + raw.actor = { ...actor, extra: urls[0] }; + raw.audioTrack = { id: "1", lang: "ara", title: urls[1], extra: urls[2] }; + raw.extra = urls[0]; + assert.equal(history.recordActualPlayback(raw), true); + const entry: any = history.readPlayback(raw.src.meta.id); + values.set( + storageKey, + JSON.stringify({ + [raw.src.meta.id]: { + ...entry, + extra: urls[0], + actualByProfile: { one: { ...entry.actualByProfile.one, extra: urls[0] } }, + }, + }), + ); + history.readLastActualPlayback(); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123|INNERSECRET|"extra"/); +}); + +test("a URL-only source preference and its season lock retain exact matching without storing transport", () => { + configure(); + const url = urls[0]; + const entry = { + addonId: "synthetic-addon", + url, + title: url, + parsedTitle: encodeURIComponent(url), + extra: url, + }; + history.savePlayback("tt-preference", entry); + saveSeasonLock("tt-preference", entry, 2); + const saved = history.readPlayback("tt-preference")!; + assert.equal(history.streamMatchesEntry({ addonId: "synthetic-addon", url }, saved), true); + assert.equal( + history.streamMatchesEntry({ addonId: "synthetic-addon", url: urls[1] }, saved), + false, + ); + assert.equal(history.streamMatchesEntry({ url }, readSeasonLock("tt-preference", 2)!), true); + assert.doesNotMatch([...values.values()].join(""), /PATHSECRET123|"extra"|https%3A/i); +}); + +test("legacy key migration keeps both private profile sessions when a canonical key already exists", () => { + configure(); + const src = source(urls[1]); + src.meta.id = `url:${urls[1]}`; + history.recordActualPlayback(recorded(src, "one")); + const canonical = Object.keys(JSON.parse(values.get(storageKey)!))[0]; + const one = { ...recorded(src, "one"), actor: { ...actor, privateCw: true } }; + const two = { + ...recorded(src, "two"), + actor: { ...actor, profileId: "two", privateCw: true }, + playedAt: one.playedAt + 1, + }; + values.set( + storageKey, + JSON.stringify({ + [src.meta.id]: { + savedAt: one.playedAt, + actual: one, + actualByProfile: { one: { actual: one } }, + }, + [canonical]: { + savedAt: two.playedAt, + actual: two, + actualByProfile: { two: { actual: two } }, + }, + }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.readLastActualPlayback()?.id, "one"); + const migrated = JSON.parse(values.get(storageKey)!); + assert.deepEqual(Object.keys(migrated), [canonical]); + assert.equal(migrated[canonical].actualByProfile.two.actual.id, "two"); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123/); +}); + +test("a late subtitle cache update identifies the exact redacted source and rejects a newer selection", () => { + configure(); + const src = source(); + src.meta.id = `url:${urls[0]}`; + const first = { + source: urls[0], + external: true, + lang: "ara", + streamKey: subtitleStreamKey(src.streamRef), + }; + assert.equal(history.recordActualPlayback({ ...recorded(src), subtitle: first }), true); + const target = history.readActualPlaybackForSubtitle( + subtitleMediaKey(src.meta.id), + first.streamKey, + 0, + )!; + assert.ok(target); + const cached = { imported: true, external: true, lang: "ara", source: "C:/synthetic/cached.srt" }; + assert.equal(history.cacheActualPlaybackSubtitle(target, first, cached), true); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal( + history.recordActualPlayback({ ...recorded(src), subtitle: { ...first, source: urls[1] } }), + true, + ); + assert.equal(history.cacheActualPlaybackSubtitle(target, first, cached), false); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123/); +}); + +test("a manual URL returns to its live session but asks for re-entry after restart", async () => { + configure(); + const src = source(urls[0]); + src.meta.id = `url:${urls[0]}`; + assert.equal(history.recordActualPlayback(recorded(src)), true); + const target = history.readLastActualPlayback()!; + let returned = false; + const services: ContinuationServices = { + isCurrent: history.isPlaybackActorCurrent, + latest: history.readLastActualPlayback, + active: () => ({ + src, + resume: async () => { + returned = true; + }, + }), + localFileExists: async () => { + throw new Error("not local"); + }, + prepareServer: async () => { + throw new Error("not a server"); + }, + openPlayer: () => { + throw new Error("must not reopen a retained network URL"); + }, + openPicker: () => { + throw new Error("no resolver for a manual URL"); + }, + }; + assert.equal(await continueActualPlayback(target, {}, services), "returned"); + assert.equal(returned, true); + await assert.rejects( + continueActualPlayback(target, {}, { ...services, active: () => null }), + /open.*source|enter.*link/i, + ); +}); + +test("protocol-relative URLs in ordinary text and IDs are not retained", () => { + configure(); + const src = source(); + const label = "Provider //media.example/private/PATHSECRET"; + src.meta.name = label; + src.title = label; + history.recordActualPlayback(recorded(src)); + history.savePlayback("addon " + label, { title: label }); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET/); +}); + +test("invalid required snapshot scalars are rejected and optional scalars cannot store objects", () => { + configure(); + const actual = recorded(source("C:/synthetic/movie.mp4")); + const nested = { url: urls[0] }; + assert.equal(history.recordActualPlayback({ ...actual, completed: nested } as any), false); + assert.equal( + history.recordActualPlayback({ + ...actual, + subDelaySec: nested, + audioDelaySec: nested, + requiresSourceRefresh: nested, + } as any), + true, + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); + assert.equal(history.readLastActualPlayback()?.subDelaySec, undefined); +}); + +test("a malformed legacy snapshot cannot prevent cleaning other history entries", () => { + configure(); + const valid = recorded(source("C:/synthetic/movie.mp4")); + values.set( + storageKey, + JSON.stringify({ + broken: { + savedAt: Date.now(), + actual: { ...valid, src: { ...valid.src, meta: { ...valid.src.meta, id: 42 } } }, + }, + valid: { savedAt: Date.now(), url: urls[0], actual: valid }, + }), + ); + assert.equal(history.readLastActualPlayback()?.id, valid.id); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); +}); + +test("legacy URL IDs ending in key-like text retain their exact source identity", () => { + for (const suffix of ["|all", "|s2e3"]) { + configure(); + const src = source(urls[1] + suffix); + src.meta.id = `url:${src.url}`; + values.set( + storageKey, + JSON.stringify({ [src.meta.id]: { savedAt: Date.now(), actual: { ...recorded(src), src } } }), + ); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); + assert.equal(history.readPlayback(src.meta.id)?.actual?.id, "observed"); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123/); + } +}); + +test("raw-key statistics scrub old transport without imposing an earlier history expiry", () => { + configure(); + const id = `url:${urls[0]}`; + values.set( + storageKey, + JSON.stringify({ [id]: { savedAt: Date.now() - 31 * 86400000, url: urls[0] } }), + ); + const keys = history.playbackHistoryRawKeys(); + assert.equal(keys.length, 1); + assert.doesNotMatch(keys[0], /PATHSECRET123/); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); + assert.equal(Object.keys(JSON.parse(values.get(storageKey)!)).length, 1); +}); + +test("expired season locks cannot leave their old transport in storage", () => { + configure(); + values.set( + "harbor.season-lock.v1", + JSON.stringify({ "tt-old|s2": { savedAt: Date.now() - 121 * 86400000, url: urls[1] } }), + ); + assert.equal(readSeasonLock("tt-old", 2), null); + assert.doesNotMatch(values.get("harbor.season-lock.v1")!, /QUERYSECRET123/); +}); + +test("late subtitle caching still matches a provider whose opaque ID contains a URL", () => { + configure(); + const src = source(); + src.streamRef!.addonId = urls[1]; + const original = { + source: urls[0], + external: true, + lang: "ara", + streamKey: subtitleStreamKey(src.streamRef), + }; + history.recordActualPlayback({ ...recorded(src), subtitle: original }); + const target = history.readActualPlaybackForSubtitle( + subtitleMediaKey(src.meta.id), + original.streamKey, + 0, + )!; + assert.ok(target); + assert.equal( + history.cacheActualPlaybackSubtitle(target, original, { + source: "C:/synthetic/cached.srt", + imported: true, + external: true, + }), + true, + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123/); +}); + +test("manual URL preferences do not retain a path credential as a bare title", () => { + configure(); + const id = "url:https://media.example/play/BARESECRET123"; + const entry = { + url: "https://media.example/play/BARESECRET123", + title: "BARESECRET123", + parsedTitle: "BARESECRET123", + }; + history.savePlayback(id, entry); + saveSeasonLock(id, entry, 1); + assert.doesNotMatch([...values.values()].join(""), /BARESECRET123/); +}); + +test("a redacted external subtitle restores only an exact fresh transport or its provider identity", () => { + configure(); + const original = { external: true, lang: "ara", source: urls[0] }; + history.recordActualPlayback({ ...recorded(), subtitle: original }); + const choice = history.readLastActualPlayback()!.subtitle!; + const track = { + id: "new-id", + kind: "subtitle" as const, + external: true, + lang: "ara", + label: "Arabic", + originalUrl: urls[0], + }; + assert.equal(selectPlaybackSubtitle([track], choice)?.id, "new-id"); + assert.equal(selectPlaybackSubtitle([{ ...track, originalUrl: urls[1] }], choice), null); + assert.equal( + selectPlaybackSubtitle([track, { ...track, id: "duplicate" }], choice), + null, + "ambiguous matches remain unavailable", + ); + const cached = { ...choice, source: "C:/synthetic/chosen.srt", imported: true }; + const local = { ...track, id: "cached", originalUrl: undefined, externalFilename: cached.source }; + assert.equal( + selectPlaybackSubtitle([track, local], cached)?.id, + "cached", + "the exact cached file takes precedence over its old remote fingerprint", + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); +}); + +test("the existing empty guest scope remains a valid playback owner", () => { + values.clear(); + const guest = history.capturePlaybackActor(); + assert.equal(guest.profileId, ""); + assert.equal( + history.recordActualPlayback({ ...recorded(source("C:/synthetic/guest.mp4")), actor: guest }), + true, + ); + assert.equal(history.readLastActualPlayback()?.id, "observed"); +}); + +test("free text cannot hide a relative transport behind punctuation or URI nesting", () => { + configure(); + for (const prefix of [ + "Provider{", + "Provider:", + "Provider_", + "Provider/", + "Provider|", + "Provider[", + "Provider(", + ]) { + const text = `${prefix}//media.example/private/PUNCTSECRET`; + for (const label of [ + text, + encodeURIComponent(text), + encodeURIComponent(encodeURIComponent(text)), + ]) { + const src = source("C:/synthetic/movie.mp4"); + src.title = label; + src.meta.name = label; + history.recordActualPlayback(recorded(src)); + history.savePlayback("tt-provenance", { title: label, parsedTitle: label }); + assert.doesNotMatch(values.get(storageKey)!, /PUNCTSECRET/); + } + } +}); + +test("validated filesystem and installed-plugin identities remain usable", () => { + configure(); + const src = source("file:///C:/synthetic/movie.mp4"); + src.meta.id = `local:${src.url}`; + src.meta.addonOrigin = { + id: "synthetic", + name: "Installed plugin", + base: "harbor-plugin://synthetic/id", + }; + history.recordActualPlayback(recorded(src)); + const saved = history.readLastActualPlayback()!; + assert.equal(saved.src.url, src.url); + assert.equal(saved.src.meta.id, src.meta.id); + assert.equal(saved.src.meta.name, src.meta.name); + assert.equal(saved.src.meta.addonOrigin?.base, src.meta.addonOrigin.base); +}); diff --git a/tests/playback-session-fps.test.ts b/tests/playback-session-fps.test.ts new file mode 100644 index 000000000..489181508 --- /dev/null +++ b/tests/playback-session-fps.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mpvBridgeHarness, playerSnapshotChanged } from "./helpers/mpv-bridge-harness"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "../src/lib/player/subtitle-choice"; +import { subtitleMediaKey, subtitleStreamKey } from "../src/lib/subtitles/subtitle-memory"; + +test("native subtitle FPS observations reach the player snapshot and existing actual history payload", async () => { + const h = mpvBridgeHarness(); + await h.bridge.load({ url: "C:/fixture/video.mp4" }); + h.emit("sub-fps", 25); + assert.equal(h.snapshot().subtitleFps, 25); + const snapshot = { + ...h.snapshot(), + status: "paused" as const, + firstFrameReady: true, + subtitleTracks: [ + { id: "1", kind: "subtitle" as const, label: "Arabic", lang: "ara", selected: true }, + ], + }; + const result = actualPlaybackSnapshot( + { + url: "C:/fixture/video.mp4", + title: "Fixture", + meta: { id: "local:fixture", name: "Fixture", type: "movie" }, + }, + snapshot, + 30, + { id: "session", actor: { profileId: "one", storageProfileId: "one", accountId: null } }, + ); + assert.equal(result?.subtitle?.fpsOverride, 25); + h.emit("sub-fps", 0); + assert.equal(h.snapshot().subtitleFps, 0); + assert.equal(playerSnapshotChanged()(snapshot, { ...snapshot, subtitleFps: 0 }), true); + h.bridge.destroy(); +}); + +test("FPS restoration waits for the intended selected subtitle and is not replayed on ordinary rerenders", async () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let callback: ts.Node | undefined; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && node.expression.getText(ast) === "useEffect") { + const candidate = node.arguments[0]; + if (candidate?.getText(ast).includes("writeMpvSubtitleFps(")) callback = candidate; + } + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(callback, "the existing track-restoration path must restore saved FPS"); + const compiled = ts.transpileModule(`const effect = ${callback.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const correct = { + id: "7", + kind: "subtitle", + selected: true, + label: "Full dialogue", + title: "Full dialogue", + lang: "ara", + codec: "ass", + }; + const wrong = { ...correct, id: "1", title: "Signs", label: "Signs" }; + const writes: unknown[] = []; + let validity: (() => boolean) | undefined; + const scope = { + engine: "mpv", + rememberedSession: { + subtitle: { + trackId: "1", + title: "Full dialogue", + lang: "ara", + codec: "ass", + fpsOverride: 25, + }, + }, + src: { url: "fixture.mkv", meta: { id: "fixture" } }, + snap: { status: "paused", subtitleTracks: [wrong] }, + snapRef: { current: { subtitleTracks: [wrong] } }, + bridgeRef: { current: { canAutoSelectSubtitle: () => true } }, + playbackActor: {}, + isPlaybackActorCurrent: () => true, + selectPlaybackSubtitle, + restorablePlaybackSubtitleChoice, + subtitleMediaKey, + subtitleStreamKey, + readRememberedSub: () => null, + subtitleFpsAppliedRef: { current: null }, + getMpvSubtitleFpsGeneration: () => 1, + writeMpvSubtitleFps: async (...args: unknown[]) => { + writes.push(args[0]); + validity = args[2] as () => boolean; + }, + emitListToast() {}, + t: (key: string) => key, + }; + const run = () => + new Function(...Object.keys(scope), `${compiled}\nreturn effect();`)(...Object.values(scope)); + run(); + assert.deepEqual(writes, []); + scope.snap.subtitleTracks = [correct, { ...correct, id: "8", selected: false }]; + scope.snapRef.current = scope.snap; + run(); + assert.deepEqual(writes, [], "ambiguous semantic tracks cannot inherit an FPS correction"); + scope.snap.subtitleTracks = [correct]; + scope.snapRef.current = scope.snap; + run(); + await Promise.resolve(); + run(); + assert.deepEqual(writes, [25]); + scope.snapRef.current = { subtitleTracks: [] }; + assert.equal(validity?.(), false, "clearing native tracks invalidates a queued correction"); +}); diff --git a/tests/playback-session-restore.test.ts b/tests/playback-session-restore.test.ts new file mode 100644 index 000000000..d6c39be5d --- /dev/null +++ b/tests/playback-session-restore.test.ts @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { restoreHarness } from "./helpers/subtitle-memory-harness"; +import { playbackSubtitleChoice } from "../src/lib/player/subtitle-choice"; +import type { TrackInfo } from "../src/lib/player/bridge"; +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { selectPlaybackAudio } from "../src/lib/player/audio-choice"; +import { + selectedSubtitleCacheName, + subtitleCacheOriginFingerprint, +} from "../src/lib/subtitles/selected-subtitle-cache"; +import { persistablePlaybackSubtitle } from "../src/lib/player/playback-history-source"; +import type { PlayerSrc } from "../src/lib/view"; +import { rememberedFromChoice } from "../src/lib/subtitles/subtitle-memory"; + +const embedded = (id: string, title: string): TrackInfo => ({ + id, + kind: "subtitle", + label: title, + title, + lang: "ara", + selected: false, + codec: "ass", +}); + +test("a fresh subtitle preselection wins over recalled history or continuation subtitle intent", () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let recall: ts.Node | undefined; + let preselect: ts.Node | undefined; + const visit = (node: ts.Node) => { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(ast) === "rememberedSession" && + node.initializer && + ts.isCallExpression(node.initializer) + ) + recall = node.initializer.arguments[0]; + if ( + ts.isCallExpression(node) && + node.expression.getText(ast) === "useEffect" && + node.arguments[0]?.getText(ast).includes("const choice = src.subtitlePreselect;") + ) + preselect = node.arguments[0]; + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(recall); + assert.ok(preselect); + const compile = (callback: ts.Node) => + ts.transpileModule(`const effect = ${callback.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const recallCode = compile(recall); + const preselectCode = compile(preselect); + + for (const fromContinuation of [false, true]) { + for (const choice of [{ off: true }, { url: "https://fixture.invalid/new.srt" }]) { + const session = { + subtitle: playbackSubtitleChoice(embedded("1", "Old Arabic")), + audioTrack: { lang: "eng" }, + subDelaySec: 2, + audioDelaySec: 0.5, + }; + const h = restoreHarness({ updatedAt: 1 }, [embedded("9", "Old Arabic")]); + Object.assign(h.scope.src, { + subtitlePreselect: choice, + ...(fromContinuation ? { continuation: { ...session, actor: {} } } : {}), + }); + const rememberedSession = new Function( + "src", + "continuationAppliesToSource", + "isPlaybackActorCurrent", + "readActualPlaybackFor", + `${recallCode}\nreturn effect();`, + )( + h.scope.src, + () => true, + () => true, + () => session, + ); + Object.assign(h.scope, { + rememberedSession, + preselectAppliedRef: { current: null }, + markAddedSub() {}, + }); + h.run(); + assert.deepEqual(h.picks, [], "recalled subtitle must not replace the new selection"); + new Function(...Object.keys(h.scope), `${preselectCode}\nreturn effect();`)( + ...Object.values(h.scope), + ); + if ("off" in choice) assert.deepEqual(h.picks, [null]); + else assert.deepEqual(h.adds, [choice.url]); + assert.deepEqual(rememberedSession.audioTrack, session.audioTrack); + assert.equal(rememberedSession.subDelaySec, 2); + assert.equal(rememberedSession.audioDelaySec, 0.5); + } + } +}); + +test("the real restore effect uses scoped continuation subtitle intent ahead of unrelated subtitle memory", () => { + const selected = embedded("1", "Full dialogue"); + const h = restoreHarness({ off: true, updatedAt: 1 }, [ + embedded("1", "Signs"), + embedded("9", "Full dialogue"), + ]); + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(selected) }, + embeddedRestoreRef: { current: null }, + }); + h.run(); + assert.deepEqual(h.picks, ["9"]); +}); + +test("scoped off selection stays off even when a prior subtitle memory would select a subtitle", () => { + const h = restoreHarness({ source: "https://fixture.invalid/a.srt", updatedAt: 1 }, [ + { ...embedded("3", "Arabic"), selected: true }, + ]); + Object.assign(h.scope, { rememberedSession: { subtitle: { off: true } } }); + h.run(); + assert.deepEqual(h.picks, [null]); + assert.deepEqual(h.adds, []); +}); + +test("late subtitle preparation cannot mutate the player after its account changes", async () => { + const h = restoreHarness( + { source: "C:/fixture/a.srt", imported: true, streamKey: "torrent:fixture:0", updatedAt: 1 }, + [], + ); + let current = true; + let release!: () => void; + Object.assign(h.scope, { + isPlaybackActorCurrent: () => current, + playbackActor: {}, + bindSubtitleDownloadAuth: () => + new Promise((resolve) => { + release = resolve; + }), + }); + h.run(); + current = false; + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, []); +}); + +test("failure to reopen the saved local subtitle reports incomplete restoration", async () => { + const h = restoreHarness( + { + source: "C:/fixture/missing.srt", + imported: true, + streamKey: "torrent:fixture:0", + updatedAt: 1, + }, + [], + ); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { + subtitle: { + source: "C:/fixture/missing.srt", + imported: true, + streamKey: "torrent:fixture:0", + }, + }, + emitListToast: (message: string) => errors.push(message), + bindSubtitleDownloadAuth: async () => { + throw new Error("Unavailable subtitle"); + }, + }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +test("a missing embedded subtitle is not silently replaced by a different default track", () => { + const h = restoreHarness({ updatedAt: 1 }, [ + { ...embedded("1", "Different translation"), selected: true }, + ]); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(embedded("1", "Intended translation")) }, + snap: { ...h.scope.snap, firstFrameReady: true }, + emitListToast: (message: string) => errors.push(message), + }); + h.run(); + assert.deepEqual(h.picks, [null]); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +test("an embedded subtitle missing from an empty ready track list reports once without a premature loading error", () => { + const h = restoreHarness({ updatedAt: 1 }, []); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(embedded("1", "Intended translation")) }, + emitListToast: (message: string) => errors.push(message), + }); + h.run(); + assert.deepEqual(errors, []); + h.scope.snap.firstFrameReady = true; + h.run(); + h.run(); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +function latePreplayCacheFixture() { + const track = { + ...embedded("9", "Arabic"), + external: true, + url: "https://fixture.invalid/chosen.srt", + subId: "chosen", + provider: "Fixture provider", + }; + const streamKey = "torrent:fixture:0"; + const saved = { ...rememberedFromChoice(track), external: true, streamKey, fpsOverride: 25 }; + const filename = selectedSubtitleCacheName( + "fixture||", + streamKey, + { ...track, source: track.url }, + "srt", + ); + const cached = { + ...rememberedFromChoice({ + ...saved, + source: `C:/fixture/selected/${filename}`, + format: "srt", + imported: true, + sourceFingerprint: subtitleCacheOriginFingerprint({ source: saved.source }), + }), + updatedAt: Date.now(), + }; + return { saved, cached }; +} + +test("reopening uses a proven late pre-play cache even when the scoped actual snapshot still has its remote source", async () => { + const { saved, cached } = latePreplayCacheFixture(); + const h = restoreHarness(cached, []); + Object.assign(h.scope, { rememberedSession: { subtitle: saved } }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, [cached.source]); + assert.deepEqual(h.timers, []); +}); + +test("redacted playback history restores its exact cached remote subtitle", async () => { + const { saved, cached } = latePreplayCacheFixture(); + const sanitized = persistablePlaybackSubtitle(saved, { + url: "https://fixture.invalid/video.mp4", + meta: { id: "fixture", name: "Fixture", type: "movie" }, + } as PlayerSrc)!; + assert.equal(sanitized.source, undefined, "the remote subtitle URL must not be persisted"); + assert.ok(sanitized.sourceFingerprint); + assert.equal(sanitized.sourceFingerprint, cached.sourceFingerprint); + + const h = restoreHarness(cached, []); + Object.assign(h.scope, { rememberedSession: { subtitle: sanitized } }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, [cached.source]); + + for (const bad of [ + { ...cached, sourceFingerprint: "a".repeat(64) }, + { ...cached, subId: "another-subtitle" }, + { ...cached, provider: "another-provider" }, + { ...cached, source: "C:/fixture/unrelated.srt" }, + ]) { + const wrong = restoreHarness(bad, []); + Object.assign(wrong.scope, { rememberedSession: { subtitle: sanitized } }); + wrong.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(wrong.adds, [], "an unverified subtitle must not be restored"); + } +}); + +test("late cache preference does not cross a different stream, unrelated cached file, or stale actor", async () => { + const { saved, cached } = latePreplayCacheFixture(); + for (const change of ["stream", "file", "actor"] as const) { + const memory = + change === "stream" + ? { ...cached, streamKey: "torrent:other:0" } + : change === "file" + ? { ...cached, source: "C:/fixture/selected/unrelated.srt" } + : cached; + const h = restoreHarness(memory, []); + Object.assign(h.scope, { rememberedSession: { subtitle: saved } }); + if (change === "actor") { + const stale = {}; + Object.assign(h.scope.src, { continuation: { actor: stale } }); + Object.assign(h.scope, { isPlaybackActorCurrent: (actor: unknown) => actor !== stale }); + } + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(h.adds.includes(memory.source), false, change); + } +}); + +test("unavailable remembered audio produces honest one-time feedback after decoding", () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let effect: ts.Node | undefined; + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + node.expression.getText(ast) === "useEffect" && + node.arguments[0]?.getText(ast).includes("The saved audio track could not be restored.") + ) + effect = node.arguments[0]; + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(effect); + const compiled = ts.transpileModule(`const effect = ${effect.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const errors: string[] = []; + const scope = { + rememberedAudio: { id: "1", lang: "ara", title: "Arabic" }, + src: { url: "fixture.mkv" }, + snap: { + firstFrameReady: false, + audioTracks: [ + { id: "1", kind: "audio", selected: true, title: "English", label: "English", lang: "eng" }, + ], + }, + selectPlaybackAudio, + audioRestoreWarningRef: { current: null }, + autoAudioIdRef: { current: "1" }, + emitListToast: (message: string) => errors.push(message), + t: (key: string) => key, + }; + const run = () => + new Function(...Object.keys(scope), `${compiled}\nreturn effect();`)(...Object.values(scope)); + run(); + assert.deepEqual(errors, []); + scope.snap.firstFrameReady = true; + run(); + run(); + assert.deepEqual(errors, ["The saved audio track could not be restored. Choose an audio track."]); +}); diff --git a/tests/playback-subtitle-choice.test.ts b/tests/playback-subtitle-choice.test.ts new file mode 100644 index 000000000..bf585c706 --- /dev/null +++ b/tests/playback-subtitle-choice.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { playbackSubtitleChoice, selectPlaybackSubtitle } from "../src/lib/player/subtitle-choice"; +import type { TrackInfo } from "../src/lib/player/bridge"; + +const track = (id: string, title: string): TrackInfo => ({ + id, + title, + label: title, + lang: "ara", + codec: "ass", + kind: "subtitle", + selected: true, +}); + +test("embedded subtitle identity wins over a stale numeric id after track ordering changes", () => { + const selected = playbackSubtitleChoice(track("1", "Full dialogue")); + assert.equal( + selectPlaybackSubtitle([track("1", "Signs only"), track("7", "Full dialogue")], selected)?.id, + "7", + ); + assert.equal(selectPlaybackSubtitle([track("7", "Unrelated")], selected), null); +}); + +test("ephemeral subtitle URLs are not persisted as durable identities", () => { + assert.equal( + playbackSubtitleChoice({ ...track("1", "Arabic"), external: true, url: "blob:temp" }).source, + undefined, + ); + assert.equal( + playbackSubtitleChoice({ + ...track("1", "Arabic"), + external: true, + url: "blob:temp", + originalUrl: "C:/fixture/arabic.srt", + }).source, + "C:/fixture/arabic.srt", + ); + assert.equal( + playbackSubtitleChoice( + { + ...track("1", "Arabic"), + external: true, + url: "blob:temp", + subId: "id", + provider: "Provider", + }, + { source: "blob:old", subId: "id", provider: "Provider", updatedAt: 1 }, + ).source, + undefined, + ); +}); + +test("durable local subtitle identity accepts platform path separators without accepting another language", () => { + const local = { ...track("1", "Arabic"), external: true, originalUrl: "C:/fixture/arabic.srt" }; + assert.equal( + selectPlaybackSubtitle([local], { external: true, source: "C:\\fixture\\arabic.srt" })?.id, + "1", + ); + const choice = { external: true, subId: "provider:1", provider: "Provider", lang: "ara" }; + assert.equal( + selectPlaybackSubtitle( + [{ ...local, subId: "provider:1", provider: "Provider", lang: "eng" }], + choice, + ), + null, + ); +}); + +test("indistinguishable embedded subtitle tracks are not guessed using an old numeric id", () => { + const choice = playbackSubtitleChoice(track("1", "Full dialogue")); + assert.equal( + selectPlaybackSubtitle([track("1", "Full dialogue"), track("2", "Full dialogue")], choice), + null, + ); +}); diff --git a/tests/player-menu-tools.test.ts b/tests/player-menu-tools.test.ts index 2d263224b..dec774fcb 100644 --- a/tests/player-menu-tools.test.ts +++ b/tests/player-menu-tools.test.ts @@ -71,10 +71,9 @@ test("published player tools refresh after source and subtitle capability change assert.equal(actions.currentPlayerActions(), null); }); -test("player menu has a source-scoped magnet strip and separate subtitle and stream tools", async () => { +test("player menu keeps magnet as a normal row with subtitle and stream tools and no quick strip", async () => { const copied: string[] = []; const opened: string[] = []; - let settings = 0; let synced = 0; let current: any = { streamUrl: "https://media.invalid/a", @@ -90,9 +89,10 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str }, }; const icons = Object.fromEntries( - ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize", "Settings"].map( - (key) => [key, () => null], - ), + ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize"].map((key) => [ + key, + () => null, + ]), ); const { playerContextSources } = load("src/components/context-menu/player-actions.tsx", { "react/jsx-runtime": jsxRuntime, @@ -109,25 +109,27 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str }, }, }); - const source = playerContextSources( - () => current, - () => ({ - id: "page:go:settings", - label: "Settings", - run: () => { - settings++; - }, - }), - ); + const source = playerContextSources(() => current); + assert.equal(source.quick, undefined, "item menus do not provide an empty or populated strip"); assert.deepEqual( - source.quick.actions().map((a: any) => a.label), - ["Copy magnet", "Settings"], + source.rows.actions().map((a: any) => a.label), + [ + "Full screen", + "Download Video", + "Download Subtitle", + "Live sync", + "Copy stream link", + "Open in browser", + "Copy magnet", + ], ); assert.equal( - source.rows.actions().some((a: any) => /magnet|Back|Resume/.test(a.label)), + source.rows.actions().some((a: any) => /Settings|Back|Resume/.test(a.label)), false, ); - await source.quick.actions()[0].run(); + const magnet = source.rows.actions().find((a: any) => a.label === "Copy magnet"); + assert.equal(magnet.group, "links", "magnet shares the ordinary link command group"); + await magnet.run(); assert.deepEqual(copied, ["magnet:a"]); current = { ...current, @@ -135,9 +137,9 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str magnetUrl: null, canLiveSync: false, }; - assert.deepEqual( - source.quick.actions().map((a: any) => a.label), - ["Settings"], + assert.equal( + source.rows.actions().some((a: any) => a.label === "Copy magnet"), + false, ); const live = source.rows.actions().find((a: any) => a.label === "Live sync"); assert.equal(live.disabled, true); @@ -153,21 +155,28 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str .run(); assert.equal(copied[1], "https://media.invalid/b"); assert.deepEqual(opened, ["https://media.invalid/b"]); - await source.quick.actions()[0].run(); - assert.equal(settings, 1); current = null; - assert.equal(source.quick.isValid(), false); + assert.equal(source.rows.isValid(), false); assert.deepEqual(source.rows.actions(), []); }); -test("player Settings preserves the current navigation command's permission, PIN and availability", async () => { - let pinRequests = 0; - let navigations = 0; - let available: any = undefined; +test("player rows preserve available commands without adding application navigation", async () => { + const calls: string[] = []; + let current: any = { + toggleFullscreen: () => calls.push("fullscreen"), + download: () => calls.push("video"), + downloadSubtitle: () => calls.push("subtitle"), + liveSync: () => calls.push("sync"), + canDownload: true, + canDownloadSubtitle: true, + canLiveSync: true, + streamUrl: "file:///disposable-fixture/video.mp4", + }; const icons = Object.fromEntries( - ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize", "Settings"].map( - (key) => [key, () => null], - ), + ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize"].map((key) => [ + key, + () => null, + ]), ); const { playerContextSources } = load("src/components/context-menu/player-actions.tsx", { "react/jsx-runtime": jsxRuntime, @@ -176,39 +185,17 @@ test("player Settings preserves the current navigation command's permission, PIN "./content-actions": { copyContextText: async () => {} }, "@/lib/social/link-out": { openLinkOut: async () => {} }, }); - const source = playerContextSources( - () => ({}), - () => available, + const source = playerContextSources(() => current); + assert.equal(source.quick, undefined); + assert.deepEqual( + source.rows.actions().map((action: any) => action.label), + ["Full screen", "Download Video", "Download Subtitle", "Live sync"], + ); + for (const action of source.rows.actions()) await action.run(); + assert.deepEqual(calls, ["fullscreen", "video", "subtitle", "sync"]); + current = { ...current, canDownload: false, canDownloadSubtitle: false, liveSync: undefined }; + assert.deepEqual( + source.rows.actions().map((action: any) => action.label), + ["Full screen"], ); - assert.deepEqual(source.quick.actions(), [], "hidden/kid navigation must not gain Settings"); - const lockIcon = React.createElement("span", null, "locked"); - available = { - id: "page:go:settings", - label: "Settings", - icon: lockIcon, - reason: "PIN required", - run: () => { - pinRequests++; - }, - }; - const gated = source.quick.actions()[0]; - assert.equal(gated.id, "player:settings"); - assert.equal(gated.icon, lockIcon); - assert.equal(gated.reason, "PIN required"); - await gated.run(); - assert.equal(pinRequests, 1); - assert.equal(navigations, 0, "the player menu cannot bypass the supplied PIN operation"); - available = { ...available, disabled: true }; - assert.equal(source.quick.actions()[0].disabled, true); - available = { - ...available, - disabled: false, - run: () => { - navigations++; - }, - }; - await source.quick.actions()[0].run(); - assert.equal(navigations, 1, "unlocked navigation retains the shared departure dispatcher"); - available = undefined; - assert.deepEqual(source.quick.actions(), [], "policy changes are read while the menu is open"); }); diff --git a/tests/player-recovery-ownership.test.ts b/tests/player-recovery-ownership.test.ts index cf5f846a0..102ed7b4a 100644 --- a/tests/player-recovery-ownership.test.ts +++ b/tests/player-recovery-ownership.test.ts @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import ts from "typescript"; import { emptySnapshot } from "../src/lib/player/bridge.ts"; +import { continuationAppliesToSource } from "../src/lib/player/continue-playback"; +import { playbackSourceKey } from "../src/lib/playback-history"; +import { playerLoadIdentity } from "../src/lib/player/load-identity"; function deferred() { let resolve!: (value: T) => void, reject!: (error: Error) => void; @@ -23,8 +26,10 @@ function recoveryHarness() { mi = 0, ei = 0, position = 0, - timer = 0; + timer = 0, + actorCurrent = true; let pending: Array<{ i: number; deps?: unknown[]; run: () => any }> = []; + const timeouts = new Map void; delay: number }>(); const same = (a?: unknown[], b?: unknown[]) => !!a && !!b && a.length === b.length && a.every((v, i) => Object.is(v, b[i])); const react = { @@ -65,14 +70,18 @@ function recoveryHarness() { const proxyRequests: Array<{ args: any[]; job: ReturnType }> = []; const debridRequests: Array<{ args: any[]; job: ReturnType }> = []; const probes: Array> = [], - pickers: any[][] = []; + pickers: any[][] = [], + cleared: any[][] = []; const dependencies = { getPlaybackPosition: () => position, getPlaybackBuffered: () => 0, usePlaybackFlag: (read: () => boolean) => read(), isLocalUrl: () => false, isTruncatedEnd: () => false, - clearOnePickerCache() {}, + clearOnePickerCache: (...args: any[]) => cleared.push(args), + isPlaybackActorCurrent: () => actorCurrent, + continuationAppliesToSource, + playerLoadIdentity, registerStreamProxy: (...args: any[]) => { const job = deferred(); proxyRequests.push({ args, job }); @@ -109,8 +118,11 @@ function recoveryHarness() { (id: string) => (id === "react" ? react : dependencies), module, { - setTimeout: () => ++timer, - clearTimeout() {}, + setTimeout: (run: () => void, delay: number) => { + timeouts.set(++timer, { run, delay }); + return timer; + }, + clearTimeout: (id: number) => timeouts.delete(id), setInterval: () => ++timer, clearInterval() {}, }, @@ -157,6 +169,18 @@ function recoveryHarness() { debridRequests, probes, pickers, + cleared, + actorCurrent: (current: boolean) => { + actorCurrent = current; + }, + runTimeouts: (minimumDelay: number) => { + for (const [id, timeout] of timeouts) { + if (timeout.delay < minimumDelay) continue; + timeouts.delete(id); + timeout.run(); + } + }, + pendingTimeouts: () => [...timeouts.values()], get params() { return params; }, @@ -174,6 +198,168 @@ function recoveryHarness() { }; } +function resumedSource(h: ReturnType) { + const src = h.params.src; + return { + ...src, + startPositionMs: 45000, + continuation: { + id: "saved-session", + actor: { profileId: "one", storageProfileId: "one", accountId: "fixture-account" }, + positionMs: 45000, + sourceKey: playbackSourceKey(src), + target: { metaId: src.meta.id }, + source: { url: src.url, savedAt: 1 }, + audioTrack: { lang: "ara", title: "Arabic" }, + subtitle: { off: true }, + subDelaySec: 1.25, + audioDelaySec: -0.2, + }, + }; +} + +test("a valid resumed direct stream starts without a probe or source picker", () => { + const h = recoveryHarness(); + h.render({ src: resumedSource(h), instantPlay: false }); + h.render({ snap: { ...emptySnapshot, status: "playing", firstFrameReady: true } }); + assert.equal(h.pickers.length, 0); + assert.equal(h.proxyRequests.length, 0); + assert.equal(h.probes.length, 0); + assert.equal(h.cleared.length, 0); +}); + +test("failed direct continuation refreshes its picker once with the complete saved session", () => { + const h = recoveryHarness(); + const src = resumedSource(h); + h.render({ src, instantPlay: false }); + h.clock(45); + h.fail(); + assert.equal(h.pickers.length, 1, "a seek position does not prove a decoded frame"); + assert.deepEqual(h.pickers[0], [ + src.meta, + src.episode, + { + autoPlay: false, + resume: true, + continuation: src.continuation, + queueItemId: undefined, + }, + ]); + assert.deepEqual(h.cleared, [[src.meta, src.episode]]); + assert.equal(h.bridge.destroyed, 1); + assert.equal(h.proxyRequests.length, 0); + h.cycle(); + assert.equal(h.pickers.length, 1, "the same failed opening never repeats recovery"); +}); + +test("a stale actor or mismatched continuation cannot reopen its picker", () => { + for (const changed of ["actor", "target"] as const) { + const h = recoveryHarness(); + const src = resumedSource(h); + if (changed === "actor") h.actorCurrent(false); + else src.continuation.target.metaId = "another-title"; + h.render({ src, instantPlay: false }); + h.fail(); + assert.equal(h.pickers.length, 0); + assert.equal(h.bridge.loads.length, 0); + assert.equal(h.proxyRequests.length, 0); + } +}); + +test("a direct continuation that decoded a frame keeps ordinary mid-playback failure handling", () => { + const h = recoveryHarness(); + h.render({ src: resumedSource(h), instantPlay: false }); + h.render({ snap: { ...emptySnapshot, status: "playing", firstFrameReady: true } }); + h.clock(45); + h.fail(); + assert.equal(h.pickers.length, 0); +}); + +test("a terminal startup timeout recovers a direct continuation with its saved state", () => { + const h = recoveryHarness(); + const src = resumedSource(h); + h.render({ src, instantPlay: false }); + h.clock(45); + h.runTimeouts(70000); + h.render(); + assert.equal(h.pickers.length, 1); + assert.equal(h.pickers[0][2].continuation, src.continuation); + assert.equal(h.pickers[0][2].autoPlay, false); +}); + +test("a previous source's terminal error cannot dismiss a fresh direct continuation", () => { + const h = recoveryHarness(); + h.render({ instantPlay: false }); + h.runTimeouts(70000); + assert.ok(h.render().sourceError); + const src = resumedSource(h); + h.render({ src, snap: { ...emptySnapshot, status: "loading" } }); + assert.equal(h.pickers.length, 0); + assert.equal(h.bridge.destroyed, 0); +}); + +test("a replaced continuation ignores the prior opening's queued startup deadline", () => { + const h = recoveryHarness(); + const first = resumedSource(h); + h.render({ src: first, instantPlay: false }); + const oldDeadline = h.pendingTimeouts().find((timer) => timer.delay > 70000)!; + const next = { ...first, continuation: { ...first.continuation, id: "new-session" } }; + h.render({ src: next, snap: { ...emptySnapshot, status: "loading" } }); + oldDeadline.run(); + assert.equal(h.render().sourceError, null); + assert.equal(h.pickers.length, 0); +}); + +test("a new continuation on the same URL receives its own recovery attempt", () => { + const h = recoveryHarness(); + const first = resumedSource(h); + h.render({ src: first, instantPlay: false }); + h.fail(); + h.bridgeRef.current = h.makeBridge(); + const next = { ...first, continuation: { ...first.continuation, id: "new-session" } }; + h.render({ src: next, snap: { ...emptySnapshot, status: "loading" } }); + h.fail(); + assert.equal(h.pickers.length, 2); + assert.equal(h.pickers[1][2].continuation, next.continuation); +}); + +test("server, local, proxy, torrent, live and room sources keep their existing recovery paths", () => { + for (const mode of ["server", "local", "proxy", "torrent", "live", "room"] as const) { + const h = recoveryHarness(); + const src: any = resumedSource(h); + if (mode === "server") + src.homeServer = { connectionId: "one", itemId: "two", versionId: "three" }; + if (mode === "local") src.url = "C:/fixture/video.mkv"; + if (mode === "proxy") src.proxySessionId = "fixture-proxy"; + if (mode === "torrent") src.streamRef = { infoHash: "fixture-hash", fileIdx: 1 }; + if (mode === "live") src.meta = { ...src.meta, id: "iptv:fixture" }; + src.continuation = { + ...src.continuation, + target: { metaId: src.meta.id }, + sourceKey: playbackSourceKey(src), + }; + h.render({ src, instantPlay: false, inRoom: mode === "room" }); + h.fail(); + assert.equal(h.pickers.length, 0, mode); + assert.equal(h.cleared.length, 0, mode); + } +}); + +test("an old proxy failure cannot act after a direct continuation opens its picker", async () => { + const h = recoveryHarness(); + h.fail(); + h.cycle(); + const old = h.proxyRequests[0].job; + const src = resumedSource(h); + h.render({ src, instantPlay: false, snap: { ...emptySnapshot, status: "loading" } }); + h.fail(); + assert.equal(h.pickers.length, 1); + old.reject(new Error("fixture old proxy failed")); + await settle(); + assert.equal(h.pickers.length, 1); + assert.equal(h.bridge.destroyed, 1); +}); + async function settle() { for (let i = 0; i < 8; i++) await Promise.resolve(); } @@ -348,6 +534,18 @@ test("manual source recovery failure does not silently choose another title or s assert.equal(h.render().sourceError?.host, "media.test"); }); +test("showing an ordinary manual source error does not restart its recovery effect", async () => { + const h = recoveryHarness(); + h.render({ instantPlay: false }); + h.fail(); + h.cycle(); + h.proxyRequests[0].job.reject(new Error("fixture proxy unavailable")); + await settle(); + assert.ok(h.render().sourceError); + assert.equal(h.proxyRequests.length, 1, "a visible error does not start another remux request"); + assert.equal(h.pickers.length, 0); +}); + test("a failure after playback started is not auto-retried", () => { const h = recoveryHarness(); h.render({ snap: { ...emptySnapshot, status: "playing", positionSec: 10 } }); diff --git a/tests/subtitle-last-choice.test.ts b/tests/subtitle-last-choice.test.ts index e4b077344..6fa2ed403 100644 --- a/tests/subtitle-last-choice.test.ts +++ b/tests/subtitle-last-choice.test.ts @@ -109,6 +109,23 @@ test("a confirmed choice can finish copying after exit without losing the local assert.equal(h.saved.get("fixture||")?.source, "cached-6.srt"); }); +test("late selected-subtitle cache attaches to the captured observed session, never a newly opened player", async () => { + const h = memoryControlsHarness(); + const copying = deferred(); + h.cacheWith(() => copying.promise); + const captured = { id: "old-observed-session" }; + h.actualPlayback(captured); + const player = h.mount(six); + player.rememberSubChoice(six); + player.unmount(); + h.actualPlayback({ id: "new-session" }); + copying.resolve({ ...six, source: "C:/fixture/cached-six.srt", imported: true }); + await flushBridge(); + assert.equal(h.actualCachePatches.length, 1); + assert.equal(h.actualCachePatches[0][0], captured); + assert.equal((h.actualCachePatches[0][2] as SubChoiceInput).source, "C:/fixture/cached-six.srt"); +}); + test("Off cancels a waiting cache and cannot be overwritten by an older file copy", async () => { const h = memoryControlsHarness(); const copying = deferred(); diff --git a/tests/subtitle-native-mpv.test.ts b/tests/subtitle-native-mpv.test.ts index 1136cc397..a222ed7f1 100644 --- a/tests/subtitle-native-mpv.test.ts +++ b/tests/subtitle-native-mpv.test.ts @@ -151,6 +151,9 @@ test( "sub-start", "time-pos", "pause", + "sub-fps", + "sub-delay", + "audio-delay", ].entries()) { await command(["observe_property", i + 1, property]); } @@ -172,6 +175,27 @@ test( assert.equal(String(await command(["get_property", "sid"])), english); assert.equal(await command(["get_property", "sub-visibility"]), false); + // Verify real property events reach the persisted-session snapshot. This + // exercises native mpv + the bridge, not a full Tauri restart. + await command(["set_property", "sub-fps", 25]); + h.bridge.setSubDelay(0.35); + h.bridge.setAudioDelay(-0.2); + await eventually( + () => + h.snapshot().subtitleFps === 25 && + h.snapshot().subDelaySec === 0.35 && + h.snapshot().audioDelaySec === -0.2, + ); + await command(["set_property", "sub-fps", 0]); + h.bridge.setSubDelay(0); + h.bridge.setAudioDelay(0); + await eventually( + () => + h.snapshot().subtitleFps === 0 && + h.snapshot().subDelaySec === 0 && + h.snapshot().audioDelaySec === 0, + ); + // Run the real style writer against this isolated native player's IPC. const styleSource = readFileSync( new URL("../src/lib/player/sub-style.ts", import.meta.url),