import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react"; import { api, HttpError, type FeedFilters, type Video } from "../lib/api"; import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import VirtualFeed from "./VirtualFeed"; import PlayerModal from "./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 SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; 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); // 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. type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle"; const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"]; const SORT_MAP: Record, { asc: string; desc: string }> = { date: { desc: "newest", asc: "oldest" }, popular: { desc: "views", asc: "views_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" }, title: { asc: "title", desc: "title_desc" }, subscribers: { desc: "subscribers", asc: "subscribers_asc" }, priority: { desc: "priority", asc: "priority_asc" }, }; const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc", }; function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } { if (s === "shuffle") return { key: "shuffle", dir: "desc" }; for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) { if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" }; if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; } return { key: "date", dir: "desc" }; } function buildSort(key: SortKey, dir: "asc" | "desc"): string { return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir]; } function matchesView(status: string, 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 } } export default function Feed({ filters, setFilters, view, canRead, isDemo = false, onOpenWizard, ytSearch, onYtSearch, onExitYtSearch, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; view: "grid" | "list"; canRead: boolean; isDemo?: boolean; onOpenWizard: () => void; // 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; }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // 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 }), [] ); const ytActive = !!ytSearch; // 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: ["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: ["yt-search", ytSearch], queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, enabled: ytActive, staleTime: 5 * 60_000, retry: false, }); // 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. useEffect(() => { setOverrides({}); setSavedOverrides({}); }, [filters]); useEffect(() => { setOverrides({}); setSavedOverrides({}); }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: ["feed-count", queryFilters], queryFn: () => api.feedCount(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([]); const onState = useCallback( (id: string, status: string) => { 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(() => { qc.invalidateQueries({ queryKey: ["feed"] }); 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: () => onState(id, "new") }, meta: { kind: "video-hidden", videoId: id, title: v?.title ?? "this video", 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: () => onState(id, "new") }, meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video", 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(() => // Server rejected the change — drop the optimistic override so the card // reverts to the real (unchanged) state instead of staying phantom-hidden. setOverrides((o) => { const next = { ...o }; delete next[id]; return next; }) ); }, [qc] ); 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" })); api .clearState(id) .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .catch(() => setOverrides((o) => { const next = { ...o }; delete next[id]; return next; }) ); }, [qc] ); const onChannelFilter = useCallback( (channelId: string, channelName: string) => { setFilters({ ...filters, channelId, channelName }); }, [filters, setFilters] ); const onToggleSave = useCallback( (id: string, saved: boolean) => { setSavedOverrides((o) => ({ ...o, [id]: saved })); (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) .then(() => { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist"] }); }) .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); }, [qc] ); const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; const items: Video[] = loaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); // --- 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[] = ytLoaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); 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 (
{ytQuery.isLoading ? (
{t("feed.yt.searching")}
) : ytError ? (
{ytError}
) : ytItems.length === 0 ? (
{t("feed.yt.noResults")}
) : ( <> {}} /> {ytQuery.hasNextPage && (
)} )} {activeVideo && ( setActiveVideo(null)} onState={onState} /> )}
); } if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; // 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 (

{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}

{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}

{isDemo ? ( ) : ( <> )}
); const { key: sortKey, dir: sortDir } = parseSort(filters.sort); const toolbar = (
{SHOW_IDS.map((id) => ( ))}
{countQuery.data ? t("feed.videoCount", { count: countQuery.data.count, formattedCount: countQuery.data.count.toLocaleString(), }) : " "}
{t("feed.sortLabel")} {sortKey !== "shuffle" && ( )} {sortKey === "shuffle" && ( )}
); return (
{toolbar} {items.length === 0 ? (

{t("feed.noMatches")}

{filters.q.trim() && !isDemo && ( )}
) : ( )} {activeVideo && ( setActiveVideo(null)} onState={onState} /> )} {isFetchingNextPage && (
{t("feed.loadingMore")}
)}
); }