diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index f435f2e1..c7d96658 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -185,6 +185,84 @@ describe("useHistorySubview entry", () => { }); }); +// A view that moves on by itself — the Plex player stepping to the next episode — is still the same +// entry: no push, nothing for Back to step through. But the entry must say where it moved, or a reload +// reopens the item the viewer started with (the owner, 2026-10-25: F5 during a binge restarted the +// first episode). +describe("useHistorySubview follow", () => { + async function overlayOver() { + const closed = vi.fn(); + const layer = renderHook(() => useBackToClose(closed)); + await act(async () => { + await Promise.resolve(); + }); + return { closed, layer }; + } + + it("rewrites the entry it stands on, so a reload reopens the view it moved on to", () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const before = window.history.length; + act(() => result.current.follow("b")); + expect(result.current.view).toBe("b"); + expect(window.history.state?._sub).toBe("b"); + expect(window.location.search).toBe("?v=b"); + expect(window.history.length).toBe(before); + const { result: reloaded } = renderHook(() => useHistorySubview("root", url)); + expect(reloaded.current.view).toBe("b"); + }); + + it("is not a navigation: an overlay over the view stays open", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const { closed, layer } = await overlayOver(); + act(() => result.current.follow("b")); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(closed).not.toHaveBeenCalled(); + expect(window.history.state?._ov).toBeDefined(); + // A reload with the overlay up reopens the view it moved on to as well. + expect(window.history.state?._sub).toBe("b"); + layer.unmount(); // the history goes back to the view's own entry before the next test + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + }); + + it("the entry under an overlay follows too: closing the overlay lands on the moved-on view", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const { layer } = await overlayOver(); + act(() => result.current.follow("b")); + layer.unmount(); // the overlay closes: its entry goes by a back() onto the view's own entry + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + expect(result.current.view).toBe("b"); + expect(window.history.state?._sub).toBe("b"); + expect(window.location.search).toBe("?v=b"); + }); + + it("a navigation on the same entry ends it: an overlay closing over it lands on the new view", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + act(() => result.current.follow("b")); + act(() => result.current.open("c", { replace: true })); + const { layer } = await overlayOver(); + layer.unmount(); + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + expect(result.current.view).toBe("c"); + expect(window.history.state?._sub).toBe("c"); + }); + + it("does not follow onto another entry: Back to the view below restores that one", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + act(() => result.current.open("p")); + act(() => result.current.follow("q")); + act(() => window.history.back()); + await waitFor(() => expect(result.current.view).toBe("a")); + expect(window.history.state?._sub).toBe("a"); + }); +}); + // Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page // left by a pushState with no user gesture — and that can be this page's own entry under the // sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7ece3984..cda8ea83 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -9,7 +9,7 @@ // its own button pops exactly its entry without tripping the modals underneath. // // setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. -import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { navPosition, restoreNavPosition, type NavPosition } from "./listNav"; import { urlWithParams } from "./urlState"; import { useLatest } from "./useLatest"; @@ -143,6 +143,9 @@ export function useHistorySubview( * continuation of the navigation that just arrived here, not a step the user took inside the * module. See the `jumpTo` hand-off in PlexBrowse. */ open: (next: T, opts?: { replace?: boolean }) => void; + /** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands + * on says so from now on, without a navigation — the overlays over it stay open. */ + follow: (next: T) => void; back: () => void; } { // Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the @@ -161,7 +164,16 @@ export function useHistorySubview( return root; }; const [view, setView] = useState(readView); - const readLandedView = useEffectEvent(readView); + // Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its + // own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten: + // the view's own entry under the overlay still says where it was until a step lands back on it. + const followedRef = useRef<{ entry: number; view: T } | null>(null); + const readLandedView = useEffectEvent((): T => { + const followed = followedRef.current; + if (followed == null || peekNavId() !== followed.entry) return readView(); + writeSubEntry(followed.view, url); + return followed.view; + }); useEffect(() => { // Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL). const onPop = () => setView(readLandedView()); @@ -182,16 +194,15 @@ export function useHistorySubview( const go = useCallback( (next: T, opts?: { replace?: boolean }) => { setView(next); - const kept = url?.entry ? url.entry(next) : next; - const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; + // A navigation says where the view is now, on the entry it lands on or rewrites. + followedRef.current = null; if (opts?.replace) { - // Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and - // the view key has changed, so the sub-view still opens at the top without minting an id - // that would orphan the one this entry already carries. - window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); + writeSubEntry(next, url); notifyNav(); return; } + const kept = url?.entry ? url.entry(next) : next; + const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. window.history.pushState( @@ -218,9 +229,26 @@ export function useHistorySubview( }), [go, rootRef], ); + const follow = useCallback( + (next: T) => { + setView(next); + followedRef.current = { entry: ensureNavId(), view: next }; + writeSubEntry(next, url); + }, + [url], + ); // Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves" // can now write `[sub]` and mean it, instead of re-running on every parent render. - return useMemo(() => ({ view, open, back }), [view, open, back]); + return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]); +} + +// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys +// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top +// without minting an id that would orphan the one this entry already carries. +function writeSubEntry(next: T, url: SubviewUrl | undefined): void { + const kept = url?.entry ? url.entry(next) : next; + const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; + window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- diff --git a/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx new file mode 100644 index 00000000..6dc141e8 --- /dev/null +++ b/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +// +// A reload reopens the item playing NOW (the owner, 2026-10-25): binge a series, press F5 mid-episode, +// and the player restarted the episode the session started with — the player moved on in its own +// state while the history entry still held the first item. The player says what it moved on to +// (PlexPlayer's own tests); this is the page writing it into the entry and the address bar. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./PlexPlayer", () => ({ + default: ({ + itemId, + queue, + onItemChange, + }: { + itemId: string; + queue?: string[]; + onItemChange: (id: string) => void; + }) => ( +
+ + player {itemId} queue {queue?.join(",") ?? "none"} + + +
+ ), +})); +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), +})); + +import PlexBrowse from "./PlexBrowse"; +import { api } from "../../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +beforeEach(() => { + vi.spyOn(api, "plexLibrary").mockResolvedValue({ + items: [], + total: 0, + offset: 0, + episodes: [], + } as never); + window.history.replaceState( + { _sub: { kind: "player", id: "e1", queue: ["e1", "e2", "e3"] }, sfScroll: 4 }, + "", + "/?page=plex&v=player:e1", + ); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +describe("the Plex player on a reload", () => { + it("reopens the item it moved on to, with the list it plays from", async () => { + mount(); + expect(await screen.findByText("player e1 queue e1,e2,e3")).toBeTruthy(); + const before = window.history.length; + fireEvent.click(screen.getByText("next")); + expect(screen.getByText("player e2 queue e1,e2,e3")).toBeTruthy(); + expect(window.history.length).toBe(before); // moving on is no step for Back + cleanup(); // F5: the page goes, the history entry stays + mount(); + expect(await screen.findByText("player e2 queue e1,e2,e3")).toBeTruthy(); + }); + + it("says the item playing now in the address bar, so a bookmark opens it", async () => { + mount(); + fireEvent.click(await screen.findByText("next")); + expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e2"); + cleanup(); + window.history.replaceState(null, "", window.location.href); // a fresh tab: the URL alone + mount(); + expect(await screen.findByText("player e2 queue none")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 28dabe9e..2764e007 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -429,6 +429,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType } if (sub.view.kind === "player") { + const { queue } = sub.view; // The fallback is portaled for the same reason the player itself is: it is a fixed // full-viewport overlay, and this tree renders inside the page scroller. return ( @@ -437,9 +438,11 @@ function PlexBrowseView({ sub }: { sub: ReturnType > sub.follow({ kind: "player", id, queue })} /> ); diff --git a/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx index daf714ee..d5cde7c7 100644 --- a/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx @@ -75,7 +75,7 @@ async function mount() { - + {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx index 8e39e5ef..3d8a436d 100644 --- a/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx @@ -135,6 +135,7 @@ function mount(opts: { first?: string; fromStart?: boolean; onClose?: () => void; + onItemChange?: (id: string) => void; }) { vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!)); const session = vi @@ -149,6 +150,7 @@ function mount(opts: { {})} + onItemChange={opts.onItemChange ?? (() => {})} queue={opts.queue} fromStart={opts.fromStart} /> @@ -795,6 +797,40 @@ describe("the C5 row", () => { }); }); +// The page's history entry keeps the item playing now, so a reload reopens THAT one (the owner, +// 2026-10-25: F5 during a binge restarted the episode the session started with). The player says +// every item it moves on to; the page writes it into the entry (PlexBrowse, useHistorySubview). +describe("the item it moves on to", () => { + it("is said on Next", async () => { + const onItemChange = vi.fn(); + mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange }); + await playingAt("/media/A", 100); + fireEvent.click(screen.getByLabelText("plex.player.next")); + expect(onItemChange).toHaveBeenCalledWith("B"); + }); + + it("is said when an item played to its end advances by itself", async () => { + localStorage.setItem( + "siftlode.plexPlayerPrefs", + JSON.stringify({ subLang: "en", upNext: false }), + ); + vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + const onItemChange = vi.fn(); + mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"], onItemChange }); + await playingAt("/media/A", 2990); + act(() => void video().dispatchEvent(new Event("ended"))); + expect(onItemChange).toHaveBeenCalledWith("B"); + await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B")); + }); + + it("is not said for the item already playing", async () => { + const onItemChange = vi.fn(); + mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange }); + await playingAt("/media/A", 100); + expect(onItemChange).not.toHaveBeenCalled(); + }); +}); + describe("Back goes by position (C5)", () => { it("more than 3 s in: back to the start of this item", async () => { // B opened at its resume point, so a session asked at 0 can only be the restart. diff --git a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx index 117ad31d..40ac9c05 100644 --- a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx @@ -124,7 +124,7 @@ function mount() { - {}} /> + {}} onItemChange={() => {}} /> , ); return session; diff --git a/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx index d797bc3d..22d521f0 100644 --- a/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx @@ -75,7 +75,7 @@ async function mount() { - + {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx index 40fa3418..e8680d20 100644 --- a/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx @@ -127,7 +127,7 @@ function mount(opts: { const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!; const view = render( - {}} queue={opts.queue} /> + {}} onItemChange={() => {}} queue={opts.queue} /> , ); return { ...view, progress, status, qc }; diff --git a/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx index 2d8e164a..7f070626 100644 --- a/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx @@ -89,7 +89,7 @@ describe("PlexPlayer under StrictMode", () => { render( - {}} /> + {}} onItemChange={() => {}} /> , ); @@ -112,7 +112,7 @@ describe("PlexPlayer under StrictMode", () => { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - {}} /> + {}} onItemChange={() => {}} /> , ); const video = await waitFor(() => document.querySelector("video")!); diff --git a/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx index a2460e03..5b90302a 100644 --- a/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx @@ -84,7 +84,7 @@ async function mount() { - {}} /> + {}} onItemChange={() => {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.tsx b/frontend/src/modules/plex/PlexPlayer.tsx index cc805349..3e4619d7 100644 --- a/frontend/src/modules/plex/PlexPlayer.tsx +++ b/frontend/src/modules/plex/PlexPlayer.tsx @@ -133,10 +133,24 @@ const DOUBLE_TAP_MS = 300; // the queue instead of the item's own episode neighbours. // `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it // moves on to starts at its own resume point. -type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean }; +// `onItemChange` hears every item it moves on to (Next, Back, an auto-advance, a collection sibling): +// the page's history entry keeps the item playing NOW, so a reload reopens that one, not the first. +type Props = { + itemId: string; + onClose: () => void; + onItemChange: (id: string) => void; + queue?: string[]; + fromStart?: boolean; +}; type Panel = "none" | "more" | "tracks" | "settings" | "info" | "upNext"; -export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { +export default function PlexPlayer({ + itemId, + onClose, + onItemChange, + queue, + fromStart = false, +}: Props) { const { t, i18n } = useTranslation(); // A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)"). const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language); @@ -745,9 +759,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } movedOnRef.current = true; setAbs(0); setId(otherId); + onItemChange(otherId); } }, - [id, engine, seekTo, requestPlay, settleProgress, finishProgress], + [id, engine, seekTo, requestPlay, settleProgress, finishProgress, onItemChange], ); // Prev/next: from the playlist queue when playing one, else the item's own episode neighbours. diff --git a/frontend/src/modules/plex/plexSubUrl.test.ts b/frontend/src/modules/plex/plexSubUrl.test.ts index 76821e21..f90fc361 100644 --- a/frontend/src/modules/plex/plexSubUrl.test.ts +++ b/frontend/src/modules/plex/plexSubUrl.test.ts @@ -16,9 +16,15 @@ describe("PLEX_SUB_URL encode", () => { expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5"); }); - it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => { + // The item playing now — the list it plays from rides in the history entry, not the URL. + it("mirrors the player as the item it plays", () => { + expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe( + "player:m9", + ); + }); + + it("does NOT mirror the root Discover landing (encode → null)", () => { expect(U.encode({ kind: "discover" })).toBeNull(); - expect(U.encode({ kind: "player", id: "m9" })).toBeNull(); }); }); @@ -33,6 +39,7 @@ describe("PLEX_SUB_URL round-trip", () => { { kind: "person", name: "Jane Doe" }, { kind: "playlist", id: 12 }, { kind: "collection", id: "c5" }, + { kind: "player", id: "m9" }, ]; it.each(cases)("encode→decode is identity for %o", (view) => { const raw = U.encode(view); @@ -67,11 +74,11 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("info:")).toBeNull(); expect(U.decode("person:")).toBeNull(); expect(U.decode("collection:")).toBeNull(); + expect(U.decode("player:")).toBeNull(); }); it("rejects an unknown kind or garbage", () => { expect(U.decode("grid")).toBeNull(); - expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable expect(U.decode("bogus")).toBeNull(); expect(U.decode("")).toBeNull(); }); diff --git a/frontend/src/modules/plex/plexSubUrl.ts b/frontend/src/modules/plex/plexSubUrl.ts index b05bddfc..aaae63af 100644 --- a/frontend/src/modules/plex/plexSubUrl.ts +++ b/frontend/src/modules/plex/plexSubUrl.ts @@ -26,10 +26,11 @@ export type Sub = | { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key) // Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page -// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing, so it (and the -// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a -// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing -// ":" round-trips. +// — the flat browse grid, and the item playing — are bookmarkable. Discover is the ROOT landing, so it +// is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a distinct, shareable +// URL. The player's URL is the item playing NOW (the player rewrites it as it moves on); the list it +// plays from rides in the history entry. The person name is the whole tail after the first ":", so a +// name containing ":" round-trips. export const PLEX_SUB_URL: SubviewUrl = { key: "v", encode: (v) => { @@ -52,8 +53,10 @@ export const PLEX_SUB_URL: SubviewUrl = { return `playlist:${v.id}`; case "collection": return `collection:${v.id}`; + case "player": + return `player:${v.id}`; default: - return null; // discover (root) + the ephemeral player are not URL-mirrored + return null; // discover, the root, is not URL-mirrored } }, // `fromStart` is the Restart CLICK, not a property of the entry: kept in history.state it restarted @@ -75,6 +78,8 @@ export const PLEX_SUB_URL: SubviewUrl = { return arg ? { kind: "show", id: arg } : null; case "info": return arg ? { kind: "info", id: arg } : null; + case "player": + return arg ? { kind: "player", id: arg } : null; case "person": return arg ? { kind: "person", name: arg } : null; case "playlist": {