diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36f4f8a..b777c3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -124,7 +124,22 @@ export default function App() { const [page, setPageState] = useState(loadInitialPage); // Live YouTube search term (null = normal feed). 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). + // The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the + // popstate handler below derives ytSearch from it, so Back steps player → search → feed in + // that order (a player opened over the results pops first, then the search, then the feed) — + // instead of the search vanishing because it had no history entry of its own. const [ytSearch, setYtSearch] = useState(null); + const enterYtSearch = useCallback((q: string) => { + setYtSearch(q); + const 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); + }, []); const [wizardOpen, setWizardOpen] = useState(false); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); @@ -230,10 +245,10 @@ export default function App() { // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // survives a later query-string strip.) useEffect(() => { - window.history.replaceState( - { ...window.history.state, sfPage: page }, - "" - ); + // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch + // begins null), so the first Back doesn't resurrect a search we're no longer showing. + const { _yt: _staleYt, ...rest } = window.history.state || {}; + window.history.replaceState({ ...rest, sfPage: page }, ""); function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings @@ -255,6 +270,9 @@ export default function App() { } setPageState(p); localStorage.setItem(PAGE_KEY, p); + // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or + // clears it to match the entry we landed on (and a player popped first leaves it intact). + setYtSearch((e.state?._yt as string) ?? null); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); @@ -505,7 +523,7 @@ export default function App() { filters={filters} setFilters={setFilters} page={page} - onYtSearch={setYtSearch} + onYtSearch={enterYtSearch} onGoToFullHistory={() => { setChannelFilter("needs_full"); setChannelsView("subscribed"); // the status filter applies to the subscriptions tab @@ -579,7 +597,8 @@ export default function App() { isDemo={meQuery.data!.is_demo} onOpenWizard={() => setWizardOpen(true)} ytSearch={ytSearch} - setYtSearch={setYtSearch} + onYtSearch={enterYtSearch} + onExitYtSearch={exitYtSearch} /> )} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 6f94efd..0223735 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -72,7 +72,8 @@ export default function Feed({ isDemo = false, onOpenWizard, ytSearch, - setYtSearch, + onYtSearch, + onExitYtSearch, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -80,10 +81,13 @@ export default function Feed({ canRead: boolean; isDemo?: boolean; onOpenWizard: () => void; - // Live YouTube search: the active search term (null = normal feed), and its setter so the - // empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo. + // Live YouTube search: the active search term (null = normal feed). Entering a search and + // leaving it both step browser history (the search is a feed sub-view with its own history + // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via + // onExitYtSearch. Search affordances are hidden for demo. ytSearch: string | null; - setYtSearch: (q: string | null) => void; + onYtSearch: (q: string) => void; + onExitYtSearch: () => void; }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); @@ -292,7 +296,7 @@ export default function Feed({ // The search just ingested new catalog videos, so the normal feed (which was // disabled and still holds its pre-search cache) is stale. Drop those caches so // it re-fetches fresh on return instead of showing the old (often empty) result. - setYtSearch(null); + onExitYtSearch(); qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["facets"] }); @@ -529,7 +533,7 @@ export default function Feed({

{t("feed.noMatches")}

{filters.q.trim() && !isDemo && (