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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user