A related video played in place is the player's item now: its title and its own watched / playlist / download controls, its progress checkpointed and resumed, marked watched at its end — and its end does not step the queue. Back returns to the opened video, where it was left. The related list: - its marks announce through the shared notice (the Notification Center finds them), join the host's player session (the feed refetches on close), and stay per write, so an earlier answer no longer undoes a later mark still on its way; - every host's state write patches the cached related lists, which are never refetched; - kept for the session (gcTime), a retry asks for the page that failed, a list read so far with nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names itself on hover. Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons are left out. Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the top layer; the help sheet lists the list keys over a layer that holds a list.
969 lines
42 KiB
TypeScript
969 lines
42 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, 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 { notify } from "../../lib/notifications";
|
|
import { useMe } from "../../lib/useMe";
|
|
import { useDebounced } from "../../lib/useDebounced";
|
|
import { patchCachedProgress, patchRelated, progressFlushes } 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";
|
|
// 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.
|
|
type Stamped<T> = { value: T; seq: number };
|
|
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)));
|
|
// 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;
|
|
};
|
|
|
|
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
|
const YT_COUNTS = [20, 40, 60, 100] as const;
|
|
|
|
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).
|
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
|
null,
|
|
);
|
|
const qc = useQueryClient();
|
|
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
|
const searchOn = useMe().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).
|
|
const [ytCount, setYtCount] = useState(20);
|
|
// 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 }) =>
|
|
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
|
initialPageParam: null as string | null,
|
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
|
enabled: ytActive,
|
|
staleTime: 5 * 60_000,
|
|
retry: false,
|
|
});
|
|
|
|
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
|
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
|
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
|
useEffect(() => {
|
|
if (!ytActive) return;
|
|
const st = window.history.state;
|
|
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
|
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
|
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
|
}
|
|
}, [ytActive, ytSearch, ytQuery.data]);
|
|
|
|
// 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 = api.setState(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);
|
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
|
patchRelated(qc, id, { status });
|
|
// 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));
|
|
});
|
|
},
|
|
[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);
|
|
api
|
|
.clearState(id)
|
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
|
.then(() => {
|
|
answered.current.add(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 } }));
|
|
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
|
.then(() => {
|
|
answered.current.add(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);
|
|
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
|
// 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;
|
|
});
|
|
const withOverrides = (list: Video[]): Video[] =>
|
|
list
|
|
.map((v) => {
|
|
const status = overrides[v.id]; // read once so the narrowing sticks
|
|
return status ? { ...v, status: status.value } : v;
|
|
})
|
|
.map((v) => {
|
|
const saved = savedOverrides[v.id];
|
|
return saved ? { ...v, saved: saved.value } : v;
|
|
});
|
|
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 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[] = sortYtResults(
|
|
withOverrides(ytLoaded),
|
|
ytSort.key as SortKey,
|
|
ytSort.dir,
|
|
lang,
|
|
);
|
|
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
|
const ytError =
|
|
ytQuery.error instanceof HttpError
|
|
? ytQuery.error.detail || t("feed.yt.error")
|
|
: ytQuery.isError
|
|
? t("feed.yt.error")
|
|
: null;
|
|
// 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.
|
|
const zeroQuota = ytQuery.data?.pages?.[0]?.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>
|
|
|
|
{ytQuery.isLoading ? (
|
|
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
|
) : ytError ? (
|
|
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
|
|
) : 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 && (
|
|
<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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeVideo && (
|
|
<Suspense fallback={null}>
|
|
<PlayerModal
|
|
video={activeVideo.video}
|
|
startAt={activeVideo.startAt}
|
|
queue={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={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>
|
|
);
|
|
}
|