import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import Avatar from "./Avatar"; import Feed from "./Feed"; import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar, PageToolbarSlot } from "./PageShell"; import { useFeedFilters } from "./FeedFiltersProvider"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { api, type FeedFilters, type Me } from "../lib/api"; import type { FeedView } from "../lib/feedView"; import { channelYouTubeUrl, formatViews } from "../lib/format"; // 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 { 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"); // 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 URL is valid, but googleusercontent intermittently throttles it when the channel // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would // succeed. 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); const detail = useQuery({ queryKey: ["channel", channelId], queryFn: () => api.channelDetail(channelId), }); const ch = detail.data; useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again // 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: ["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: ["channel", channelId] }); }) .finally(() => setExploring(false)); }, [channelId, qc] ); // First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo // account (explore spends shared quota) and when videos are already present (a revisit, or a // subscribed channel whose uploads the scheduler backfills). useEffect(() => { if (!ch || me.is_demo) return; if (autoTried.current === channelId) return; if (ch.subscribed || ch.blocked) return; if (ch.explored && ch.stored_videos > 0) return; autoTried.current = channelId; runExplore().catch(() => {}); }, [ch, channelId, me.is_demo, runExplore]); const block = useMutation({ mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["blocked-channels"] }); }, }); const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["channels"] }); // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["discovered-channels"] }); 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: () => { qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["channels"] }); }, }); 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). const [filters, setFilters] = useState(() => ({ tags: [], tagMode: "or", 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: "all", channelIds: [channelId], })); const name = ch?.title ?? initialName ?? channelId; const ytUrl = channelYouTubeUrl(channelId, ch?.handle); const joined = ch?.published_at ? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" }) : 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: formatViews(ch.subscriber_count) }) : null, ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null, ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; const tabClass = (active: boolean) => active ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; // 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 // banner/identity/tabs/toolbar stay put and only the video grid scrolls. return ( <> {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
{ch?.banner_url && 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={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`} alt="" className="w-full h-full object-cover object-center" 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 + actions. relative+z-base so the avatar that overlaps up into the banner paints ABOVE it (the banner's positioned container would otherwise cover it). */}

{name}

{ch?.blocked ? ( {t("channel.blockedBadge")} ) : ( ch?.explored && !ch?.subscribed && ( {t("channel.exploringBadge")} ) )}
{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. */}
{ch?.subscribed && ( )} {!me.is_demo && ( )} {!me.is_demo && !ch?.blocked && (ch?.subscribed ? ( ) : ( ))}
{/* 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")}

)}
)}
); }