From 9bd7304bb9dce2185c5af387c849cd8138420043 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 25 Oct 2026 23:24:29 +0100 Subject: [PATCH] fix(plex): a reload during a binge reopens the episode playing now The player stepped to the next item in its own state while the history entry still held the item it opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the player stay open, and the entry under an overlay is rewritten when a step lands back on it). The player's URL is the item playing (`?v=player:`), so a bookmark opens it; the list it plays from stays in the history entry. --- frontend/src/lib/history.subview.dom.test.ts | 78 ++++++++++++++ frontend/src/lib/history.ts | 46 ++++++-- .../plex/PlexBrowse.playerReload.dom.test.tsx | 102 ++++++++++++++++++ frontend/src/modules/plex/PlexBrowse.tsx | 5 +- .../plex/PlexPlayer.backspace.dom.test.tsx | 2 +- .../plex/PlexPlayer.controls.dom.test.tsx | 36 +++++++ .../modules/plex/PlexPlayer.hls.dom.test.tsx | 2 +- .../plex/PlexPlayer.panels.dom.test.tsx | 2 +- .../plex/PlexPlayer.session.dom.test.tsx | 2 +- .../plex/PlexPlayer.strictmode.dom.test.tsx | 4 +- .../plex/PlexPlayer.tabtrap.dom.test.tsx | 2 +- frontend/src/modules/plex/PlexPlayer.tsx | 21 +++- frontend/src/modules/plex/plexSubUrl.test.ts | 13 ++- frontend/src/modules/plex/plexSubUrl.ts | 15 ++- 14 files changed, 302 insertions(+), 28 deletions(-) create mode 100644 frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx 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": {