Files
youclonedl/frontend/src/pages/Home.jsx
Mattias Thall bbf7cc939b Overhaul For You feed ranking and freshness
Ranking improvements:
- Wider candidate pool (4x limit) with ±12pt score perturbation so
  same-score videos shuffle differently each load
- Recent channel engagement signal: channels watched in past 30 days
  get a +4pts/watch boost
- Bail penalty: -25pts for videos started but abandoned before 20%
- Impression penalty: -3pts per prior feed appearance (capped at 10),
  so repeatedly-skipped videos sink naturally
- rn cap raised to 5 for more candidates; Python-side sampling picks top limit

Feed UX:
- Reshuffle button now available on For You (ranked) mode, not just Explore
- shuffleKey now always included in query key (not just random mode)
- Ranked mode staleTime reduced from 10min to 90s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:14:10 +02:00

317 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api";
import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 25;
const FEED_MODES = [
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
{ value: "chronological", label: "New", hint: "Everything in date order" },
{ value: "rediscover", label: "Rediscover", hint: "Older unwatched videos ranked by your taste" },
{ value: "random", label: "Explore", hint: "Random from discovery pool" },
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
];
export default function Home() {
const qc = useQueryClient();
const [surpriseResults, setSurpriseResults] = useState(null);
const [mode, setMode] = useState(() => localStorage.getItem("home-feed-mode") ?? "ranked");
const [page, setPage] = useState(0);
const [dismissed, setDismissed] = useState(new Set());
const [shuffleKey, setShuffleKey] = useState(0);
const [duration, setDuration] = useState("");
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "list");
const toggleViewMode = () => {
const next = viewMode === "grid" ? "list" : "grid";
localStorage.setItem("home-view-mode", next);
setViewMode(next);
};
const { data: userSettings } = useQuery({
queryKey: ["settings"],
queryFn: () => getSettings().then(r => r.data),
staleTime: 60_000,
});
const { data: channels = [] } = useQuery({
queryKey: ["channels"],
queryFn: () => getChannels().then(r => r.data),
staleTime: 60_000,
});
const inboxCount = channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
const markSeenMut = useMutation({
mutationFn: () => markChannelsSeen(),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const hideWatched = userSettings?.hide_watched_from_feed ?? false;
const handleHideWatchedToggle = () => {
const next = !hideWatched;
updateSettings({ hide_watched_from_feed: next });
qc.setQueryData(["settings"], old => old ? { ...old, hide_watched_from_feed: next } : old);
};
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
queryKey: ["home-feed", mode, page, hideWatched, duration, shuffleKey],
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
staleTime: mode === "ranked" ? 90_000 : 10 * 60_000,
placeholderData: (prev) => prev,
});
const surpriseMut = useMutation({
mutationFn: () => surpriseMe().then((r) => r.data),
onSuccess: (data) => setSurpriseResults(data),
});
const visibleFeed = useMemo(
() => feedData.filter(v => !dismissed.has(v.youtube_video_id)),
[feedData, dismissed],
);
const hasFollowing = channels.length > 0 || feedData.length > 0 || page > 0;
const hasNextPage = mode === "ranked"
? feedData.filter(v => !v.is_recommended).length === PAGE_SIZE
: feedData.length === PAGE_SIZE;
const handleDismiss = (video) =>
setDismissed(prev => new Set([...prev, video.youtube_video_id]));
const handleModeChange = (newMode) => {
localStorage.setItem("home-feed-mode", newMode);
setMode(newMode);
setPage(0);
setDismissed(new Set());
};
const handleDurationChange = (d) => {
setDuration(prev => prev === d ? "" : d);
setPage(0);
setDismissed(new Set());
};
const handleReshuffle = () => {
setShuffleKey(k => k + 1);
setPage(0);
setDismissed(new Set());
scrollToTop();
};
return (
<div className="flex flex-col gap-10">
{loadingFeed ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : hasFollowing ? (
<section className="flex flex-col gap-6">
{/* Title row + secondary controls */}
<div className="flex items-center justify-between gap-2">
<h2 className="font-display font-semibold text-base sm:text-xl text-zinc-200">Home</h2>
<div className="flex items-center gap-1.5">
<button
onClick={toggleViewMode}
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
className="flex items-center justify-center w-7 h-7 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
>
{viewMode === "grid" ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
)}
</button>
<button
onClick={handleHideWatchedToggle}
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
className={[
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
hideWatched ? "text-accent" : "text-zinc-600 hover:text-zinc-400",
].join(" ")}
>
{hideWatched ? "Unwatched" : "All"}
</button>
</div>
</div>
{/* Mode switcher — own row, full width on mobile */}
<div className="flex items-center gap-0.5 bg-zinc-900 rounded-lg p-0.5 self-start">
{FEED_MODES.map(m => (
<button
key={m.value}
onClick={() => handleModeChange(m.value)}
title={m.hint}
className={[
"relative px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
mode === m.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300",
].join(" ")}
>
{m.label}
{m.value === "inbox" && inboxCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[13px] h-3 bg-zinc-200 text-zinc-900 text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{inboxCount > 99 ? "99+" : inboxCount}
</span>
)}
</button>
))}
</div>
{/* Duration filter */}
<div className="flex items-center gap-1.5">
{[["short", "< 10 min"], ["medium", "1030 min"], ["long", "30+ min"]].map(([val, label]) => (
<button
key={val}
onClick={() => handleDurationChange(val)}
className={[
"px-2.5 py-1 rounded-full text-xs font-medium transition-colors",
duration === val
? "bg-zinc-700 text-zinc-100"
: "text-zinc-600 hover:text-zinc-400",
].join(" ")}
>
{label}
</button>
))}
</div>
{mode === "inbox" && (
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
<button
onClick={() => markSeenMut.mutate()}
disabled={markSeenMut.isPending}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4 disabled:opacity-50"
>
Mark all read
</button>
</div>
)}
{mode === "chronological" && (
<p className="text-xs text-zinc-600">All videos from channels you follow, newest first.</p>
)}
{(mode === "ranked" || mode === "random") && (
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-600">
{mode === "ranked"
? "Ranked by your taste — reshuffles show a fresh mix."
: "Random from your discovery pool — no weighting, no ranking."}
</p>
<button
onClick={handleReshuffle}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Reshuffle
</button>
</div>
)}
{visibleFeed.length === 0 && !loadingFeed ? (
<p className="text-center text-zinc-500 text-sm py-12">
{mode === "inbox" ? "You're all caught up — no new videos since your last visit." : "Nothing to show here."}
</p>
) : (
<div className={viewMode === "list" ? "flex flex-col gap-2" : "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"}>
{visibleFeed.map((v) => (
<VideoCard
key={v.youtube_video_id}
video={v}
variant={viewMode}
onDismiss={v.is_recommended ? handleDismiss : undefined}
/>
))}
</div>
)}
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
disabled={page === 0}
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="text-zinc-500 text-xs tabular-nums">Page {page + 1}</span>
<button
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextPage}
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</section>
) : (
<section className="flex flex-col items-center py-16 gap-4 text-center">
<p className="text-zinc-400 text-sm max-w-sm">
Follow some channels to get a personalised feed. Or let us pick something.
</p>
<button
onClick={() => surpriseMut.mutate()}
disabled={surpriseMut.isPending}
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg"
>
<span className="flex items-center gap-2">
<span className="text-2xl"></span>
{surpriseMut.isPending ? "Picking something…" : "Surprise Me"}
</span>
</button>
{surpriseResults && (
<div className="mt-6 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{surpriseResults.map((v) => (
<VideoCard key={v.youtube_video_id || v.video_id} video={{
youtube_video_id: v.youtube_video_id,
title: v.title,
thumbnail_url: v.thumbnail_url,
duration_seconds: v.duration_seconds,
channel_name: v.channel_name,
is_downloaded: v.downloaded,
is_watched: v.watched,
}} />
))}
</div>
)}
</section>
)}
{/* Surprise Me — footer */}
{hasFollowing && (
<section className="flex flex-col items-center gap-3 py-4 border-t border-zinc-800/60">
<p className="text-zinc-600 text-xs">Want something random?</p>
<button
onClick={() => surpriseMut.mutate()}
disabled={surpriseMut.isPending}
className="flex items-center gap-2 px-5 py-2 rounded-full bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
<span></span>
{surpriseMut.isPending ? "Picking…" : "Surprise Me"}
</button>
{surpriseResults && (
<div className="mt-4 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{surpriseResults.map((v) => (
<VideoCard key={v.youtube_video_id || v.video_id} video={{
youtube_video_id: v.youtube_video_id,
title: v.title,
thumbnail_url: v.thumbnail_url,
duration_seconds: v.duration_seconds,
channel_name: v.channel_name,
is_downloaded: v.downloaded,
is_watched: v.watched,
}} />
))}
</div>
)}
</section>
)}
</div>
);
}