import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { urlWithParams, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; import { setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { setNavigator } from "../lib/nav"; import { nextNavId, notifyNav } from "../lib/history"; import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit"; // 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 . // // TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference) // and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the // value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never // re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering // every existing card. 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. */ ytSearch: string | null; } export interface NavigationActions { setPage: (next: Page) => void; openChannel: (id: string, name?: string) => void; closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; } const StateContext = createContext({ page: "feed", channelView: null, ytSearch: null, }); const ActionsContext = createContext({ setPage: () => {}, openChannel: () => {}, closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, }); export function useNavigation(): NavigationState { return useContext(StateContext); } export function useNavigationActions(): NavigationActions { return useContext(ActionsContext); } 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), ); // 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). // Written during render (the sanctioned "latest value" pattern): the ref is only READ in event // handlers after commit, and this provider never suspends or renders in a transition, so no // discarded render can leave it stale. const pageRef = useRef(page); pageRef.current = page; const channelViewRef = useRef(channelView); channelViewRef.current = channelView; // The landing page is per-account (PAGE_KEY), but this provider mounts above , which pins the // tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and // falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry // (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage) // skips this if the user already navigated in the pre-pin window. const markDirty = useAccountRehydrate(() => { const stored = loadInitialPage(); if (stored === pageRef.current) return; setPageState(stored); setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative // Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel // (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page, // rather than being orphaned from history so Back can't close it. Mirror the corrected page to the // address bar too (urlWithParams keeps any ?chan/filter params already there). window.history.replaceState( { ...(window.history.state || {}), sfPage: stored }, "", urlWithParams({ page: stored === "feed" ? null : stored }), ); }); const setPage = useCallback( (next: Page) => { // 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) return; markDirty(); 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. 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, }), ); notifyNav(); }, [markDirty], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a // pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's // replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page. const openChannel = useCallback((id: string, name?: string) => { setChannelView({ id, name }); const st = window.history.state || {}; const url = urlWithParams({ chan: id }); if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); }, []); 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) => { // Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt. 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 window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry }, []); 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. useEffect(() => { if (page !== "feed") setYtSearch(null); }, [page]); // Reflect the current module in the browser tab title. Feed = brand only; an open channel page // shows the channel name. useEffect(() => { const brand = "Siftlode"; const label = channelView ? channelView.name || t("header.channelManager") : page === "feed" ? null : t(pageTitleKey(page)); document.title = label ? `${label} · ${brand}` : brand; }, [page, channelView, i18n.language, t]); // 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: page === "feed" ? null : page }); if (st._yt && st._ytScrape) { window.history.replaceState({ ...st, sfPage: page }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; window.history.replaceState({ ...rest, sfPage: page }, "", 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); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const state = useMemo( () => ({ page, channelView, ytSearch }), [page, channelView, ytSearch], ); const actions = useMemo( () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }), [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch], ); return ( {children} ); }