import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ArrowDown, ArrowUp, Check, GripVertical, ListPlus, Pencil, Pin, Play, Plus, RefreshCw, Trash2, Upload, X, Youtube, } from "lucide-react"; import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; type SortKey = "manual" | "title" | "duration" | "channel"; type SortDir = "asc" | "desc"; function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null { if (key === "manual") return null; const sign = dir === "asc" ? 1 : -1; if (key === "duration") return (a, b) => { const av = a.duration_seconds; const bv = b.duration_seconds; if (av == null && bv == null) return 0; if (av == null) return 1; // nulls always last, both directions if (bv == null) return -1; return sign * (av - bv); }; return (a, b) => { const av = (key === "channel" ? a.channel_title : a.title) ?? ""; const bv = (key === "channel" ? b.channel_title : b.title) ?? ""; return sign * av.localeCompare(bv); }; } // Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered // by channel name in the chosen direction) and the chosen sort is applied within each group; // with key "manual" the within-group order is left as-is. Returns the same array reference // when nothing would change, so it doesn't create a spurious undo entry. function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] { const cmp = comparator(key, dir); if (!group) return cmp ? [...items].sort(cmp) : items; const groups = new Map(); for (const v of items) { const k = v.channel_title ?? ""; const g = groups.get(k); if (g) g.push(v); else groups.set(k, [v]); } const sign = dir === "asc" ? 1 : -1; const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b)); const out: Video[] = []; for (const k of keys) { const g = groups.get(k)!; out.push(...(cmp ? [...g].sort(cmp) : g)); } return out; } const idsKey = (items: Video[]) => items .map((v) => v.id) .sort() .join(","); type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; function Row({ video, index, readOnly, onPlay, onRemove, }: { video: Video; index: number; readOnly?: boolean; onPlay: () => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: video.id, disabled: readOnly }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, }; return (
{readOnly ? ( ) : ( )} {index + 1} {video.duration_seconds != null && ( {formatDuration(video.duration_seconds)} )} {!readOnly && ( )}
); } export default function Playlists({ canWrite }: { canWrite: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). const [selectedId, setSelectedId] = useState(() => { const s = localStorage.getItem("siftlode.playlist"); return s ? Number(s) : null; }); useEffect(() => { if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); }, [selectedId]); const selectedRef = useRef(null); const scrolledRef = useRef(false); // How the left playlist rail is ordered (persisted). const [plSort, setPlSort] = useState(() => { try { const s = localStorage.getItem("siftlode.plSort"); if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; } catch { /* ignore */ } return PL_SORT_DEFAULT; }); useEffect(() => { localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); // The item order is undoable: drag, sort and group all go through `order.set`, so each is // reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server. const order = useUndoable([], { onApply: (next) => { if (selectedId == null) return; api .reorderPlaylist(selectedId, next.map((v) => v.id)) .then(() => qc.invalidateQueries({ queryKey: ["playlists"] })); }, }); const items = order.value; const [sortKey, setSortKey] = useState("manual"); const [sortDir, setSortDir] = useState("asc"); const [groupBy, setGroupBy] = useState(false); // Tracks the membership (id set) currently loaded into `order`, so a refetch that only // re-confirms our own reorder doesn't wipe the undo history — we reset history only when // the actual set of items changes (different playlist, or an add/remove). const lastSetRef = useRef(""); // The open player, as an index into `items` (the queue); null = closed. const [playingIndex, setPlayingIndex] = useState(null); const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists(), refetchOnWindowFocus: true, }); const playlists = listQuery.data ?? []; // Keep the selection valid: default to the first playlist, and if the selected one // disappears (e.g. just deleted) drop to the next available, or clear when none remain. useEffect(() => { if (!playlists.length) { if (selectedId != null) setSelectedId(null); return; } if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { setSelectedId(playlists[0].id); } }, [playlists, selectedId]); const detailQuery = useQuery({ queryKey: ["playlist", selectedId], queryFn: () => api.playlist(selectedId as number), enabled: selectedId != null, // Refetch when returning to the tab so a change made elsewhere (another tab/device) // shows up — including the player's live N / M count, which reads the queue length. refetchOnWindowFocus: true, }); const detail = detailQuery.data; useEffect(() => { if (!detail) return; const key = idsKey(detail.items); if (key !== lastSetRef.current) { lastSetRef.current = key; order.reset(detail.items); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [detail]); // Reset the sort controls when switching playlists (the order itself is per-playlist). useEffect(() => { setSortKey("manual"); setSortDir("asc"); setGroupBy(false); }, [selectedId]); function applySort(key: SortKey, dir: SortDir, group: boolean) { order.set(sortItems(items, key, dir, group)); } const undo = () => { setSortKey("manual"); setGroupBy(false); order.undo(); }; const redo = () => { setSortKey("manual"); setGroupBy(false); order.redo(); }; const sensors = useSensors( 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; // The left rail in the chosen order. "custom" keeps the server position order (Array.sort // is stable); "dirty first" floats playlists with unpushed edits to the top. const sortedPlaylists = useMemo(() => { const sign = plSort.dir === "asc" ? 1 : -1; return [...playlists].sort((a, b) => { if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; switch (plSort.key) { case "name": return sign * plName(a).localeCompare(plName(b)); case "count": return sign * (a.item_count - b.item_count); case "duration": return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); default: return 0; // custom: server order } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlists, plSort]); // Scroll the restored selection into view once after the rail first renders. useEffect(() => { if (!scrolledRef.current && selectedRef.current) { selectedRef.current.scrollIntoView({ block: "nearest" }); scrolledRef.current = true; } }, [sortedPlaylists]); const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips // a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. const mirrored = detail?.source === "youtube"; const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); async function pushToYoutube() { if (selectedId == null || !detail || pushing) return; setPushing(true); try { const plan = await api.playlistPushPlan(selectedId); if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) { notify({ level: "info", message: t("playlists.pushUpToDate") }); return; } if (!plan.affordable) { notify({ level: "warning", message: t("playlists.pushNoQuota", { left: plan.remaining_today, units: plan.units_estimate, }), }); return; } let message = plan.action === "create" ? t("playlists.pushPlanCreate", { count: plan.to_insert, units: plan.units_estimate, left: plan.remaining_today, }) : t("playlists.pushPlanUpdate", { insert: plan.to_insert, del: plan.to_delete, reorder: plan.to_reorder, units: plan.units_estimate, left: plan.remaining_today, }); if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra }); const ok = await confirm({ title: t("playlists.pushTitle"), message, confirmLabel: t("playlists.pushConfirm"), }); if (!ok) return; const r = await api.pushPlaylist(selectedId); refreshAll(); if (r.failures.length) { notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); } else { notify({ level: "success", message: t("playlists.pushDone") }); } } catch { notify({ level: "warning", message: t("playlists.pushFailed") }); } finally { setPushing(false); } } async function syncYoutube() { if (syncing) return; setSyncing(true); try { const r = await api.syncYoutubePlaylists(); qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist"] }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); } catch { notify({ level: "warning", message: t("playlists.syncFailed") }); } finally { setSyncing(false); } } const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), onSuccess: (pl: Playlist) => { setNewName(""); qc.invalidateQueries({ queryKey: ["playlists"] }); setSelectedId(pl.id); }, }); function refreshAll() { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); } function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; if (!over || a.id === over.id || selectedId == null) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; // A manual drag breaks any active sort/grouping; reflect that in the controls. setSortKey("manual"); setGroupBy(false); order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists } async function removeItem(videoId: string) { if (selectedId == null) return; // Membership changes aren't undoable: reset the order to the new set (clearing history) // and keep lastSetRef in sync so the follow-up refetch doesn't reset again. const next = items.filter((v) => v.id !== videoId); order.reset(next); lastSetRef.current = idsKey(next); await api.removeFromPlaylist(selectedId, videoId); refreshAll(); } async function saveRename() { if (selectedId == null) return; const name = renameValue.trim(); setRenaming(false); if (name && name !== detail?.name) { await api.renamePlaylist(selectedId, name); refreshAll(); } } async function deletePlaylist() { if (selectedId == null || !detail) return; const ok = await confirm({ title: t("playlists.delete"), message: t("playlists.confirmDelete", { name: detail.name }), confirmLabel: t("playlists.delete"), danger: true, }); if (!ok) return; // For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only). let onYoutube = false; if (linked && canWrite) { onYoutube = await confirm({ title: t("playlists.deleteOnYoutubeTitle"), message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }), confirmLabel: t("playlists.deleteOnYoutubeConfirm"), cancelLabel: t("playlists.deleteHereOnly"), danger: true, }); } const deletedId = selectedId; // Pick the next selection from what's left *now* (don't go via null, or the // auto-select effect would re-pick the just-deleted id from the stale list). const remaining = playlists.filter((p) => p.id !== deletedId); setSelectedId(remaining[0]?.id ?? null); try { await api.deletePlaylist(deletedId, onYoutube); } catch { notify({ level: "warning", message: t("playlists.deleteYtFailed") }); } qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.invalidateQueries({ queryKey: ["playlists"] }); } function playerState(id: string, status: string) { api.setState(id, status).then(() => { qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: ["feed"] }); }); } return (
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
) : ( <>
{items[0]?.thumbnail_url && ( )}
{renaming ? ( setRenameValue(e.target.value)} onBlur={saveRename} onKeyDown={(e) => { if (e.key === "Enter") saveRename(); if (e.key === "Escape") setRenaming(false); }} className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" /> ) : (

{plName(detail)}

{editable && ( )}
)}
{t("playlists.itemCount", { count: items.length })}
{editable && canWrite && ( )} {editable && ( )} {linked && detail.dirty && ( {t("playlists.unsynced")} )} {mirrored && ( {t("playlists.ytReadonly")} )}
{editable && items.length > 1 && (
{t("playlists.sortLabel")}
)} {items.length === 0 ? (
{t("playlists.empty")}
) : ( v.id)} strategy={verticalListSortingStrategy} >
{items.map((v, i) => ( setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> ))}
)} )}
{playingIndex != null && items[playingIndex] && ( setPlayingIndex(null)} onState={playerState} /> )}
); }