feat(channels): a "Discover from your history" tab for the channels watched but not followed

The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
This commit is contained in:
2026-10-24 00:00:50 +02:00
parent bd2340c204
commit b5f9cf14db
17 changed files with 831 additions and 9 deletions
+13 -3
View File
@@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import WatchedChannels from "./WatchedChannels";
import TagManager from "../../features/channel/TagManager";
import {
useChannelLayout,
@@ -183,6 +184,8 @@ export default function Channels() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() });
// A blocked channel is off the watched-channels list; unblocked, it may be back on it.
qc.invalidateQueries({ queryKey: qk.watchedChannels() });
},
});
@@ -530,7 +533,7 @@ export default function Channels() {
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
);
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
// Table / cards density — shared by the subscribed + both discovery tables (blocked is a chip list, no
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
id,
@@ -546,7 +549,7 @@ export default function Channels() {
labelClassName="hidden lg:inline"
/>
);
const hasLayout = view === "subscribed" || view === "discovery";
const hasLayout = view === "subscribed" || view === "discovery" || view === "watched";
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = (
@@ -612,7 +615,7 @@ export default function Channels() {
const viewTabs: TabDef[] = useMemo(
() =>
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
(["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
id: v,
label: t(`channels.tabs.${v}`),
})),
@@ -778,6 +781,13 @@ export default function Channels() {
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "watched" ? (
<WatchedChannels
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">