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 { urlWithParams, type Page } from "../lib/urlState"; import { LOGS_KEYS } from "../lib/logsUrlState"; import { pageTitleKey } from "../lib/pageMeta"; import { useDocumentObjectTitle } from "../lib/documentTitle"; import { setAccountRaw } from "../lib/storage"; import { setNavigator } from "../lib/nav"; import { closeOverlaysThenNavigate, nextNavId, notifyNav, rememberNavPosition, } from "../lib/history"; import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit"; import { NavigationActionsContext, NavigationStateContext, type NavigationActions, type NavigationState, } from "./navigationContext"; // Owns the app's navigation + browser-history state, lifted out of the App god component so pages // consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree. // Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside // ConfirmProvider, around . // // The contexts, their value types and the hooks live in navigationContext.ts. export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); const [page, setPageState] = useState(loadInitialPage); // 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 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. const [channelView, setChannelView] = useState(() => channelViewFrom(window.history.state, window.location.search), ); // Latest-value refs so the STABLE action callbacks can read the latest state without depending on // it — a dep would recreate them on every navigation and re-render every actions consumer (e.g. // VideoCard). Only READ in event handlers, after commit. const pageRef = useLatest(page); const channelViewRef = useLatest(channelView); const setPage = useCallback( (next: Page, then?: () => void) => // Never under an open overlay — see the interface doc. The refs are read when the push // actually happens, which over an open player is a moment after this call. 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) { then?.(); return; } setChannelView(null); // leave any open channel page when navigating via the rail 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. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null, pscope: null, psort: null, pshow: null, pf: null, ctab: null, csort: null, cf: null, ...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])), }), ); notifyNav(); then?.(); }), [pageRef, channelViewRef], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands // under an open overlay: the player's channel link and the palette's channel rows call it from // inside one. const openChannel = useCallback((id: string, name?: string) => { closeOverlaysThenNavigate(() => { const url = urlWithParams({ chan: id }); if (window.history.state?._chan) { const st = window.history.state; window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); } else { const st = rememberNavPosition(); window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); } setChannelView({ id, name }); }); }, []); const closeChannel = useCallback(() => { if (window.history.state?._chan) window.history.back(); else { // No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and // drop the ?chan param from the address bar. setChannelView(null); window.history.replaceState(window.history.state, "", urlWithParams({ chan: null })); } }, []); 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 }, ""); } }, []); const exitYtSearch = useCallback(() => { // Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one. if (window.history.state?._yt) window.history.back(); else setYtSearch(null); }, []); // Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton. useEffect(() => setNavigator(setPage), [setPage]); // 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 // after the new page had already committed, so for one frame a module rendered with a live search // still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has // to be permanent — a derived null would quietly come back when you returned to the feed. const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); if (page !== "feed") setYtSearch(null); } // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): // " · · Siftlode". The object is the open view's own heading (a channel, a Plex // title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same // as one opened in the app. Signed out, the page on screen is the public landing whatever `page` // says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19). const signedIn = useMe() != null; const objectTitle = useDocumentObjectTitle(); useEffect(() => { const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : []; document.title = [...parts.filter(Boolean), "Siftlode"].join(" · "); }, [signedIn, page, objectTitle, i18n.language, t]); // The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history // entry: every later page change writes its own entry through setPage. const [mountPage] = useState(page); // In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar), // 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. 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. const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage }); if (st._yt && st._ytScrape) { window.history.replaceState({ ...st, sfPage: mountPage }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); } function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; 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 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 // shares this resolution with the seed initializer above. setChannelView(channelViewFrom(e.state, window.location.search)); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, [mountPage]); const state = useMemo( () => ({ page, channelView, ytSearch }), [page, channelView, ytSearch], ); const actions = useMemo( () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }), [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch], ); return ( {children} ); }