NEXT_PAGE joins ANY fetch in flight: a read-ahead during an invalidation or a poll joined that refetch and appended no page, and nothing asked again until the reader moved the range (the channel page during a backfill). The read-ahead now waits while the list fetches anything (mayReadAhead, re-run on isFetching); a Load older click cancels a poll instead (nextPageOnClick). A page turn to a revealed row restarted the progressive mount at 30 rows; Channels focuses that row in the next commit, and at index 40 of a 50-row page it was not there, so focus fell to <body>. The mount now reaches through the revealed row.
916 lines
37 KiB
TypeScript
916 lines
37 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
keepPreviousData,
|
|
useInfiniteQuery,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { NEXT_PAGE } from "../lib/nextPage";
|
|
import {
|
|
ArrowLeft,
|
|
Columns2,
|
|
Grid3x3,
|
|
Info,
|
|
LayoutGrid,
|
|
Library,
|
|
List,
|
|
RefreshCw,
|
|
Rows3,
|
|
Trash2,
|
|
Youtube,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
|
|
import i18n from "../i18n";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { notify, resolveVideo } from "../lib/notifications";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
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 "./ActiveFilterChips";
|
|
import SelectedViewChip from "./SelectedViewChip";
|
|
import { Button } from "./ui/Button";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { PageToolbar } from "./ui/PageShell";
|
|
import { PageMenu } from "./ui/PageMenu";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
|
import { useWizard } from "./wizardContext";
|
|
import SortControl from "./ui/SortControl";
|
|
import FilterSelect from "./ui/FilterSelect";
|
|
import ItemCount from "./ui/ItemCount";
|
|
import { FilterFoldButton } from "./ui/FilterFold";
|
|
import { useFilterFold } from "../lib/useFilterFold";
|
|
import ViewSwitcher from "./ViewSwitcher";
|
|
import VirtualFeed from "./VirtualFeed";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { IconButton } from "./ui/IconButton";
|
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
|
const PlayerModal = lazy(() => import("./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
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
isDemo = false,
|
|
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;
|
|
isDemo?: 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. Search affordances are hidden for demo.
|
|
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<Record<string, VideoStatus>>({});
|
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
|
// A reset drops the resume position too, which a status override cannot say: without this the card
|
|
// kept its progress bar and its Continue button until the next refetch.
|
|
const [resetIds, setResetIds] = useState<Record<string, true>>({});
|
|
// 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();
|
|
|
|
const openVideo = useCallback(
|
|
(video: Video, startAt: number | null = null) => 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), []);
|
|
|
|
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({});
|
|
setResetIds({});
|
|
}
|
|
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
|
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
|
// Only a real refetch (page count doesn't grow) is authoritative.
|
|
const pagesLenRef = useRef(0);
|
|
const pagesLen = query.data?.pages?.length ?? 0;
|
|
useEffect(() => {
|
|
const appended = pagesLen > pagesLenRef.current;
|
|
pagesLenRef.current = pagesLen;
|
|
if (!appended) {
|
|
setOverrides({});
|
|
setSavedOverrides({});
|
|
setResetIds({});
|
|
}
|
|
}, [query.dataUpdatedAt, pagesLen]);
|
|
|
|
// 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[]>([]);
|
|
|
|
const onState = useCallback(
|
|
(id: string, status: VideoStatus) => {
|
|
setOverrides((o) => ({ ...o, [id]: status }));
|
|
// 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).
|
|
api
|
|
.setState(id, status)
|
|
.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.
|
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
|
if (status === "hidden") {
|
|
const v = loadedRef.current.find((x) => x.id === id);
|
|
notify({
|
|
message: v?.title
|
|
? i18n.t("feed.hiddenNamed", { title: v.title })
|
|
: i18n.t("feed.hidden"),
|
|
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
|
|
meta: {
|
|
kind: "video-hidden",
|
|
videoId: id,
|
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
|
channelId: v?.channel_id ?? "",
|
|
channelName: v?.channel_title ?? "",
|
|
},
|
|
});
|
|
} else if (status === "watched") {
|
|
const v = loadedRef.current.find((x) => x.id === id);
|
|
notify({
|
|
message: v?.title
|
|
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
|
: i18n.t("feed.markedWatched"),
|
|
action: {
|
|
label: i18n.t("feed.unwatch"),
|
|
onClick: () => onStateRef.current(id, "new"),
|
|
},
|
|
meta: {
|
|
kind: "video-watched",
|
|
videoId: id,
|
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
|
channelId: v?.channel_id ?? "",
|
|
channelName: v?.channel_title ?? "",
|
|
},
|
|
});
|
|
} else if (status === "new") {
|
|
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
|
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
|
|
resolveVideo(id);
|
|
}
|
|
})
|
|
.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) => {
|
|
const next = { ...o };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
});
|
|
},
|
|
[qc],
|
|
);
|
|
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
|
|
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
|
|
const onStateRef = useRef(onState);
|
|
// eslint-disable-next-line react-hooks/immutability
|
|
onStateRef.current = onState;
|
|
|
|
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.
|
|
setOverrides((o) => ({ ...o, [id]: "new" }));
|
|
setResetIds((r) => ({ ...r, [id]: true }));
|
|
api
|
|
.clearState(id)
|
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
|
.catch((e: unknown) => {
|
|
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
|
setOverrides((o) => {
|
|
const next = { ...o };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
setResetIds((r) => {
|
|
const next = { ...r };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
});
|
|
},
|
|
[qc],
|
|
);
|
|
|
|
const onToggleSave = useCallback(
|
|
(id: string, saved: boolean) => {
|
|
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
|
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
|
.then(() => {
|
|
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) => ({ ...o, [id]: !saved }));
|
|
});
|
|
},
|
|
[qc],
|
|
);
|
|
|
|
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
|
loadedRef.current = loaded;
|
|
const withOverrides = (list: Video[]): Video[] =>
|
|
list
|
|
.map((v) => {
|
|
const status = overrides[v.id]; // read once so the narrowing sticks
|
|
return status ? { ...v, status } : v;
|
|
})
|
|
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
|
|
.map((v) => {
|
|
const saved = savedOverrides[v.id];
|
|
return saved !== undefined ? { ...v, saved } : 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 ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
|
loadedRef.current = ytLoaded;
|
|
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" />
|
|
<Youtube 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(NEXT_PAGE)}
|
|
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}
|
|
/>
|
|
</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 shared demo account can never connect
|
|
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
|
|
// wizard; real users get the onboarding prompt. 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">
|
|
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
|
</h2>
|
|
<p className="text-sm text-muted mt-2 mb-4">
|
|
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
|
</p>
|
|
{isDemo ? (
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
>
|
|
{t("feed.browseLibrary")}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<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} />
|
|
<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() && !isDemo && (
|
|
<Button
|
|
className="mt-4"
|
|
icon={<Youtube />}
|
|
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}
|
|
hasNextPage={!!hasNextPage}
|
|
isFetching={query.isFetching}
|
|
fetchNextPage={fetchNextPage}
|
|
/>
|
|
)}
|
|
{activeVideo && (
|
|
<Suspense fallback={null}>
|
|
<PlayerModal
|
|
video={activeVideo.video}
|
|
startAt={activeVideo.startAt}
|
|
queue={playerQueue}
|
|
onClose={closePlayer}
|
|
onState={onState}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
{isFetchingNextPage && (
|
|
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|