Files
siftlode/frontend/src/modules/feed/Feed.tsx
T
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +01:00

1055 lines
47 KiB
TypeScript

import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import {
ArrowLeft,
Columns2,
Grid3x3,
Info,
LayoutGrid,
Library,
List,
RefreshCw,
Rows3,
Trash2,
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import {
api,
HttpError,
refusalReason,
type FeedFilters,
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
resetVideoState,
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useDebounced } from "../../lib/useDebounced";
import {
markedRows,
patchCachedProgress,
progressFlushes,
stampFetch,
useVideoMarks,
} from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
parseSort,
SORT_KEYS,
sortYtResults,
YT_SORT_KEYS,
type SortKey,
} from "../../lib/feedSort";
import { type SortOption, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref";
import { SHOW_IDS } from "./feedFiltersInit";
import { FEED_VIEWS, type FeedView } from "../../lib/feedView";
import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters";
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
import SelectedViewChip from "../../features/filters/SelectedViewChip";
import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { useWizard } from "../../features/wizard/wizardContext";
import SortControl from "../../components/ui/SortControl";
import FilterSelect from "../../components/ui/FilterSelect";
import ItemCount from "../../components/ui/ItemCount";
import { FilterFoldButton } from "../../components/ui/FilterFold";
import { useFilterFold } from "../../lib/useFilterFold";
import ViewSwitcher from "../../components/ui/ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { IconButton } from "../../components/ui/IconButton";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
const PAGE = 60;
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" },
{ key: "includeLive", label: "sidebar.content.live" },
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
cards: LayoutGrid,
cardsSmall: Grid3x3,
rows: Rows3,
rowsCompact: List,
tiles: Columns2,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
function matchesView(status: VideoStatus, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
case "watched":
return status === "watched";
case "unwatched":
case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only
// need to drop a card once it's optimistically marked watched/hidden.)
return status !== "watched" && status !== "hidden";
default:
return status !== "hidden"; // all
}
}
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
// `answered`: the server has taken the write — the live search then reads it from the marks
// (`markedRows`), where a newer word from elsewhere can follow it; the feed holds it until a refetch.
type Stamped<T> = { value: T; seq: number; answered?: boolean };
type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// The server took a write: say so on its own value only — a later write for the same video stands.
const withAnswer = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
const own = o[id];
return own?.seq === seq ? { ...o, [id]: { ...own, answered: true } } : o;
};
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
const next = { ...o };
delete next[id];
return next;
};
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
"search_daily_limit",
"search_disabled",
"quota_spent",
"quota",
]);
export default function Feed({
filters,
setFilters,
view,
setView,
canRead,
ytSearch,
onYtSearch,
onExitYtSearch,
channelScoped,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
// The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
// FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller
// (FeedViewProvider on the feed page; ChannelPage threads its own).
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
// 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.
ytSearch: string | null;
onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
// True when this feed is scoped to a single channel (the channel page). Drops sort options
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean;
}) {
const { t, i18n: ui } = useTranslation();
const lang = ui.language;
// "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
const { openWizard: onOpenWizard } = useWizard();
// Same list the panel's badge counts — see lib/useActiveFilters.
const activeFilters = useActiveFilters(filters, setFilters);
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
const fold = useFilterFold();
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
// The write order the stamps above count in, the writes the server has answered, and which of
// them it had answered when the running refetch set out.
const writeSeq = useRef(0);
const answered = useRef(new Set<number>());
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
// before the server took the reset puts the old position back: written again on its landing.
const resets = useRef(new Map<string, number>());
// The open player: which video and where to start (null = resume from saved position). `alone`:
// opened from an address naming a video this list has not loaded, so it plays without the queue.
const [activeVideo, setActiveVideo] = useState<{
video: Video;
startAt: number | null;
alone?: boolean;
} | null>(null);
const qc = useQueryClient();
const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused.
const me = useMe();
const searchOn = me.search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
// and the cache stayed the pre-session server's until a reload (owner).
const playerSession = useRef({
open: false,
changed: false,
pending: new Set<Promise<unknown>>(),
});
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
playerSession.current.open = true;
// Its player writes the next position: a reset no longer speaks for it.
resets.current.delete(video.id);
setActiveVideo({ video, startAt });
}, []);
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass.
const closePlayer = useCallback(() => setActiveVideo(null), []);
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
// every unmount cleanup before any effect's setup, so here that write is already in the session,
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
// list read the older position and, landing after the player's patch, put it back.
const playerOpen = activeVideo !== null;
useEffect(() => {
const session = playerSession.current;
if (playerOpen || !session.open) return;
session.open = false;
if (!session.changed) return;
session.changed = false;
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
void qc.invalidateQueries({ queryKey: qk.feed() });
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
});
}, [playerOpen, qc]);
const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more";
// the free scrape source pages through continuations until this many are collected). Named in the
// address with the term, so it is the navigation's, like the term.
const { ytCount, ytSearchAsked } = useNavigation();
const { setYtCount, askYtSearch } = useNavigationActions();
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
// or Back waits for "Search YouTube" — unless its results are still cached, which a disabled
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
// any quota, and its refusal is what says so (no press offered that could only be refused).
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
// ranking, which is what the view always showed before it had a control.
const [ytSort, setYtSort] = usePref<NonNullable<SortState>>("ytSearchSort", {
key: "relevance",
dir: "desc",
});
// Debounce only the search term feeding the queries: the input still updates instantly (it
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
// and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
const debouncedQ = useDebounced(filters.q, 300);
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({
queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
placeholderData: keepPreviousData,
});
// Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
// explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) =>
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytRuns,
staleTime: 5 * 60_000,
retry: false,
});
// Switching to the relevance sort when a search starts happens atomically in the header's
// input onChange (race-free with the query update). Here we only handle the reverse: when
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
// doesn't rank without a query.
const qEmpty = !filters.q.trim();
useEffect(() => {
if (qEmpty && parseSort(filters.sort).key === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
// guarding against a loop that the guard already prevents.
}, [qEmpty, filters, setFilters]);
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view.
const [overridesFor, setOverridesFor] = useState(filters);
if (overridesFor !== filters) {
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
}
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
// And only the overrides whose write it had answered when it set out (`Stamped`).
const refetching = query.isFetching && !query.isFetchingNextPage;
const wasRefetching = useRef(false);
useEffect(() => {
const started = !wasRefetching.current && refetching;
const landed = wasRefetching.current && !refetching && query.status === "success";
wasRefetching.current = refetching;
if (started) answeredAtRefetch.current = new Set(answered.current);
if (!landed) return;
const known = answeredAtRefetch.current;
setOverrides((o) => unconfirmed(o, known));
setSavedOverrides((o) => unconfirmed(o, known));
for (const seq of known) answered.current.delete(seq);
const zero = new Map<string, number>();
for (const [id, seq] of resets.current) {
if (known.has(seq)) resets.current.delete(id);
else zero.set(id, 0);
}
patchCachedProgress(qc, zero);
}, [refetching, query.status, qc]);
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
const summaryQuery = useQuery({
queryKey: qk.feedSummary(queryFilters),
queryFn: () => api.feedSummary(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
placeholderData: keepPreviousData,
});
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
// Keep the loaded videos in a ref so onState can stay referentially stable
// (it needs the list only to look up a title for the notification). A stable
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
const loadedRef = useRef<Video[]>([]);
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
// A write made while the player is open joins its session: the list refetches once the player
// has closed and every such write has landed (the effect above). The player's own related list
// writes through this too — its marks reach videos this list may hold.
const trackSessionWrite = useCallback((write: Promise<unknown>) => {
const session = playerSession.current;
if (!session.open) return;
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}, []);
const onState = useCallback(
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = writeVideoState(qc, id, status);
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
// would falsely report a change that the .catch below is about to roll back (e.g. when
// the API is unreachable).
write
.then(() => {
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
// already show the correct state (a hidden/watched card drops out immediately), so refetching
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
})
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq));
});
return write;
},
[qc, trackSessionWrite],
);
const onResetState = useCallback(
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
// The position goes into the cached lists, where the player's own closing patch writes the
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
// checkpoint wrote over the progress just made.
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
resetVideoState(qc, id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return;
resets.current.delete(id);
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
});
},
[qc],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
writeWatchLater(qc, id, saved)
.then(() => {
answered.current.add(seq);
setSavedOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
// from the pages it started from, without the mark. The feed's own writes reach the marks too,
// once answered: over these rows only an unanswered one of them is laid (`withOverrides`), or an
// earlier click of the feed's hid a newer mark from elsewhere for the rest of the search.
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
useLayoutEffect(() => {
onStateRef.current = onState;
loadedRef.current = ytActive ? ytLoaded : loaded;
});
// `live`: the live search's rows, which carry the marks — there an answered write stands down.
const withOverrides = (list: Video[], live = false): Video[] =>
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status && !(live && status.answered) ? { ...v, status: status.value } : v;
})
.map((v) => {
const saved = savedOverrides[v.id];
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
});
// The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
// (the feed's filters are in the address under it) — over a live search too, its results the
// queue, once they are in (or the search waits for its press, and it plays alone). Scoped to the
// list it was linked over: leaving the search first gives it up.
useOpenLinkedPlayer(
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
(id) => (ytActive ? ytQueueOf(ytShown(ytLoaded)) : playerQueue).find((v) => v.id === id),
(video, inList) => {
openVideo(video);
// Not in what this list has loaded (further down it, or no longer in it): it plays alone.
if (!inList) setActiveVideo((a) => a && { ...a, alone: true });
},
ytActive ? `yt:${ytSearch}` : "feed",
);
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: qk.feedSummary() });
};
// --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytItems: Video[] = ytShown(ytLoaded);
const ytPlayerQueue: Video[] = ytQueueOf(ytItems);
const ytError =
ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error")
: ytQuery.isError
? t("feed.yt.error")
: null;
// A refusal a retry cannot answer (the day's cap, turned off, the shared budget): Load more —
// the same request — is not offered after it.
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
// Before an answer (a search waiting for its press), the instance's own source says it.
const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape";
return (
<div className="p-4">
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
onExitYtSearch();
dropFeedCaches();
}}
>
{t("feed.yt.back")}
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span>
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
pick, 2026-09-19). The caption is the group's name, the hint its description. */}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
<span aria-hidden="true">{t("feed.yt.show")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("feed.yt.show")}
title={t("feed.yt.showHint")}
value={String(ytCount)}
onChange={(v) => setYtCount(Number(v))}
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
/>
</div>
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
costs nothing and works the same on both search sources. */}
<SortControl
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
sort={ytSort}
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
/>
</div>
{ytItems.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
<Info className="w-3.5 h-3.5 shrink-0" />
<span>{t("feed.yt.ephemeralNote")}</span>
<Button
variant="outline-danger"
size="xs"
icon={<Trash2 />}
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
.clearSearch(ids)
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
>
{t("feed.yt.clearNow")}
</Button>
</div>
)}
</div>
{!ytRuns && ytQuery.data === undefined ? (
// Reopened, not asked for: the term is on screen, the search waits for a press.
<div className="py-16 text-center max-w-md mx-auto">
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
{t("feed.yt.runAgain")}
</Button>
</div>
) : ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can
// help (YouTube busy or unreachable): Search YouTube with the same term reads the
// failure from the cache and sends nothing.
<StateMessage
tone="error"
className="max-w-md mx-auto"
onRetry={ytFinal ? undefined : () => void ytQuery.refetch()}
>
{ytError}
</StateMessage>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
<>
<VirtualFeed
items={ytItems}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && !ytFinal && (
<div className="text-center pt-4">
<Button
variant="outline"
onClick={() =>
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
}
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</Button>
</div>
)}
{/* A page that failed after others loaded: they stay, and the failure is said under
them (Load more above is the retry, when there is one). */}
{ytError && (
<StateMessage tone="error" compact className="mt-3">
{ytError}
</StateMessage>
)}
</>
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={activeVideo.alone ? undefined : ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
</div>
);
}
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
if (query.isError && !query.data)
return (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("feed.loadError")}
</StateMessage>
);
// Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
// library. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseShared")}
</Button>
</div>
</div>
);
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
const sortOptions: SortOption[] = feedSortOptions(
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys,
(k) => t("feed.sortKey." + k),
);
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
// same controls render on both the feed and channel-scoped rows.
// One setter per toolbar control, shared with the page's background menu (the same choices, the
// same effect, two doors).
const setSort = (next: SortState) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
};
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
const setContent = (next: string[]) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
};
const sourceOptions = [
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
];
const setSource = (source: string) =>
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
const viewOptions = FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}));
const sortView = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={setSort}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
<RefreshCw />
</IconButton>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={viewOptions}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</>
);
const toolbar = (
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */}
<ItemCount
count={summaryQuery.data?.count}
stale={summaryQuery.isPlaceholderData}
failed={summaryQuery.isError && !summaryQuery.data}
/>
<FilterFoldButton
fold={fold}
label={t("sidebar.filters")}
count={channelScoped ? 0 : activeFilters.length}
/>
{!fold.folded && (
<>
{/* Watch state is single-select (always exactly one); content types are an
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={showOptions}
selected={[filters.show]}
onChange={(next) => next[0] && setShow(next[0])}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={contentOptions}
selected={contentSelected}
onChange={setContent}
/>
{/* The Source filter (organic / include-search / search-only) is a global-catalog
concept — it selects how videos ENTERED the library. On a channel-scoped view we
already show all of this one channel's videos (librarySource is pinned to "all"), so
the selector would only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={sourceOptions}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) => next[0] && setSource(next[0])}
/>
)}
</>
)}
{/* Folded, the band is one row: Sort + View join it on the right. */}
{fold.folded && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
</div>
);
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
const pageMenuItems = menuGroups(
[
sortSubmenu({
id: "sort",
label: t("feed.sortLabel"),
options: sortOptions,
sort: { key: sortKey, dir: sortDir },
onChange: setSort,
ascLabel: t("feed.dirAsc"),
descLabel: t("feed.dirDesc"),
}),
...(sortKey === "shuffle"
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
: []),
choiceSubmenu({
id: "view",
label: t("feed.viewLabel"),
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
selected: view,
onPick: setView,
}),
],
[
choiceSubmenu({
id: "show",
label: t("feed.showLabel"),
options: showOptions,
selected: filters.show,
onPick: setShow,
}),
multiSubmenu({
id: "content",
label: t("feed.contentLabel"),
options: contentOptions,
selected: contentSelected,
onChange: setContent,
}),
...(channelScoped
? []
: [
choiceSubmenu({
id: "source",
label: t("feed.source.label"),
options: sourceOptions,
selected: filters.librarySource ?? "organic",
onPick: setSource,
}),
]),
],
!channelScoped && activeFilters.length > 0
? [
{
id: "clear",
label: t("sidebar.clearAll"),
onSelect: () => setFilters(clearedFilters(filters)),
},
]
: [],
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
baseline (channel-scoped, whole-catalog, the watch state the channel page remembers), so
each would read as a "filter you applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
applied"), but still get the right-pinned Sort/View. */}
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
{!fold.folded && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
Library. Point there rather than leaving a bare "no matches". */}
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<Button
className="mt-3"
icon={<Library />}
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.viewInLibrary")}
</Button>
</div>
)}
{filters.q.trim() && searchOn && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
onClick={() => onYtSearch(filters.q.trim())}
>
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</Button>
)}
</div>
) : (
<VirtualFeed
items={items}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
// A channel page's view is the channel already: no row there to narrow it to one.
channelFilter={
channelScoped
? undefined
: {
ids: filters.channelIds,
set: (channelIds) => setFilters({ ...filters, channelIds }),
}
}
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view. The row
// below says so and retries on a click.
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
isFetching={query.isFetching}
fetchNextPage={fetchNextPage}
/>
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={activeVideo.alone ? undefined : playerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
{isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
{query.isFetchNextPageError && !isFetchingNextPage && (
<StateMessage
tone="error"
compact
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
>
{t("common.loadMoreFailed")}
</StateMessage>
)}
</div>
);
}