storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
413 lines
18 KiB
TypeScript
413 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
|
|
import { YouTubeIcon } from "../lib/brandIcons";
|
|
import { api, type Playlist } from "../lib/api";
|
|
import { compareText } from "../lib/collate";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notify } from "../lib/notifications";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import { LS } from "../lib/storage";
|
|
import { usePref } from "../lib/usePref";
|
|
import { type PanelLayout } from "../lib/panelLayout";
|
|
import FilterRail from "./FilterRail";
|
|
import { Button } from "./ui/Button";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { useComfortPin, pinPlaylist } from "./comfortPin";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { type PanelItem } from "./ui/PanelGroups";
|
|
import SortControl from "./ui/SortControl";
|
|
import { type SortOption } from "../lib/columnSort";
|
|
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
|
import { useMe } from "../lib/useMe";
|
|
import { playlistName } from "../lib/playlistName";
|
|
import { usePlaylistActions } from "./usePlaylistActions";
|
|
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { Input } from "./ui/Input";
|
|
import { MoreButton } from "./ui/MoreButton";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
|
|
type PlSort = {
|
|
key: "custom" | "name" | "count" | "duration" | "recent";
|
|
dir: "asc" | "desc";
|
|
dirtyFirst: boolean;
|
|
};
|
|
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
|
|
|
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
|
|
// becomes) — the row must not resize the moment you rename it.
|
|
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
|
|
|
|
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
|
|
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
|
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
|
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
|
export default function PlaylistsRail({
|
|
layout,
|
|
setLayout,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
}: {
|
|
layout: PanelLayout;
|
|
setLayout: (l: PanelLayout) => void;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
}) {
|
|
const canWrite = useMe().can_write;
|
|
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
|
|
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
|
const { search, selectedId } = usePlaylists();
|
|
const { setSelectedId } = usePlaylistsActions();
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const [plSort, setPlSort] = usePref<PlSort>("plSort", PL_SORT_DEFAULT, {
|
|
localKey: LS.plSort,
|
|
parse: (raw) => ({ ...PL_SORT_DEFAULT, ...(raw && typeof raw === "object" ? raw : {}) }),
|
|
});
|
|
const plSortOptions: SortOption[] = [
|
|
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
|
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
|
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
|
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
|
// "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
|
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
|
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
|
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
|
];
|
|
const [newName, setNewName] = useState("");
|
|
const [syncing, setSyncing] = useState(false);
|
|
const plActions = usePlaylistActions();
|
|
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
|
const railMenu = useContextMenu<Playlist>();
|
|
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
|
|
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
|
|
const comfortPin = useComfortPin(railMenu.open);
|
|
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
|
// context menu is that the action lands where the gesture did.
|
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
|
const scrolledRef = useRef(false);
|
|
|
|
const listQuery = useQuery({
|
|
queryKey: qk.playlists(),
|
|
queryFn: () => api.playlists(),
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
// Memoised so the effect and the sort below depend on a value that only changes with the query —
|
|
// `?? []` would otherwise mint a new empty array on every render while the list is loading.
|
|
const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]);
|
|
|
|
// Keep the selection valid: default to the first playlist, and if the selected one disappears
|
|
// drop to the next available (or clear when none remain). Waits for the first load.
|
|
useEffect(() => {
|
|
if (!listQuery.data) return;
|
|
if (!playlists.length) {
|
|
if (selectedId != null) setSelectedId(null);
|
|
return;
|
|
}
|
|
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
|
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
|
|
}
|
|
}, [playlists, selectedId, listQuery.data, setSelectedId]);
|
|
|
|
// useCallback because the two memos below sort by it: as a fresh closure each render it could not
|
|
// be named in their dep lists, and `t` — which is what actually changes it, on a language switch —
|
|
// was therefore invisible to them. A rename in the other language now re-sorts.
|
|
const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]);
|
|
|
|
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 * compareText(plName(a), plName(b), lang);
|
|
case "count":
|
|
return sign * (a.item_count - b.item_count);
|
|
case "duration":
|
|
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
|
case "recent":
|
|
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}, [playlists, plSort, plName, lang]);
|
|
|
|
const q = search.trim().toLowerCase();
|
|
const visiblePlaylists = useMemo(
|
|
() =>
|
|
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
|
|
[sortedPlaylists, q, plName],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!scrolledRef.current && selectedRef.current) {
|
|
selectedRef.current.scrollIntoView({ block: "nearest" });
|
|
scrolledRef.current = true;
|
|
}
|
|
}, [sortedPlaylists]);
|
|
|
|
async function syncYoutube() {
|
|
if (syncing) return;
|
|
setSyncing(true);
|
|
try {
|
|
const r = await api.syncYoutubePlaylists();
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
|
|
} catch (e) {
|
|
notifyYouTubeActionError(e, t("playlists.syncFailed"));
|
|
}
|
|
setSyncing(false);
|
|
}
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (name: string) => api.createPlaylist(name),
|
|
meta: { failTitle: "errors.writeFailed.createPlaylist" },
|
|
onSuccess: (pl: Playlist) => {
|
|
setNewName("");
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
setSelectedId(pl.id);
|
|
},
|
|
});
|
|
|
|
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
|
|
// this a keyboard user was dropped on <body> and lost their place in the rail.
|
|
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome, a
|
|
// focus issued after the commit sticks and nothing takes it back — the first attempt that
|
|
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
|
|
// blur-commit means the user put the focus somewhere else on purpose.
|
|
const refocusRowRef = useRef<number | null>(null);
|
|
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
|
|
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
|
|
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
|
|
// return, so the field simply mounts and is focused here; measured in Chrome, the field is
|
|
// focused and survives.
|
|
useEffect(() => {
|
|
if (renamingId !== null) renameInputRef.current?.focus();
|
|
}, [renamingId]);
|
|
|
|
async function commitRename(pl: Playlist) {
|
|
const name = renameValue.trim();
|
|
setRenamingId(null);
|
|
if (name && name !== pl.name) await plActions.rename(pl.id, name);
|
|
}
|
|
|
|
const ctxPl = railMenu.target;
|
|
// The list's ONE menu (usePlaylistActions.items), the same the page header's "…" opens; the rail
|
|
// renames IN the row that was right-clicked — the action lands where the gesture did.
|
|
const railItems: ActionMenuItem[] = !ctxPl
|
|
? []
|
|
: plActions.items(ctxPl, {
|
|
onRename: () => {
|
|
setRenameValue(ctxPl.name);
|
|
setRenamingId(ctxPl.id);
|
|
},
|
|
pin: comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
|
|
});
|
|
|
|
const items: PanelItem[] = [
|
|
{
|
|
id: "manage",
|
|
title: t("playlists.manageGroup"),
|
|
body: (
|
|
<div className="flex flex-col gap-2">
|
|
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
|
{t("playlists.syncYoutube")}
|
|
</Button>
|
|
{playlists.length > 1 && (
|
|
<SortControl
|
|
label={t("playlists.sortLabel")}
|
|
ascLabel={t("playlists.dirAsc")}
|
|
descLabel={t("playlists.dirDesc")}
|
|
options={plSortOptions}
|
|
sort={{ key: plSort.key, dir: plSort.dir }}
|
|
onChange={(next) =>
|
|
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
|
}
|
|
trailing={
|
|
<IconButton
|
|
variant="outline"
|
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
|
label={t("playlists.dirtyFirst")}
|
|
pressed={plSort.dirtyFirst}
|
|
>
|
|
<Pin />
|
|
</IconButton>
|
|
}
|
|
/>
|
|
)}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newName.trim()) createMut.mutate(newName.trim());
|
|
}}
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-4 h-4 text-muted shrink-0" />
|
|
<Input
|
|
className="flex-1"
|
|
size="sm"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder={t("playlists.newPlaylist")}
|
|
disabled={!canWrite}
|
|
/>
|
|
</form>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "list",
|
|
title: t("playlists.listGroup"),
|
|
body: (
|
|
<>
|
|
{playlists.length === 0 &&
|
|
(listQuery.isError && !listQuery.data ? (
|
|
<Pressable
|
|
onClick={() => listQuery.refetch()}
|
|
className="text-xs text-danger px-1 py-1 hover:underline"
|
|
>
|
|
{t("common.loadError")} — {t("common.retry")}
|
|
</Pressable>
|
|
) : listQuery.isLoading ? (
|
|
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
|
|
) : (
|
|
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
|
|
))}
|
|
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
|
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
{visiblePlaylists.map((pl) =>
|
|
renamingId === pl.id ? (
|
|
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
|
// whole row swaps rather than the label alone — but it KEEPS the cover, because
|
|
// that is what holds the row at its two-line height. Without it the rail jumps by
|
|
// ~12px the moment you start renaming, and every row below it moves under the
|
|
// pointer.
|
|
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
|
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
|
{pl.cover_thumbnail && (
|
|
<RemoteImage
|
|
src={pl.cover_thumbnail}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
<Input
|
|
className="flex-1"
|
|
size="sm"
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={() => void commitRename(pl)}
|
|
onKeyDown={(e) => {
|
|
// preventDefault: the focus lands on the row before this keystroke is done,
|
|
// and a real Enter's own activation would then press it (see useReturnFocus).
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
refocusRowRef.current = pl.id;
|
|
void commitRename(pl);
|
|
}
|
|
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
refocusRowRef.current = pl.id;
|
|
setRenamingId(null);
|
|
}
|
|
}}
|
|
aria-label={t("playlists.rename")}
|
|
/>
|
|
</div>
|
|
) : (
|
|
// `group`: the row's "…" shows on its hover or focus (`.reveal`), and always on touch.
|
|
// A sibling of the row button, not inside it — a button cannot hold a button.
|
|
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
|
|
<Pressable
|
|
selectable
|
|
ref={(el) => {
|
|
if (pl.id === selectedId) selectedRef.current = el;
|
|
// Back from the rename field by Enter/Escape — see refocusRowRef.
|
|
if (el && refocusRowRef.current === pl.id) {
|
|
refocusRowRef.current = null;
|
|
el.focus();
|
|
}
|
|
}}
|
|
onClick={() => setSelectedId(pl.id)}
|
|
layer={pl.id !== selectedId}
|
|
// pr-9: the "…" sits over the row's right end, so the name truncates short of it.
|
|
className={`${ROW_BASE} pr-9 ${
|
|
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
|
|
}`}
|
|
>
|
|
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
|
{pl.cover_thumbnail && (
|
|
<RemoteImage
|
|
src={pl.cover_thumbnail}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
|
<span className="truncate">{plName(pl)}</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<YouTubeIcon
|
|
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="text-2xs text-muted">
|
|
{t("playlists.itemCount", { count: pl.item_count })}
|
|
{pl.total_duration_seconds > 0 &&
|
|
` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
|
|
</div>
|
|
</div>
|
|
</Pressable>
|
|
<MoreButton
|
|
label={t("playlists.railMenu")}
|
|
expanded={railMenu.open && railMenu.target === pl}
|
|
menuProps={railMenu.buttonProps(pl)}
|
|
className={`absolute right-1.5 top-1/2 -translate-y-1/2 ${
|
|
railMenu.open && railMenu.target === pl ? "" : "reveal"
|
|
}`}
|
|
/>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
|
<PlaylistLimitsHint />
|
|
</>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<FilterRail
|
|
kind="playlists"
|
|
title={t("playlists.title")}
|
|
icon={<ListVideo className="w-4 h-4" />}
|
|
collapsed={collapsed}
|
|
onToggleCollapse={onToggleCollapse}
|
|
layout={layout}
|
|
setLayout={setLayout}
|
|
items={items}
|
|
/>
|
|
);
|
|
}
|