feat(plex): total playtime in the playlist header (E9 S3)

Parity with the YouTube playlist header (E9 12D): the Plex playlist detail
now carries a meta line under the title — "N items · Xh Ym", summed from the
items' duration_seconds. No backend change: leaf_card already serializes
duration_seconds for both movies and episodes, and no new i18n key either —
the count reuses plex.browse.items, the same key the collection header uses.

The title/rename input moved into a min-w-0 flex-1 column so the meta line
sits under both; the line is hidden on an empty playlist.
This commit is contained in:
2026-09-02 18:30:06 +02:00
parent 95c33e0b0f
commit 1b004e6ccf
+27 -12
View File
@@ -32,6 +32,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
}, [q.data]); }, [q.data]);
const blocks = useMemo(() => buildBlocks(items), [items]); const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]); const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
[items],
);
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
<div className="glass rounded-2xl p-4 sm:p-6"> <div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
{renaming ? ( <div className="min-w-0 flex-1">
<input {renaming ? (
autoFocus <input
value={name} autoFocus
onChange={(e) => setName(e.target.value)} value={name}
onKeyDown={(e) => e.key === "Enter" && rename()} onChange={(e) => setName(e.target.value)}
onBlur={rename} onKeyDown={(e) => e.key === "Enter" && rename()}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40" onBlur={rename}
/> className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
) : ( />
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1> ) : (
)} <h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
</div>
)}
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */} {/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher <ViewSwitcher
value={layout} value={layout}