feat(playlists): unify Saved into a built-in Watch later playlist

The old per-video status='saved' becomes membership in a built-in, undeletable
'watch_later' playlist. Migration 0012 moves existing saved videos into each user's
Watch later list and demotes the states to 'new'. The feed/playlist serializers now
expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later
via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of
setting a status. Removed the 'saved' show-filter and status. Watch later shows a
localized name and hides rename/delete in the Playlists page and add-to-playlist popover.
This commit is contained in:
npeter83 2026-06-15 15:33:53 +02:00
parent 47bad6d9ce
commit dea740b728
12 changed files with 260 additions and 36 deletions

View file

@ -165,7 +165,9 @@ export default function AddToPlaylist({
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate">{pl.name}</span>
<span className="truncate">
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
</span>
</button>
))}
</div>

View file

@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean {
return status === "hidden";
case "watched":
return status === "watched";
case "saved":
return status === "saved";
case "unwatched":
case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only
@ -42,6 +40,7 @@ export default function Feed({
}) {
const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// 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
@ -64,8 +63,14 @@ export default function Feed({
// 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({}), [filters]);
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
useEffect(() => {
setOverrides({});
setSavedOverrides({});
}, [filters]);
useEffect(() => {
setOverrides({});
setSavedOverrides({});
}, [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
@ -139,10 +144,24 @@ export default function Feed({
[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));
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
@ -192,6 +211,7 @@ export default function Feed({
video={v}
view="grid"
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onOpen={openVideo}
/>
@ -205,6 +225,7 @@ export default function Feed({
video={v}
view="list"
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onOpen={openVideo}
/>

View file

@ -137,6 +137,11 @@ export default function Playlists() {
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
// Watch later is a built-in playlist: show a localized name and no rename/delete.
const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
const builtin = detail?.kind === "watch_later";
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
onSuccess: (pl: Playlist) => {
@ -230,7 +235,7 @@ export default function Playlists() {
)}
</div>
<div className="min-w-0">
<div className="text-[13px] text-fg truncate">{pl.name}</div>
<div className="text-[13px] text-fg truncate">{plName(pl)}</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
</div>
@ -287,17 +292,19 @@ export default function Playlists() {
/>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold truncate">{detail.name}</h2>
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
className="text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{!builtin && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
className="text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
@ -311,12 +318,14 @@ export default function Playlists() {
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
<button
onClick={deletePlaylist}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
>
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button>
{!builtin && (
<button
onClick={deletePlaylist}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
>
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button>
)}
</div>
</div>
</div>

View file

@ -50,7 +50,7 @@ const SORT_IDS = [
"shuffle",
];
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
// Fresh shuffle token for the "surprise me" sort.
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);

View file

@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onToggleSave,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const { t } = useTranslation();
@ -31,6 +33,11 @@ function Actions({
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
@ -48,11 +55,11 @@ function Actions({
)}
</button>
<button
onClick={act("saved")}
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "fill-current text-accent"
video.saved && "fill-current text-accent"
)}
>
<Bookmark className="w-4 h-4" />
@ -157,7 +164,7 @@ function Thumb({
{t("card.stream")}
</span>
)}
{video.status === "saved" && (
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
@ -209,12 +216,14 @@ function VideoCard({
video,
view,
onState,
onToggleSave,
onChannelFilter,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
@ -264,7 +273,7 @@ function VideoCard({
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
);
}
@ -295,7 +304,7 @@ function VideoCard({
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
</div>
</div>