import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useLiveQuery } from "../lib/useLiveQuery"; import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll"; import { qk } from "../lib/queryKeys"; import { invalidateSubscription } from "../lib/subscriptionCache"; import { useNavigationActions } from "./navigationContext"; import { PriorityStepper } from "./ui/PriorityStepper"; import { useChannelPriority } from "../lib/useChannelPriority"; import { ArrowLeft, Ban, Check, ExternalLink, Eye, EyeOff, History, Loader2, Plus, RefreshCw, SlidersHorizontal, } from "lucide-react"; import Avatar from "./ui/Avatar"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { useScrollFold } from "./ui/scrollFold"; import Feed from "./Feed"; import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar } from "./ui/PageShell"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { useFeedFilters } from "./feedFiltersContext"; import { SHOW_IDS } from "./feedFiltersInit"; import { usePref } from "../lib/usePref"; import { LS } from "../lib/storage"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyActionError } from "../lib/actionErrors"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import type { FeedView } from "../lib/feedView"; import { channelYouTubeUrl, formatDate, formatCompact } from "../lib/format"; import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; import { useTags } from "../lib/useTags"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it // auto-ingests recent uploads in the background ("explore") so they're browsable immediately, // and offers "load more" to page deeper — without permanently polluting everyone's library // (the backend keeps explored videos private to this user until they subscribe). export default function ChannelPage({ channelId, initialName, me, view, setView, onShowInFeed, }: { channelId: string; initialName?: string; me: Me; // The feed's view pref, passed straight through to the Feed this page renders — so its // toolbar carries the same switcher and a change here is the same account-wide choice. view: FeedView; setView: (v: FeedView) => void; // Narrow the real feed to this channel and go there. Offered only for a channel you're // subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't // follow would just land you on an empty page. onShowInFeed: () => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const { closeChannel } = useNavigationActions(); const qc = useQueryClient(); const confirm = useConfirm(); // Seed the channel view's content-type prefs from the global feed (read once at mount). The // channel page is its own filter scope, but WHICH content types the user wants to see (normal / // shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we // inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these. const globalFilters = useFeedFilters(); const [tab, setTab] = useState<"videos" | "about">("videos"); const panel = useTabPanel(); // The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's // fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so // the toolbar lands under the tabs inside the fixed band rather than in the scroller. const [chromeSlot, setChromeSlot] = useState(null); // The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome // is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row. const foldable = useRef(null); const folded = useScrollFold(foldable); // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, // so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall // back to the blank bar if it keeps failing (a genuinely dead URL — rare). const MAX_BANNER_RETRIES = 3; const [bannerAttempt, setBannerAttempt] = useState(0); // The banner source that has actually loaded. Until then the is invisible: an attempt that // fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28). const [bannerLoaded, setBannerLoaded] = useState(null); // A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and // the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the // scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is // in. Same queryKey as before, so mutation invalidations still hit. const detail = useLiveQuery(qk.channel(channelId), () => api.channelDetail(channelId), { intervalMs: channelDetailPollMs, }); const ch = detail.data; // A fresh channel/banner gets the retries back, in the render that changes it: as an effect the // new banner was committed with the previous one's exhausted attempt count still in place. const [bannerFor, setBannerFor] = useState(ch?.banner_url); if (bannerFor !== ch?.banner_url) { setBannerFor(ch?.banner_url); setBannerAttempt(0); } // The poll above is also the ONLY signal that the running backfill has written videos, so it // drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on // the PATCH response, before the background job has stored anything). useBackfillFeedRefresh(ch, qc); // Background auto-ingest ("explore") of an un-subscribed channel's uploads. const [nextToken, setNextToken] = useState(undefined); const [exploring, setExploring] = useState(false); const autoTried = useRef(null); const runExplore = useCallback( (token?: string | null) => { setExploring(true); return ( api .exploreChannel(channelId, token ?? undefined) .then((r) => { setNextToken(r.next_page_token); qc.invalidateQueries({ queryKey: qk.feed() }); // Refetch the detail so `explored` flips true → the "Exploring" badge shows on the // first visit (the GET that drove this render predated the explore that just ran). qc.invalidateQueries({ queryKey: qk.channel(channelId) }); }) // Both the automatic first-visit explore and "load more" used to fail in silence: an empty // channel page that looked like a channel with no videos (E11 S2b). .catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel"))) .finally(() => setExploring(false)) ); }, [channelId, qc, t], ); // First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are // already present (a revisit, or a subscribed channel whose uploads the scheduler backfills). useEffect(() => { if (!ch) return; if (autoTried.current === channelId) return; if (ch.subscribed || ch.blocked) return; if (ch.explored && ch.stored_videos > 0) return; autoTried.current = channelId; // eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel. void runExplore(); }, [ch, channelId, runExplore]); const block = useMutation({ mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() }); }, // No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide // reporter names the failure the error dialog does not cover (a 404 for a channel gone). meta: { failTitle: "errors.writeFailed.blockChannel" }, }); // "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}). // Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore. const toggleHidden = useMutation({ mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }), meta: { failTitle: "errors.writeFailed.hideChannel" }, onSuccess: () => { qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.channels() }); }, }); const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { invalidateSubscription(qc, channelId); notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); }, // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle // here, so no Connect button — but the user at least sees why it didn't work). onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")), }); const unsubscribe = useMutation({ mutationFn: () => api.unsubscribeChannel(channelId), onSuccess: () => invalidateSubscription(qc, channelId), onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full // back-catalog pager. Same trigger the channel manager uses. const deepBackfill = useMutation({ mutationFn: () => api.updateChannel(channelId, { deep_requested: true }), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "info", message: t("channel.fullHistoryQueued") }); }, onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), }); // Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared // hook so the two can't drift). Optimistic on this channel's detail cache; a success re-sorts the // manager list + feed, a failure reverts by refetching the detail. const { setPriority, bumpPriority } = useChannelPriority({ read: (id) => qc.getQueryData(qk.channel(id))?.priority, optimistic: (id, next) => qc.setQueryData(qk.channel(id), (old) => old ? { ...old, priority: next } : old, ), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.feed() }); }, onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), }); // Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic // on this channel's detail cache; a failed attach/detach reverts by refetching, and a success // invalidates the manager list so a tag added here shows there too. // Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed // channel page never shows the control, so the request would be thrown away. const tagsQuery = useTags({ enabled: !!ch?.subscribed }); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const { toggleTag, createAndAttachTag } = useChannelTagActions({ optimisticToggle: (id, tagId, attach) => qc.setQueryData(qk.channel(id), (old) => old ? { ...old, tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId), } : old, ), onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }), onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), }); const onUnsubscribe = async () => { const ok = await confirm({ title: t("channel.unsubTitle"), message: t("channel.unsubBody", { name: ch?.title ?? channelId }), confirmLabel: t("channel.unsubscribe"), danger: true, }); if (ok) unsubscribe.mutate(); }; // Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so // the explored, per-user-private videos show — the backend gates them to this explorer). // The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows // what is still unwatched, and a change sticks — across channels, reloads and devices. const [show, setShow] = usePref("channelShow", "unwatched", { localKey: LS.channelShow, parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"), }); const [ownFilters, setOwnFilters] = useState(() => ({ tags: [], tagModes: DEFAULT_TAG_MODES, q: "", sort: "newest", scope: "all", librarySource: "all", // Content-type prefs inherited from the global feed (seeded once) — no longer forced. includeNormal: globalFilters.includeNormal, includeShorts: globalFilters.includeShorts, includeLive: globalFilters.includeLive, show: "unwatched", channelIds: [channelId], })); const filters: FeedFilters = { ...ownFilters, show }; const setFilters = (next: FeedFilters) => { if (next.show !== show) setShow(next.show); setOwnFilters(next); }; const name = ch?.title ?? initialName ?? channelId; const ytUrl = channelYouTubeUrl(channelId, ch?.handle); const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null; // Handle + stats on one compact meta line under the name (saves the separate stats row). const metaParts = [ ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null, ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) }) : null, // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the // gap between the browsable videos and YouTube's raw count is honest rather than looking like // missing data (the "get full history" button pulls the rest). ch?.video_count != null ? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count ? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count }) : t("channel.videoCount", { count: ch.video_count }) : null, ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) }) : null, // Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful // once we actually hold some of the channel's videos. ch && ch.stored_videos > 0 ? t("channel.videoTypes", { normal: ch.count_normal, short: ch.count_short, live: ch.count_live, }) : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; // The page's name and its state, shown in the identity block or, folded, in the tab row. const heading = ( {name} ); const stateBadge = ch?.blocked ? ( {t("channel.blockedBadge")} ) : ( ch?.explored && !ch?.subscribed && ( {t("channel.exploringBadge")} ) ); // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the // tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile. const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}` : null; return ( <>
{!folded && (
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a // low default size, so request a crisp wide version (=w1707) and object-cover the // desktop "safe area" — the centre 2560×423 (~6:1) band.
per attempt; the ?r= cache-buster on a retry bypasses the // browser's negative cache (googleusercontent accepts an extra query param). key={bannerAttempt} src={bannerSrc} referrerPolicy="no-referrer" alt="" className={`w-full h-full object-cover object-center ${ bannerLoaded === bannerSrc ? "" : "opacity-0" }`} onLoad={() => setBannerLoaded(bannerSrc)} onError={() => { if (bannerAttempt <= MAX_BANNER_RETRIES) { window.setTimeout( () => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1), ); } }} />
) : ( // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the // overlapping avatar + the Back button have the same room and don't collide (a short bar // let the -mt-8 avatar ride up into the Back button).
)}
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints ABOVE it (the banner's positioned container would otherwise cover it). */}
{heading} {stateBadge}
{metaParts.map((part, i) => ( {i > 0 && ·} {part} ))}
)}
{exploring && (
{t("channel.loadingVideos")}
)} {/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
{/* One Back button in both states, so a focused Back keeps its focus when the header folds: over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */} {folded && (
{heading} {stateBadge}
)} setTab(id as "videos" | "about")} tabs={[ { id: "videos", label: t("channel.tabVideos") }, { id: "about", label: t("channel.tabAbout") }, ]} /> {/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back and the name take that room, so at a tablet width the actions wrap onto a line of their own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
{ch?.subscribed && ( )} {ch?.subscribed && (
{t("channels.cols.priority")} bumpPriority(channelId, d)} onSet={(v) => setPriority(channelId, v)} size="md" hint={t("channels.row.priorityHint")} />
)} {/* Only the recent subset is stored → offer to pull the channel's full back-catalogue. Once queued (deep_requested) it shows as loading; hidden once the backfill is done. While loading, the label carries the live stored/total count (the detail poll refreshes it) so a large channel's paced tail reads as visible progress rather than a stuck spinner. */} {ch?.subscribed && !ch.backfill_done && ( )} block.mutate()} loading={block.isPending} label={ch?.blocked ? t("channel.unblock") : t("channel.block")} > {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos from the feed. EyeOff = currently hidden (click to unhide). */} {ch?.subscribed && ( toggleHidden.mutate()} loading={toggleHidden.isPending} label={ch.hidden ? t("channel.unhide") : t("channel.hide")} title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")} > {ch.hidden ? : } )} {!ch?.blocked && (ch?.subscribed ? ( ) : ( ))} {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s otherwise), and personal tags are for your subscriptions — so only offer it on a channel you actually follow. */} {ch?.subscribed && (
toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} onCreate={(name) => createAndAttachTag(channelId, name)} // The header row's leftover width: tags inline, "+N" only when they do not fit. bounded={false} />
)}
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome, just under the tabs. */}
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override; only the grid (and About) render here, in the scroller. */}
{tab === "videos" ? ( <> {}} onExitYtSearch={() => {}} channelScoped /> {!ch?.subscribed && nextToken && (
)} ) : (
{ch ? ( ) : (

{t("channel.noDescription")}

)}
)}
); }