import { useEffect, 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 { Check, GripVertical, ListPlus, Pencil, Play, Plus, RefreshCw, Trash2, 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 PlayerModal from "./PlayerModal"; import { useConfirm } from "./ConfirmProvider"; 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() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [items, setItems] = useState([]); // 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) setItems(detail.items); }, [detail]); 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; const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists are read-only here until the write-back phase. const mirrored = detail?.source === "youtube"; const editable = !builtin && !mirrored; const [syncing, setSyncing] = useState(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] }); } async function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; if (!over || a.id === over.id || selectedId == null || mirrored) 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; const next = arrayMove(items, oldIndex, newIndex); setItems(next); await api.reorderPlaylist(selectedId, next.map((v) => v.id)); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); } async function removeItem(videoId: string) { if (selectedId == null) return; setItems((cur) => cur.filter((v) => v.id !== videoId)); 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; 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); await api.deletePlaylist(deletedId); 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 && ( )} {mirrored && ( {t("playlists.ytReadonly")} )}
{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} /> )}
); }