diff --git a/CHANGELOG.md b/CHANGELOG.md index 88de373e..3f4efffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.113.0 — 2026-10-28 + +### New + +- The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view. +- A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so. + +### Fixed + +- Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing. +- Signed out on a Plex page, the sign-in card could show a button with no text. +- The channel page's address names the channel and its own view, not the page under it. +- A reload during a Plex binge reopens the episode playing now, not the one it started from. + ## 0.112.1 — 2026-10-27 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 223ce247..0534ad64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` | +| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view | | FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it | | FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` | | FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it | diff --git a/VERSION b/VERSION index 52afba83..5fea1768 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.112.1 +0.113.0 diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 60b68538..f36294dd 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -249,6 +249,21 @@ def set_video_progress( return {"ok": True, "video_id": video_id, "position_seconds": position} +@router.get("/videos/{video_id}/row") +def get_video_row( + video_id: str = Path(max_length=32), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """One catalog video as the feed lists it (its channel, this user's watch state and resume point, + saved): what a player opened from an address starts from. The catalog only, so it costs nothing; + every video a player can open (a list's row, a related one) is in it.""" + rows = queries.feed_rows_by_ids(db, user, [video_id]) + if not rows: + raise not_found("video") + return rows[0] + + @router.get("/videos/{video_id}") def get_video_detail( video_id: str, diff --git a/backend/tests/test_video_row.py b/backend/tests/test_video_row.py new file mode 100644 index 00000000..a851442c --- /dev/null +++ b/backend/tests/test_video_row.py @@ -0,0 +1,47 @@ +"""A catalog video as the feed lists it, the row a player opened from an address starts from. DB lane.""" + +from datetime import datetime, timezone + +from app.catalog.models import Channel, Video +from app.reader.models import VideoState + + +def _video(db, vid="v1"): + db.add(Channel(id="UCo", title="Owner", handle="@owner")) + db.flush() + db.add( + Video( + id=vid, + title="A talk", + channel_id="UCo", + published_at=datetime.now(timezone.utc), + duration_seconds=3600, + ) + ) + db.commit() + + +def test_a_video_comes_as_the_feed_row_with_my_own_state(db, make_user, as_user): + me, other = make_user(), make_user() + _video(db) + db.add(VideoState(user_id=me.id, video_id="v1", status="new", position_seconds=600)) + db.add(VideoState(user_id=other.id, video_id="v1", status="watched", position_seconds=90)) + db.commit() + r = as_user(me).get("/api/videos/v1/row") + assert r.status_code == 200, r.text + row = r.json() + assert (row["id"], row["title"], row["channel_title"]) == ("v1", "A talk", "Owner") + assert (row["status"], row["position_seconds"]) == ("new", 600) + assert row["watch_url"] == "https://www.youtube.com/watch?v=v1" + + +def test_a_video_never_opened_has_no_state(db, make_user, as_user): + _video(db) + row = as_user(make_user()).get("/api/videos/v1/row").json() + assert (row["status"], row["position_seconds"], row["saved"]) == ("new", 0, False) + + +def test_a_video_outside_the_catalog_is_not_found(db, make_user, as_user): + r = as_user(make_user()).get("/api/videos/nope/row") + assert r.status_code == 404 + assert r.json()["detail"]["code"] == "not_found" diff --git a/docs/development.md b/docs/development.md index 0bb8d445..465b327c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,18 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in page renders into the floating header itself through `PageHeader`; the shell knows no page. 2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a `lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically - only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. + only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every + address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside + its codec; the shell clears them on a page switch and opens a page without another page's keys. + Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec + in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out — + or always written, when an address naming none falls back to a view remembered elsewhere), written with + `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s), + always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount + (a link wins over anything remembered). A key that carries typed text — a search box's, a column filter + map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round + trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@" }`). Only what an address cannot promise to + reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`). 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's hooks). Another folder reaches the module through this file or not at all. 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in @@ -58,7 +69,10 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in returns to `ui/FilterRail`; tsc names each table still missing the new id. What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), -`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go +`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key +belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/` +function's result, never a key of its own; no key literal written or read from the live query undeclared; every key +sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the i18n parity test. diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts new file mode 100644 index 00000000..a72aa2dd --- /dev/null +++ b/frontend/e2e/bookmark.spec.ts @@ -0,0 +1,259 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { + clickVisibleChannelLink, + expectContentType, + gotoPage, + openApp, + openFirstVideo, + toggleContentType, +} from "./helpers"; + +// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW +// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view +// storage it wrote) can stand in for what the address says. One round trip per page that names its +// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions), +// plus the overlays the shell opens over any page (the channel page, the player) and the live +// search. Not Plex: what it shows is the instance's own library, which a test stack may not have. + +const STORAGE_STATE = "e2e/.auth/state.json"; + +/** The page's address, opened as a bookmark in a fresh context of the same account. */ +async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise) { + const url = page.url(); + const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" }); + const fresh = await ctx.newPage(); + await prepare?.(fresh); + await openApp(fresh, url); + return fresh; +} + +const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true }); +const column = (p: Page, name: string) => + p.getByRole("columnheader", { name: new RegExp(`^${name}`) }); + +async function gotoAdminPage(page: Page, id: string): Promise { + await page.getByTestId("nav-admin").click(); + await page.getByTestId(`nav-admin-${id}`).click(); +} + +test.describe("a bookmark reopens the view it was taken of", () => { + test("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await toggleContentType(page, "includeLive"); + await expect(page).toHaveURL(/[?&]live=/); + const fresh = await bookmarked(browser, page); + await expectContentType(fresh, "includeLive", true); + await fresh.context().close(); + }); + + test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => { + await openApp(page, "/?page=home"); + await page.getByRole("button", { name: "An evening", exact: true }).click(); + await expect(page).toHaveURL(/[?&]hb=long/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await fresh.context().close(); + }); + + test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "settings"); + await tab(page, "Notifications").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoAdminPage(page, "users"); + await tab(page, "Access requests").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoAdminPage(page, "config"); + const second = page.getByRole("tab").nth(1); + const name = (await second.textContent()) ?? ""; + await second.click(); + await expect(page).toHaveURL(/[?&]cfgtab=/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await fresh.context().close(); + }); + + test("the Activity tab and its time window", { tag: "@activity" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoAdminPage(page, "activity"); + await tab(page, "Server log").click(); + await page.getByRole("button", { name: "30 days", exact: true }).click(); + await expect(page).toHaveURL(/[?&]aw=30d/); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true"); + await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await fresh.context().close(); + }); + + test("a playlist and its sort", { tag: "@playlists" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "playlists"); + await expect(column(page, "Title")).toBeVisible(); + await column(page, "Title").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]plsort=/); + const sorted = await column(page, "Title").getAttribute("aria-sort"); + const fresh = await bookmarked(browser, page); + await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? ""); + expect(new URL(fresh.url()).searchParams.get("pl")).toBe( + new URL(page.url()).searchParams.get("pl"), + ); + await fresh.context().close(); + }); + + test( + "the Channels manager's tab and name search", + { tag: "@channels" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "channels"); + await tab(page, "Hidden").click(); + await page.getByPlaceholder("Search channels…").fill("zz"); + await expect(page).toHaveURL(/zz/); // written once the typing settles + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true"); + await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz"); + await fresh.context().close(); + }, + ); + + test("the Files search", { tag: "@files" }, async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "files"); + await page.getByPlaceholder("Search files…").fill("zz"); + await expect(page).toHaveURL(/zz/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz"); + await fresh.context().close(); + }); + + // A file of the test's own, uploaded for it and deleted after, and the account's sort put back: + // the account is left as it was found. + test( + "the Files sort, and the file open in the viewer", + { tag: "@files" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + const NAME = "bookmark-note"; + // One a run cut short left behind goes first. + const left = (await (await page.request.get("/api/files")).json()) as Record< + string, + unknown + >[]; + for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) + await page.request.delete(`/api/files/${String(f.id)}`); + const up = await page.request.post("/api/files/upload", { + headers: { "X-Upload-Filename": `${NAME}.txt` }, + data: Buffer.from("a note the bookmark reopens\n"), + }); + expect(up.ok(), await up.text()).toBeTruthy(); + const { id } = (await up.json()) as { id: number }; + // The account's own sort, put back after: a header press cycles it (asc, desc, none), so the + // case starts from none, as the address names it, whatever an earlier run left. + const me = (await (await page.request.get("/api/me")).json()) as { + preferences?: Record; + }; + const ownSort = me.preferences?.filesSort ?? null; + try { + await openApp(page, "/?page=files&fsort=none"); + await column(page, "Name").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]fsort=name/); + const sorted = await column(page, "Name").getAttribute("aria-sort"); + await page.getByText(NAME, { exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`)); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible(); + await fresh.keyboard.press("Escape"); + await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? ""); + await fresh.context().close(); + } finally { + await page.request.delete(`/api/files/${id}`); + await page.request.put("/api/me/preferences", { data: { filesSort: ownSort } }); + } + }, + ); + + test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + expect(await clickVisibleChannelLink(page)).toBe(true); + const title = await page.getByTestId("channel-title").textContent(); + await tab(page, "About").click(); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? ""); + await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("the player and the video it plays", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await openFirstVideo(page); + await expect(page).toHaveURL(/[?&]watch=/); + const watch = new URL(page.url()).searchParams.get("watch"); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("player-modal")).toBeVisible(); + expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch); + await fresh.context().close(); + }); + + // The search itself never reaches YouTube here: the answer is the server's shape, served by the + // test, and the request it answers is what the address named. The instance's search settings are + // pinned too (the free source, switched on): a stack left on the API source reopens the search + // waiting for its press, by design, and one with search off offers none. + test( + "a live YouTube search, its term and its count", + { tag: "@feed" }, + async ({ page, browser }) => { + const answer = async (p: Page, seen: string[]) => { + await p.route("**/api/me", async (route) => { + const me = (await (await route.fetch()).json()) as Record; + return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); + }); + await p.route("**/api/search/youtube?**", (route) => { + seen.push(route.request().url()); + return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); + }); + }; + await answer(page, []); + await openApp(page, "/?page=feed"); + await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); + await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); + await expect(page.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set + await page.getByRole("button", { name: "40", exact: true }).click(); + await expect(page).toHaveURL(/[?&]ytn=40/); + + const seen: string[] = []; + const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); + await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect.poll(() => seen.length).toBeGreaterThan(0); + const asked = new URL(seen[0]!).searchParams; + expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); + await fresh.context().close(); + }, + ); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c54c2b96..7bc0790d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -898,7 +898,8 @@ export default tseslint.config( plugins: { playwright }, rules: { "playwright/no-focused-test": "error", - "playwright/no-skipped-test": "error", + // fixme too: a test Playwright never runs, which the bookmark net would still count. + "playwright/no-skipped-test": ["error", { disallowFixme: true }], }, }, { diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 4d3d5a94..86964f22 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -65,6 +65,7 @@ export default function Modal({ placement = "center", initialFocus, headerActions, + url, }: { title: ReactNode; initialFocus: ModalFocus; @@ -81,10 +82,14 @@ export default function Modal({ placement?: "center" | "drawer" | "drawer-start" | "top"; /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ headerActions?: ReactNode; + /** What the dialog names in the address while it is open, when it is part of the view (a viewer, a + * player): written into its own history entry, so a reload or a bookmark reopens it and Back lands + * on the address without it (lib/history). */ + url?: Record; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. - useBackToClose(onClose); + useBackToClose(onClose, url); // ONE container element for the dialog's whole life, re-parented between and the fullscreen // element — and attached before the children commit, so an `autoFocus` child // still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it. diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index bc4b8abc..15afb988 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome"; // Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px: // the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there // it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with -// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats): -// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every +// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in +// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on +// the first sub-tab. Not every // caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page // and the player settings start on their first tab each time. // diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index de3226ca..a691894b 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,18 @@ [ + { + "version": "0.113.0", + "date": "2026-10-28", + "new": [ + "The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.", + "A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so." + ], + "fixed": [ + "Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.", + "Signed out on a Plex page, the sign-in card could show a button with no text.", + "The channel page's address names the channel and its own view, not the page under it.", + "A reload during a Plex binge reopens the episode playing now, not the one it started from." + ] + }, { "version": "0.112.1", "date": "2026-10-27", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 72277b72..7ae3364c 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,18 @@ [ + { + "version": "0.113.0", + "date": "2026-10-28", + "new": [ + "A cím megnevezi, amit látsz: a füleket, a listanézeteket, a rendezést és a kereséseket (az élő YouTube-keresést is), a YouTube-lejátszót és a fájlnézőt. Újratöltés, könyvjelző vagy megosztott link ugyanazt a nézetet nyitja meg.", + "Egy videóra mutató link egyszer újranyitja a lejátszót az oldala fölött, a Vissza bezárja. Ha az oldal már nem listázza a videót, a lejátszó csak azt az egyet játssza le, és ezt jelzi is." + ], + "fixed": [ + "A Vissza és az Előre úgy lép végig a nézeteken, ahogy láttad őket, párbeszédablakokon, lejátszókon és újratöltésen át: nyitott lejátszó alatti újratöltés után nem marad semmit sem csináló Vissza-lépés.", + "Plex-oldalon kijelentkezve a bejelentkező kártyán felirat nélküli gomb jelenhetett meg.", + "A csatornaoldal címe a csatornát és annak saját nézetét nevezi meg, nem az alatta lévő oldalt.", + "Plexben sorozatnézés közben az újratöltés az épp futó epizódot nyitja meg, nem azt, amelyikkel kezdted." + ] + }, { "version": "0.112.1", "date": "2026-10-27", diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 55684824..cd188695 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -27,6 +27,8 @@ vi.mock("react-i18next", async (importOriginal) => { import { NavigationProvider } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./navigationContext"; +import { urlPage } from "./urlPage"; +import { channelUrl } from "../../lib/urlState"; import { useBackToClose } from "../../lib/history"; import { PageHeading } from "../../components/ui/Headings"; @@ -167,6 +169,19 @@ describe("NavigationProvider · setPage's same-page guard", () => { expect(window.location.search).toBe("?page=plex&keep=1"); }); + it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => { + // Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key + // missing from a hand-kept list rode along onto the next page's entry. + window.history.replaceState( + { sfPage: "files" }, + "", + "/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1", + ); + const { result } = render(); + act(() => result.current.setPage("plex")); + expect(window.location.search).toBe("?page=plex&keep=1"); + }); + it("pushes nothing when it is already that page", () => { const { result } = render(); act(() => result.current.setPage("plex")); @@ -195,6 +210,93 @@ describe("NavigationProvider · setPage's same-page guard", () => { }); }); +describe("NavigationProvider · a link carrying another page's keys", () => { + it("opens with only its own page's keys: what the page does not show, its address does not say", () => { + window.history.replaceState( + {}, + "", + "/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1", + ); + render(); + expect(window.location.search).toBe("?page=activity&at=logs&keep=1"); + }); + + it("keeps the channel page open over any page", () => { + window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all"); + const { result } = render(); + expect(window.location.search).toBe("?page=stats&chan=UC1"); + expect(result.current.channelView?.id).toBe("UC1"); + }); + + 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&page=feed"); + }); +}); + +// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's +// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show. +describe("NavigationProvider · the channel page's address", () => { + it("names only the channel; the page under it keeps its own address for Back", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest"); + const { result } = render(); + act(() => result.current.openChannel("UC1", "One")); + 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&page=feed"); + }); + + it("says no page owns the address while the channel stands over it", () => { + window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex"); + const { result } = render(); + expect(result.current.page).toBe("plex"); + expect(urlPage(result.current)).toBe("plex"); + act(() => result.current.openChannel("UC1", "One")); + expect(urlPage(result.current)).toBeNull(); + }); + + it("another channel opened from it starts fresh", () => { + const { result } = render(); + act(() => result.current.openChannel("UC1", "One")); + window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest"); + act(() => result.current.openChannel("UC2", "Two")); + expect(window.location.search).toBe("?chan=UC2"); + }); + + it("a page switch takes the channel page's keys with it", () => { + window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n"); + const { result } = render(); + act(() => result.current.setPage("stats")); + expect(window.location.search).toBe("?page=stats"); + }); + + it("closing it in place takes its keys with it", () => { + window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n"); + const { result } = render(); + stubNavigation([false]); + act(() => result.current.closeChannel()); + expect(window.location.search).toBe("?page=feed"); + }); +}); + +// The YouTube player opens over any page and names itself in its own entry (lib/playerUrlState). +describe("NavigationProvider · the player's address", () => { + it("a page switch takes a player the address still names with it", () => { + window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related"); + const { result } = render(); + act(() => result.current.setPage("stats")); + expect(window.location.search).toBe("?page=stats"); + }); + + 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("/?page=feed&chan=UC1"); + }); +}); + describe("NavigationProvider · Back", () => { // A tab that had a since-removed module open, reloaded onto the new build: its older history // entries still name that page, and Back lands on one of them. @@ -208,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", () => { @@ -325,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" }); }); @@ -361,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(() => { @@ -381,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.reloadedRewrite.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx new file mode 100644 index 00000000..b104771d --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// +// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark +// still on it. A view rewritten in place there (the search refined) is another view: the next overlay +// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back +// onto the entry below, and the screen went back to the search before the refine. Its own file: the +// entry is read as the history module loads, so it is set up before the import. +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); + +describe("a view rewritten in place on the entry a reload under a dialog started from", () => { + it("is where an overlay over it closes to", async () => { + window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs"); + window.history.pushState( + { sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 }, + "", + "/?page=feed&yt=dogs", + ); + const { NavigationProvider } = await import("./NavigationProvider"); + const { useNavigation, useNavigationActions } = await import("./navigationContext"); + const { useBackToClose } = await import("../../lib/history"); + const { qk } = await import("../../lib/queryKeys"); + const { claimLocalData } = await import("../../lib/storage"); + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, role: "admin" }); + claimLocalData(1); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); + act(() => nav.result.current.enterYtSearch("cats")); + + const depth = window.history.length; + const overlay = renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth + 1); + + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + overlay.unmount(); + await landed; + }); + expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats"); + expect(nav.result.current.ytSearch).toBe("cats"); + }, 30_000); // the provider's whole import graph loads inside the case (see the header) +}); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 37909d56..93989d48 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -2,8 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react import { useLatest } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useMe } from "../../lib/useMe"; -import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; -import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; +import { + channelUrl, + resolvePage, + retiredParams, + urlWithParams, + type Page, +} from "../../lib/urlState"; import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { @@ -12,8 +17,17 @@ import { nextNavId, notifyNav, rememberNavPosition, + replaceView, } from "../../lib/history"; -import { pageTitleKey, reachablePage } from "./moduleRegistry"; +import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; +import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; +import { PLAYER_KEYS } from "../../lib/playerUrlState"; +import { + DEFAULT_YT_COUNT, + ytCountFrom, + ytSearchFrom, + ytSearchParams, +} from "../../lib/ytSearchUrlState"; import { channelViewFrom, loadInitialPage, @@ -37,24 +51,35 @@ import { // // The contexts, their value types and the hooks live in navigationContext.ts. -/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */ +/** 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, - chan: null, - v: null, - pl: null, - pscope: null, - psort: null, - pshow: null, - pf: null, - ctab: null, - csort: null, - cf: null, - ...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])), + page, + ...closedChannelParams(), + ...nulls(PLAYER_KEYS), + ...nulls(moduleUrlKeys()), }; } +/** The address of the page under a live search, the search ended: no search, player or channel. */ +const searchEndParams = (): Record => ({ + ...closedChannelParams(), + ...nulls(PLAYER_KEYS), + ...ytSearchParams(null, DEFAULT_YT_COUNT), +}); + +const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null])); + +/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) — + * a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */ +const closedChannelParams = (): Record => ({ + chan: null, + ...nulls(CHANNEL_PAGE_KEYS), +}); + export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); // Held here as well as in Settings: this is the instance mounted on every boot, so a value only @@ -66,18 +91,30 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // and the address bar (stamped from it below) agree. const me = useMe(); const page = reachablePage(chosenPage, me); - // Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search - // spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides - // in history.state and is free to re-fetch. The popstate handler derives it from history.state so - // Back steps player → search → feed instead of the search vanishing without a history entry. - const [ytSearch, setYtSearch] = useState(() => { - const st = window.history.state; - return st?._yt && st?._ytScrape ? (st._yt as string) : null; - }); + // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState) + // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed. + // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was + // asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries + // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or + // a Forward onto a search — runs only when it costs no quota (Feed decides). + const [ytSearch, setYtSearch] = useState(() => + page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, + ); + const [ytCount, setYtCountState] = useState(() => + ytCountFrom(new URLSearchParams(window.location.search)), + ); + const [ytAsked, setYtAsked] = useState(null); + const ytSearchRef = useLatest(ytSearch); + const ytCountRef = useLatest(ytCount); + // The search ends: off the screen, and no longer asked for. + const endYtSearch = useCallback(() => { + setYtSearch(null); + setYtAsked(null); + }, []); // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // detail loads) AND is mirrored to the address bar as ?chan=, so Back closes it, a reload restores - // it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app - // entry reads history.state._chan; a fresh bookmark has only the ?chan param. + // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a + // fresh bookmark has only the ?chan param. const [channelView, setChannelView] = useState(() => channelViewFrom(window.history.state, window.location.search), ); @@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) { closeOverlaysThenNavigate(() => { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). - if (next === pageRef.current && !channelViewRef.current) { + // So must a live search on it, though that press ends only the search: the page's view stays. + const samePage = next === pageRef.current && !channelViewRef.current; + if (samePage && ytSearchRef.current == null) { then?.(); return; } setChannelView(null); // leave any open channel page when navigating via the rail + // A live search under that channel ends too: the page opens at its root, which the address + // below names — the Feed press over a channel opened from a search included. + endYtSearch(); setPageState(next); 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 the - // view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page - // switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left - // intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to - // their page. The item it was opened from is still focused: the state setters above have not - // re-rendered anything yet. + // 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. + // The press on a search over its own page ends only the search: the page's view is the one + // on screen and stays named — its store, unchanged, writes nothing back in. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", - urlWithParams(pageRootParams(next)), + urlWithParams(samePage ? searchEndParams() : pageRootParams(next)), ); notifyNav(); then?.(); }), - [pageRef, channelViewRef], + [pageRef, channelViewRef, ytSearchRef, endYtSearch], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands @@ -139,10 +181,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // inside one. const openChannel = useCallback((id: string, name?: string) => { closeOverlaysThenNavigate(() => { - const url = urlWithParams({ chan: id }); + // The channel's address names the channel alone: the page under it keeps its own keys on its + // own entry, where Back finds them, and writes none while the channel stands over it (urlPage). + const url = channelUrl(id); if (window.history.state?._chan) { const st = window.history.state; - window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); + replaceView({ ...st, _chan: id, _chanName: name ?? null }, url); } else { const st = rememberNavPosition(); window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); @@ -157,36 +201,67 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the // entry's channel marks and the ?chan param. + // A live search under it (the channel opened from its results) is on screen again, and named + // again: the channel's address carried none of it. setChannelView(null); const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; - window.history.replaceState(st, "", urlWithParams({ chan: null })); + replaceView( + st, + urlWithParams({ + ...closedChannelParams(), + ...ytSearchParams(ytSearchRef.current, ytCountRef.current), + }), + ); } - }, []); + }, [ytSearchRef, ytCountRef]); const enterYtSearch = useCallback((q: string) => { setYtSearch(q); - // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it - // once the results resolve, so a reload only restores a confirmed scrape-sourced search. - const { _ytScrape: _drop, ...st } = window.history.state || {}; - if (st._yt) - window.history.replaceState({ ...st, _yt: q }, ""); // refine current search - else { - // New sub-view entry. - const { _ytScrape: _dropToo, ...left } = rememberNavPosition(); - window.history.pushState({ ...left, sfPage: "feed", _yt: q }, ""); + setYtAsked(q); + const st = window.history.state ?? {}; + const here = new URLSearchParams(window.location.search); + if (st._yt || ytSearchFrom(here) != null) { + // Refine the search on screen, in place, at the count it shows (one a link opened too: it + // is the first entry of this document, so its new `_yt` mark has nothing of ours below it). + replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here)))); + } else { + // New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it. + setYtCountState(DEFAULT_YT_COUNT); + const left = rememberNavPosition(); + window.history.pushState( + { ...left, sfPage: "feed", _yt: q, _ytOwn: 1 }, + "", + urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)), + ); } }, []); + // The press that runs the search on screen, where it did not run by itself. + const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]); + + const setYtCount = useCallback( + (n: number) => { + setYtCountState(n); + replaceView( + window.history.state ?? {}, + urlWithParams(ytSearchParams(ytSearchRef.current, n)), + ); + }, + [ytSearchRef], + ); + const exitYtSearch = useCallback(() => { // Pop our search entry (so Forward still works); fall back to a clear in place if we don't own - // one, or there is no entry of this document below it (a full tab, see backStaysInDocument). - if (window.history.state?._yt && backStaysInDocument()) window.history.back(); + // one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is + // another site, or nothing — or there is no entry of this document below it (a full tab, see + // backStaysInDocument). + if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back(); else { - setYtSearch(null); - const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {}; - window.history.replaceState(st, ""); + endYtSearch(); + const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {}; + replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } - }, []); + }, [endYtSearch]); // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted // DURING the render that changes the page, against the page we were last on: as an effect it ran @@ -196,7 +271,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); - if (page !== "feed") setYtSearch(null); + if (page !== "feed") endYtSearch(); } // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): @@ -233,25 +308,33 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // then sync state from history.state on popstate — the browser restores each entry's URL for us, so // the popstate handler only needs to read history.state. useEffect(() => { - // A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape - // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName - // are always kept so a channel page survives F5. + // The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent + // from the box, a reload, a link): a channel opened from its results reads the search under it + // from that mark. A mark left without one (an older build's entry) goes. _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 - // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any - // ?chan / ?v / ?pl / filter params the loaders already read. + // 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 keeps the + // shell's keys (?chan, a player's) and the page's own; other pages' go (below). // A retired page's link — or the remembered last page, when that is what opened — also opens its // part of the page that replaced it (an `at` tab). const linked = new URLSearchParams(window.location.search).get("page"); const remembered = getAccountRaw(PAGE_KEY); + // A link can carry another page's keys (hand-edited, or written by an older build): the page + // opens without them, so its address names only what it shows — and a channel page open over it + // shows none of the page's own (an older build's channel links carried the feed's filters). + const shown = channelViewFrom(st, window.location.search); 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); + const named = + mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null; + if (named != null) { + window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url); } else { - const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; + const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st; window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); } function onPop(e: PopStateEvent) { @@ -267,13 +350,22 @@ 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); - // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward - // restores or clears them to match the entry we landed on. - setYtSearch((e.state?._yt as string) ?? null); + // The YouTube search is read from the address the browser restored, so Back/Forward opens or + // clears it to match the entry we landed on. One landed on without it has ended: a Forward back + // onto it is not asked for, and an API search waits for a press. + // A channel page opened from its results stands over it: its entry carries the search's mark + // (its address does not), and the search stays under it — closing a player over that channel + // lands there, and is no leaving of the search. + const here = new URLSearchParams(window.location.search); + const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null; + const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null; + setYtSearch(landed); + if (landed == null) setYtAsked(null); + setYtCountState(ytCountFrom(here)); // The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark // entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom @@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) { }, [mountPage]); const state = useMemo( - () => ({ page, channelView, ytSearch }), - [page, channelView, ytSearch], + () => ({ + page, + channelView, + ytSearch, + ytCount, + ytSearchAsked: ytSearch != null && ytAsked === ytSearch, + }), + [page, channelView, ytSearch, ytCount, ytAsked], ); const actions = useMemo( - () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }), - [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch], + () => ({ + setPage, + openChannel, + closeChannel, + enterYtSearch, + exitYtSearch, + askYtSearch, + setYtCount, + }), + [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount], ); return ( diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx new file mode 100644 index 00000000..17922af2 --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -0,0 +1,320 @@ +// @vitest-environment jsdom +// +// The live YouTube search in the address: sent from the search box it gets its own entry naming the +// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the +// browser restored — and only a search asked for in this document counts as asked (an API search +// reopened any other way waits for a press, Feed's part). +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { renderHook, act, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), + }; +}); + +import { NavigationProvider } from "./NavigationProvider"; +import { useNavigation, useNavigationActions } from "./navigationContext"; +import { qk } from "../../lib/queryKeys"; +import { claimLocalData } from "../../lib/storage"; + +let qc = new QueryClient(); +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); +const render = () => + renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); +const search = () => new URLSearchParams(window.location.search); + +/** No entry of this document under the current one (a bookmark opened in a new tab). */ +function stubNoEntryBelow() { + Object.defineProperty(window, "navigation", { + configurable: true, + value: { + currentEntry: { index: 1 }, + entries: () => [{ sameDocument: false }, { sameDocument: true }], + }, + }); +} + +beforeEach(() => { + qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true }); + localStorage.clear(); + claimLocalData(1); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + Reflect.deleteProperty(window, "navigation"); +}); + +describe("NavigationProvider · the YouTube search in the address", () => { + it("names a search sent from the box on an entry of its own, over the feed's", () => { + const { result } = render(); + const depth = window.history.length; + act(() => result.current.enterYtSearch("lofi")); + expect(window.history.length).toBe(depth + 1); + expect(search().get("yt")).toBe("lofi"); + expect(search().get("q")).toBe("lofi"); // the feed's own keys stay + expect(search().get("ytn")).toBeNull(); // the default count is left out + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(true); + }); + + it("refines the search on screen in place, at the count it shows", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(60)); + expect(search().get("ytn")).toBe("60"); + const depth = window.history.length; + act(() => result.current.enterYtSearch("lofi beats")); + expect(window.history.length).toBe(depth); + expect(search().get("yt")).toBe("lofi beats"); + expect(search().get("ytn")).toBe("60"); + expect(result.current.ytCount).toBe(60); + }); + + it("a new search starts at the default count", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(40)); + stubNoEntryBelow(); // left in place, so the next search is a new entry + vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + act(() => result.current.enterYtSearch("jazz")); + expect(result.current.ytCount).toBe(20); + expect(search().get("ytn")).toBeNull(); + }); + + it("writes the count, and clears it back at the default", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(100)); + expect(search().get("ytn")).toBe("100"); + act(() => result.current.setYtCount(20)); + expect(search().get("ytn")).toBeNull(); + }); + + it("reopens the search a reload or a link names, not as asked", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytCount).toBe(40); + expect(result.current.ytSearchAsked).toBe(false); + expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it + act(() => result.current.askYtSearch()); + expect(result.current.ytSearchAsked).toBe(true); + }); + + it("refines a search a link opened in place too, at the count it shows", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + const depth = window.history.length; + act(() => result.current.enterYtSearch("jazz")); + expect(window.history.length).toBe(depth); + expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]); + }); + + it("Back onto an entry whose address alone names the search reopens it", () => { + const { result } = render(); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + }); + + it("keeps the search mark of an entry the reload started from", () => { + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + render(); + expect(window.history.state?._yt).toBe("lofi"); + }); + + it("marks the entry of a search the address opened (a reload, a link)", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + render(); + expect(window.history.state?._yt).toBe("lofi"); + }); + + it("drops an older build's search mark the address does not name", () => { + window.history.replaceState( + { sfPage: "feed", _yt: "lofi", _ytScrape: true }, + "", + "/?page=feed", + ); + const { result } = render(); + expect(result.current.ytSearch).toBeNull(); + expect(window.history.state?._yt).toBeUndefined(); + expect(window.history.state?._ytScrape).toBeUndefined(); + }); + + it("names no search under another page", () => { + window.history.replaceState(null, "", "/?page=plex&yt=lofi"); + const { result } = render(); + expect(result.current.page).toBe("plex"); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + + it("Back/Forward read the search from the address the browser restored, not as asked", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("jazz"); + expect(result.current.ytCount).toBe(60); + expect(result.current.ytSearchAsked).toBe(false); + + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBeNull(); + }); + + it("leaving a search with no entry of this document below clears it from the address", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + stubNoEntryBelow(); + const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + expect(back).not.toHaveBeenCalled(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + expect(search().get("ytn")).toBeNull(); + }); + + it("a search left by Back and reached again by Forward is not asked for", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + expect(result.current.ytSearchAsked).toBe(true); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(false); + }); + + it("a search left in place is not asked for when it is opened again by a link", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi"); + const { result } = render(); + act(() => result.current.askYtSearch()); + stubNoEntryBelow(); + act(() => result.current.exitYtSearch()); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearchAsked).toBe(false); + }); + + it("the Feed press over a channel opened from the search opens the plain feed", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.openChannel("UC1")); + act(() => result.current.setPage("feed")); + expect(result.current.channelView).toBeNull(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + + it("the Feed press on the search itself opens the plain feed, as over a channel", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + const depth = window.history.length; + act(() => result.current.setPage("feed")); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search + }); + + it("the Feed press on the search keeps the feed's own view in the address", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest"); + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setPage("feed")); + expect(search().get("yt")).toBeNull(); + expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]); + }); + + it("a search sent from the box is left by a step back off its own entry", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + expect(back).toHaveBeenCalledTimes(1); + }); + + it("a search a link opened is left in place, not by a step back off the document", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi"); + const { result } = render(); + const back = vi.spyOn(window.history, "back"); + act(() => result.current.exitYtSearch()); + expect(back).not.toHaveBeenCalled(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + + it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.openChannel("UC1")); + // A player over the channel closed: the step back lands on the channel's own entry. + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(true); + // Back off the channel: the search it stood over is still the one asked for. + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearchAsked).toBe(true); + }); + + it("a landing on a channel opened from a search the address opened keeps the search", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + const { result } = render(); + act(() => result.current.openChannel("UC1")); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + }); + + it("a channel closed in place over the search names the search again", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(40)); + act(() => result.current.openChannel("UC1")); + expect(search().get("yt")).toBeNull(); + stubNoEntryBelow(); + act(() => result.current.closeChannel()); + expect(result.current.ytSearch).toBe("lofi"); + expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]); + }); + + it("a page switch takes the search off the address", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setPage("home")); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); +}); diff --git a/frontend/src/features/navigation/channelOverPage.dom.test.tsx b/frontend/src/features/navigation/channelOverPage.dom.test.tsx new file mode 100644 index 00000000..b8cfb360 --- /dev/null +++ b/frontend/src/features/navigation/channelOverPage.dom.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +// +// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05: +// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is +// still mounted — its store lives at the root — and a change in it while the channel stands over it +// (a background refetch, the header search) must not write its keys onto the channel's address. Closing +// the channel brings them back. The real provider and the real stores; only the network is absent. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect } from "react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); + +import { NavigationProvider } from "./NavigationProvider"; +import { ModuleStores } from "./ModuleStores"; +import { useNavigationActions, type NavigationActions } from "./navigationContext"; +import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed"; +import { usePlexActions } from "../../modules/plex"; +import { useChannelsActions } from "../../modules/channels"; +import { useFilesActions } from "../../modules/files"; +import { usePlaylistsActions } from "../../modules/playlists"; +import { claimLocalData } from "../../lib/storage"; + +type Probe = { + nav: NavigationActions; + change: Record void>; +}; +const probe: { current: Probe | null } = { current: null }; + +function Probe() { + const nav = useNavigationActions(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); + const plex = usePlexActions(); + const channels = useChannelsActions(); + const files = useFilesActions(); + const playlists = usePlaylistsActions(); + // Handed out after each render (an effect, not the render itself, may write outside it). + useEffect(() => { + probe.current = { + nav, + change: { + feed: () => setFilters({ ...filters, sort: "oldest" }), + plex: () => plex.setScope("movie"), + channels: () => channels.setView("hidden"), + files: () => files.setView({ kind: "folder", id: 3 }), + playlists: () => playlists.setSelectedId(5), + "plex search": () => plex.setQ("poirot"), + "channels search": () => channels.setSearch("tom"), + "files search": () => files.setSearch("notes"), + "playlists search": () => playlists.setSearch("jazz"), + }, + }; + }); + return null; +} + +// What each change above writes, once its page is what the address shows: [page, key, value]. A +// search is written once the typing pauses. +const WRITTEN: Record = { + feed: ["feed", "sort", "oldest"], + plex: ["plex", "pscope", "movie"], + channels: ["channels", "ctab", "hidden"], + files: ["files", "folder", "3"], + playlists: ["playlists", "pl", "5"], + "plex search": ["plex", "pq", "poirot"], + "channels search": ["channels", "cq", "tom"], + "files search": ["files", "fq", "notes"], + "playlists search": ["playlists", "plq", "jazz"], +}; +const PAUSE = () => new Promise((r) => setTimeout(r, 400)); + +beforeEach(() => { + localStorage.clear(); + claimLocalData(3); +}); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +const search = () => new URLSearchParams(window.location.search); + +describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => { + it("keeps the page's keys off its address, and they come back when it closes", async () => { + const [page, key, value] = WRITTEN[change]!; + // The page named even for the feed: a bare address opens on Home (the account's default). + window.history.replaceState({ sfPage: page }, "", `/?page=${page}`); + render( + + + + + + + , + ); + act(() => probe.current!.nav.openChannel("UC1", "One")); + act(() => probe.current!.change[change]!()); + await act(PAUSE); + expect(search().get(key)).toBeNull(); + expect(search().get("chan")).toBe("UC1"); + act(() => probe.current!.nav.closeChannel()); + await waitFor(() => expect(search().get("chan")).toBeNull()); + await waitFor(() => expect(search().get(key)).toBe(value)); + }); +}); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts index 2f557273..aa84d5ef 100644 --- a/frontend/src/features/navigation/moduleRegistry.ts +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -48,6 +48,21 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id); const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record; +// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and +// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys +// are cleared the day it declares them. +const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys); +export function moduleUrlKeys(): readonly string[] { + return ALL_URL_KEYS; +} +/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */ +export function typedUrlKeys(): readonly string[] { + return ALL.flatMap((m) => m.typedUrlKeys ?? []); +} +export function foreignUrlKeys(page: Page): readonly string[] { + return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys); +} + // Look up a module's definition by page id (every Page is covered, incl. settings). export function moduleDef(id: Page): ModuleDef { return BY_ID[id]; diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts index b033ae96..08d5aa7a 100644 --- a/frontend/src/features/navigation/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -1,5 +1,6 @@ import { createContext, useContext } from "react"; import type { Page } from "../../lib/urlState"; +import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState"; import type { ChannelView } from "./navigationInit"; // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only @@ -15,8 +16,13 @@ export interface NavigationState { page: Page; /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ channelView: ChannelView | null; - /** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */ + /** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */ ytSearch: string | null; + /** How many results the search gathers (`ytn`). */ + ytCount: number; + /** The search on screen was asked for in this document (sent from the search box, or run again by + * a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */ + ytSearchAsked: boolean; } export interface NavigationActions { @@ -33,12 +39,17 @@ export interface NavigationActions { closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; + /** Run the search on screen: the "Search YouTube" press. */ + askYtSearch: () => void; + setYtCount: (n: number) => void; } export const NavigationStateContext = createContext({ page: "feed", channelView: null, ytSearch: null, + ytCount: DEFAULT_YT_COUNT, + ytSearchAsked: false, }); export const NavigationActionsContext = createContext({ setPage: () => {}, @@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext({ closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, }); export function useNavigation(): NavigationState { diff --git a/frontend/src/features/navigation/urlPage.ts b/frontend/src/features/navigation/urlPage.ts new file mode 100644 index 00000000..b63ffbb8 --- /dev/null +++ b/frontend/src/features/navigation/urlPage.ts @@ -0,0 +1,9 @@ +import type { Page } from "../../lib/urlState"; +import type { NavigationState } from "./navigationContext"; + +/** The page whose own keys the address bar carries: none while the channel page stands over it, so + * every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows. + * A plain function beside the context, not a hook in it: the stores' tests mock that module. */ +export function urlPage(nav: Pick): Page | null { + return nav.channelView ? null : nav.page; +} diff --git a/frontend/src/features/player/LinkedPlayer.tsx b/frontend/src/features/player/LinkedPlayer.tsx new file mode 100644 index 00000000..f6f86231 --- /dev/null +++ b/frontend/src/features/player/LinkedPlayer.tsx @@ -0,0 +1,40 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { type Video, type VideoStatus } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { notifyActionError } from "../../lib/actionErrors"; +import { useStableCallback } from "../../lib/useLatest"; +import { writeVideoState } from "../video/videoWrites"; +import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; + +const PlayerModal = lazy(() => import("./PlayerModal")); + +/** The shell's player, for an address that names one over a page that hosts none of its own (the + * channel manager, where the watched-videos dialog played it; any page a link names): the video + * alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */ +export function LinkedPlayer({ hosted }: { hosted: boolean }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [own] = useState(!hosted); + const [video, setVideo] = useState