From 3d26ca0efa2442634b29c3f2a870b16939ab8a7e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 07:33:55 +0100 Subject: [PATCH] feat(player): a bookmark reopens every player as it was, once The Plex player names the playlist it plays through (v=player::playlist:), so a bookmark rebuilds the queue from the playlist's own order while the item is still in it. Found in the browser and fixed: - after a reload the shell's player opened a second one over Home and the feed: it read the catalog row the page had fetched (a disabled query still reads the shared cache); it now waits for ready. - every reload under an open overlay left a dead history step: the first overlay to open takes over the entry the document was loaded on. - the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked feed opened as Home: the feed now names itself (page=feed) like every page. - a linked file or video that is gone was said, but stayed in the address and was said again on every reload: its keys are taken off. The overlay address is written in one place (the entry is named once it is current), and the player keys only the codec reads are no longer exported. --- .../NavigationProvider.dom.test.tsx | 18 ++++---- .../navigation/NavigationProvider.tsx | 17 ++++---- .../player/PlayerModal.url.dom.test.tsx | 16 ++++++++ .../player/useOpenLinkedPlayer.dom.test.tsx | 26 ++++++++++-- .../features/player/useOpenLinkedPlayer.ts | 15 +++++-- frontend/src/lib/filesUrlState.test.ts | 20 +++++++++ .../src/lib/history.overlayUrl.dom.test.tsx | 5 +++ .../lib/history.reloadedOverlay.dom.test.tsx | 36 ++++++++++++++++ frontend/src/lib/history.ts | 30 +++++++++----- frontend/src/lib/playerUrlState.ts | 8 ++-- frontend/src/lib/plexUrlState.dom.test.ts | 10 +++++ frontend/src/lib/urlState.ts | 7 ++++ frontend/src/moduleUrl.contract.test.ts | 21 ++++++++++ .../feed/Feed.playerSession.dom.test.tsx | 3 ++ .../src/modules/files/Files.menu.dom.test.tsx | 21 ++++++++++ frontend/src/modules/files/Files.tsx | 16 ++++++-- .../modules/files/LibraryPlayer.dom.test.tsx | 10 +++++ .../modules/playlists/Playlists.dom.test.tsx | 3 ++ .../plex/PlexBrowse.playerReload.dom.test.tsx | 41 +++++++++++++++++++ frontend/src/modules/plex/PlexBrowse.tsx | 9 ++-- frontend/src/modules/plex/plexSubUrl.test.ts | 17 +++++++- frontend/src/modules/plex/plexSubUrl.ts | 20 ++++++--- .../src/modules/plex/usePlexPlayerQueue.ts | 27 ++++++++++++ 23 files changed, 343 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/filesUrlState.test.ts create mode 100644 frontend/src/lib/history.reloadedOverlay.dom.test.tsx create mode 100644 frontend/src/modules/plex/usePlexPlayerQueue.ts diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 7d2ecdd8..cd188695 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -231,7 +231,7 @@ describe("NavigationProvider · a link carrying another page's keys", () => { it("opens a channel link with only the channel page's keys, even over the page they belong to", () => { window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns"); render(); - expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns"); + expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed"); }); }); @@ -242,10 +242,10 @@ describe("NavigationProvider · the channel page's address", () => { window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest"); const { result } = render(); act(() => result.current.openChannel("UC1", "One")); - expect(window.location.search).toBe("?chan=UC1"); + expect(window.location.search).toBe("?page=feed&chan=UC1"); act(() => window.history.back()); await vi.waitFor(() => expect(result.current.channelView).toBeNull()); - expect(window.location.search).toBe("?q=music&sort=oldest"); + expect(window.location.search).toBe("?q=music&sort=oldest&page=feed"); }); it("says no page owns the address while the channel stands over it", () => { @@ -277,7 +277,7 @@ describe("NavigationProvider · the channel page's address", () => { const { result } = render(); stubNavigation([false]); act(() => result.current.closeChannel()); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); }); }); @@ -293,7 +293,7 @@ describe("NavigationProvider · the player's address", () => { it("a channel link names no player: opening the channel closes it", () => { window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related"); render(); - expect(channelUrl("UC1")).toBe("/?chan=UC1"); + expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1"); }); }); @@ -310,7 +310,7 @@ describe("NavigationProvider · Back", () => { expect(result.current.page).toBe("feed"); // …and the entry says so too, so the address bar and a later Feed click agree with the screen. expect(window.history.state).toMatchObject({ sfPage: "feed" }); - expect(window.location.search).toBe("?keep=1"); + expect(window.location.search).toBe("?page=feed&keep=1"); }); it("lands where a retired page's content went, and the entry names that page", () => { @@ -427,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => { window.history.replaceState(null, "", "/?page=scheduler&keep=1"); const { result } = render(); expect(result.current.page).toBe("feed"); - expect(window.location.search).toBe("?keep=1"); + expect(window.location.search).toBe("?page=feed&keep=1"); expect(window.history.state).toMatchObject({ sfPage: "feed" }); }); @@ -463,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => { qc.setQueryData(qk.me(), member); }); await settle(); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(getAccountRaw(PAGE_KEY)).toBe("feed"); act(() => { @@ -483,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => { }); await settle(); expect(result.current.channelView).toBeNull(); - expect(window.location.search).toBe(""); + expect(window.location.search).toBe("?page=feed"); expect(window.history.state?._chan).toBeUndefined(); }); }); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 80d0fd23..78ff10fa 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -44,12 +44,13 @@ import { // // The contexts, their value types and the hooks live in navigationContext.ts. -/** The address bar of `page`'s root: the page (feed implicit), no channel or player over it, and every - * module's own keys cleared — a page switch resets to that module's root, and its store mirrors what - * it holds back in. */ +/** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or + * the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or + * player over it, and every module's own keys cleared — a page switch resets to that module's root, + * and its store mirrors what it holds back in. */ function pageRootParams(page: Page): Record { return { - page: page === "feed" ? null : page, + page, ...closedChannelParams(), ...nulls(PLAYER_KEYS), ...nulls(moduleUrlKeys()), @@ -126,7 +127,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its - // root, and the address bar reflects the page (feed is implicit) while clearing `chan` and + // root, and the address bar reflects the page (the feed too) while clearing `chan` and // every module's own keys (pageRootParams) — a page switch resets to that module's root, and // each module's store mirrors its state back in on its page. The item it was opened from is // still focused: the state setters above have not re-rendered anything yet. @@ -247,7 +248,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // are always kept so a channel page survives F5. const st = window.history.state || {}; - // Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the + // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any // ?chan / ?v / ?pl / filter params the loaders already read. // A retired page's link — or the remembered last page, when that is what opened — also opens its @@ -261,7 +262,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const url = urlWithParams({ ...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), - page: mountPage === "feed" ? null : mountPage, + page: mountPage, }); if (st._yt && st._ytScrape) { window.history.replaceState({ ...st, sfPage: mountPage }, "", url); @@ -282,7 +283,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { window.history.replaceState( { ...window.history.state, sfPage: p }, "", - urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }), + urlWithParams({ ...retiredParams(sf), page: p }), ); setPageState(p); setAccountRaw(PAGE_KEY, p); diff --git a/frontend/src/features/player/PlayerModal.url.dom.test.tsx b/frontend/src/features/player/PlayerModal.url.dom.test.tsx index 061db317..40743885 100644 --- a/frontend/src/features/player/PlayerModal.url.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.url.dom.test.tsx @@ -165,6 +165,22 @@ describe("the player's address", () => { expect(window.location.search).toBe(""); }); + it("a link's related video that arrives after the queue stepped on stays off the next item", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2"); + let arrive: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise