From e17fb5bbf5f4f3d16caf2d5b7ef779b068dc9fbc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 1 Sep 2026 03:09:53 +0200 Subject: [PATCH 1/7] feat(playlists): total playtime in header + sticky-header fade fix (E9 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the playlist's total playtime beside the item count in the detail header (12D) — sums every item's duration (formatRuntime "h m", matching the approved mockup), hidden when nothing sums. Data is already client-side; no backend change. Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade no longer sits over the already-sticky column header (tableHeader.tsx:257), matching the channels/audit pages. --- frontend/src/components/Playlists.tsx | 9 +++++++++ frontend/src/components/moduleRegistry.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 977e2926..e3d4e1e1 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { playlistName } from "../lib/playlistName"; import { useUndoable } from "../lib/useUndoable"; import { cycleSort, type SortState } from "../lib/columnSort"; +import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; @@ -259,6 +260,13 @@ export default function Playlists() { return (v: Video) => m.get(v.id) ?? 0; }, [items]); + // Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not + // the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums. + const totalSeconds = useMemo( + () => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0), + [items], + ); + const columns = useMemo( () => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }), [t, storedIndex, onPlay, onRemove, canEdit], @@ -648,6 +656,7 @@ export default function Playlists() { )}
{t("playlists.itemCount", { count: items.length })} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index c67e1ce5..19eeb6e5 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -94,7 +94,7 @@ export type PageDef = { export const PAGE_CONTENT: Record = { feed: { Component: FeedPage }, channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" }, - playlists: { Component: Playlists, maxWidth: "64rem" }, + playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" }, plex: { Component: PlexBrowse, maxWidth: "1600px" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, messages: { Component: MessagesPage }, From 4e6bcbc078710e6649b5f62234960e00d88f4832 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 1 Sep 2026 03:16:20 +0200 Subject: [PATCH 2/7] fix(playlists): stop the table header shifting up on first scroll (E9 12B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playlist content wrapper carried `pt-3`, so its sticky column header sat 12px below the scroller top at rest and jumped up to `top:0` on the first scroll. Drop the top padding so the header is flush from rest and never shifts — the fixed toolbar band above supplies the breathing room, matching the channel table's already-correct pattern. --- frontend/src/components/Playlists.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index e3d4e1e1..27ed8765 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -599,7 +599,10 @@ export default function Playlists() { } return ( -
+ // No top padding: the sticky column header must sit flush at the scroller top so it doesn't + // shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above + // gives the breathing room). Same reason as the channel table — see Channels.tsx. +
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's fixed band, so only the video list scrolls. */} From 95c33e0b0fcbeb8d0b505cab70a753ae8bf67423 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 2 Sep 2026 03:45:07 +0200 Subject: [PATCH 3/7] refactor(playlists): compact the playlist header to two rows (E9 12A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header portals into the shell's FIXED toolbar band, so its height is list height the user never gets back — and it was spending four stacked blocks on it: title, meta line, an action row, then a separate view-control row under the cover. Option B collapses that into two tight rows beside a smaller cover: title + rename/YouTube-link on the first, with Play-all, push/revert/delete and the unsynced badge pushed right; the meta line (count · playtime · filtered) and the view controls (group-by, reset view, save order, view status, undo/redo) sharing the second. Every control and its gating is unchanged — the view-control group still appears only for `items.length > 1 || viewActive`, so a one-item playlist shows just the meta line. Measured on a 32-item playlist: the band goes 170px -> 88px at rest (122px while a view is active, where the status sentence wraps), which is ~80px more visible list. --- frontend/src/components/Playlists.tsx | 312 +++++++++++++------------- 1 file changed, 162 insertions(+), 150 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 27ed8765..de3e04e6 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -606,173 +606,185 @@ export default function Playlists() { {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's fixed band, so only the video list scrolls. */} -
-
-
+ {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the + view controls sharing the second. This band is FIXED chrome (it never scrolls), so every + row it sheds is list height won back — the old layout stacked three rows next to the + cover plus a fourth block under it, for roughly twice this height. */} +
+
+
{items[0]?.thumbnail_url && ( )}
- {renaming ? ( - setRenameValue(e.target.value)} - onBlur={saveRename} - onKeyDown={(e) => { - if (e.key === "Enter") saveRename(); - if (e.key === "Escape") setRenaming(false); - }} - aria-label={t("playlists.rename")} - className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" - /> - ) : ( -
-

{plName(detail)}

- {editable && ( - + )} + {detail.yt_playlist_id && ( + + + + )} + + )} +
+ {linked && detail.dirty && ( + - + + {t("playlists.unsynced")} + + )} + + {editable && canWrite && ( + )} - {detail.yt_playlist_id && ( - - - + + + )} + {editable && ( + )}
- )} -
- {t("playlists.itemCount", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} - {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
-
- - {editable && canWrite && ( - )} - - )} - {linked && detail.dirty && ( - - )} - {editable && ( - - )} - {linked && detail.dirty && ( - - - {t("playlists.unsynced")} - + {canSaveOrder && ( + + )} + {/* ONE sentence that names the state. Three loose texts used to pile up here + (reset link + save hint + "reordering is off"), and none of them answered the + question a user actually has — "did I just change my playlist?". Worse, + sorting a list whose stored order ALREADY matches the sort produced a + warning, no Save button and no visible change, which reads as a malfunction. + Now the line says which of the three situations you are in; the disabled grip + still explains itself on hover/focus. */} + {viewActive && {viewStatus}} + {canEdit && ( +
+ +
+ )} + )}
- - {(items.length > 1 || viewActive) && ( -
- - {viewActive && ( - - )} - {canSaveOrder && ( - - )} - {/* ONE sentence that names the state. Three loose texts used to pile up here (reset - link + save hint + "reordering is off"), and none of them answered the question a - user actually has — "did I just change my playlist?". Worse, sorting a list whose - stored order ALREADY matches the sort produced a warning, no Save button and no - visible change, which reads as a malfunction. Now the line says which of the three - situations you are in; the disabled grip still explains itself on hover/focus. */} - {viewActive && {viewStatus}} - {canEdit && ( -
- -
- )} -
- )}
From 1b004e6ccf1a9f91b5ef9b2d40e5b3a43c80af0b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 2 Sep 2026 18:30:06 +0200 Subject: [PATCH 4/7] feat(plex): total playtime in the playlist header (E9 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/PlexPlaylistView.tsx | 39 ++++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index f77e9606..0bb24816 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -32,6 +32,7 @@ import { X, } from "lucide-react"; import { api, type PlexCard } from "../lib/api"; +import { formatRuntime } from "../lib/format"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; import { LoadingState, StateMessage } from "./QueryState"; @@ -387,6 +388,12 @@ export default function PlexPlaylistView({ }, [q.data]); const blocks = useMemo(() => buildBlocks(items), [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 = () => { qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); @@ -517,18 +524,26 @@ export default function PlexPlaylistView({
- {renaming ? ( - setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && rename()} - onBlur={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" - /> - ) : ( -

{q.data?.title ?? " "}

- )} +
+ {renaming ? ( + setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && rename()} + 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" + /> + ) : ( +

{q.data?.title ?? " "}

+ )} + {items.length > 0 && ( +
+ {t("plex.browse.items", { count: items.length })} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} +
+ )} +
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */} Date: Thu, 3 Sep 2026 21:43:04 +0200 Subject: [PATCH 5/7] fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic-end deep review, round 1. Six findings survived verification; five of them were one defect wearing different hats. formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a Plex movie's length, but 12D repurposed it for an aggregate, and a total is the only input that can land under a minute: a playlist of Shorts reported "0m" while the rail beside it read "0:45". Fixed in the function, not behind a `>= 60` guard at each of the seven call sites — the invariant belongs where it cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths" and was two epics stale. The rail rendered the same playlist total as formatDuration (1:23:45) while the new header rendered formatRuntime (1h 23m) — two shapes of one number, side by side on one screen. The rail now uses formatRuntime too; formatTotalHours had already recorded that H:MM:SS is wrong for an aggregate. The rename input kept min-w-0 without flex-1 in the compacted row, so it was the only shrinkable child beside a shrink-0 action cluster. The Plex half of this same epic already had the fix; this restores the symmetry. plex.browse.items had no plural forms, so a one-item Plex playlist read "1 items". Pre-existing, but the new header is the call site where count=1 is ordinary. Both languages get both forms, no key drift. formatRuntime had no unit tests at all across seven call sites. Added a tier table plus a sweep asserting no non-zero second ever renders as "0m". Dropped after 3/3 adversarial refutations: the meta-line segment ordering (the filtered count is labelled, and count-then-runtime is the convention) and the null-duration lower bound (the server's own SUM has skipped NULLs since long before this branch; changing only the client would split them). --- frontend/src/components/Playlists.tsx | 2 +- frontend/src/components/PlaylistsRail.tsx | 4 +-- frontend/src/i18n/locales/en/plex.json | 3 +- frontend/src/i18n/locales/hu/plex.json | 3 +- frontend/src/lib/format.test.ts | 34 ++++++++++++++++++++++- frontend/src/lib/format.ts | 10 +++++-- 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index de3e04e6..da772928 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -630,7 +630,7 @@ export default function Playlists() { if (e.key === "Escape") setRenaming(false); }} aria-label={t("playlists.rename")} - className="min-w-0 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" + className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> ) : ( <> diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 2c0d7ccb..3944b966 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; -import { formatDuration } from "../lib/format"; +import { formatRuntime } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS, readAccountMerged, writeAccount } from "../lib/storage"; @@ -247,7 +247,7 @@ export default function PlaylistsRail({
{t("playlists.itemCount", { count: pl.item_count })} {pl.total_duration_seconds > 0 && - ` · ${formatDuration(pl.total_duration_seconds)}`} + ` · ${formatRuntime(pl.total_duration_seconds)}`}
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 2c2867eb..1458913a 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -350,7 +350,8 @@ "browse": "Browse" }, "browse": { - "items": "{{count}} items" + "items_one": "{{count}} item", + "items_other": "{{count}} items" }, "discover": { "tab": "Discover", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 5d85c1c0..84490c4b 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -350,7 +350,8 @@ "browse": "Böngészés" }, "browse": { - "items": "{{count}} elem" + "items_one": "{{count}} elem", + "items_other": "{{count}} elem" }, "discover": { "tab": "Felfedezés", diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 5d09d96d..7b738568 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format"; +import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format"; import en from "../i18n/locales/en/time.json"; import hu from "../i18n/locales/hu/time.json"; @@ -108,3 +108,35 @@ describe("relativeFromMs", () => { expect(hu).toHaveProperty("justNow"); }); }); + +// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a +// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no +// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail +// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be +// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches. +describe("formatRuntime", () => { + const CASES: [number | null | undefined, string][] = [ + [null, ""], + [undefined, ""], + [0, ""], // nothing to show — the call sites suppress their separator on the empty string + [1, "1s"], + [45, "45s"], // one Short: the regression case + [59, "59s"], // last second before the minute tier + [60, "1m"], + [90, "1m"], // floors, never rounds up — a total must not overstate + [3599, "59m"], + [3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation + [5025, "1h 23m"], + [360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days + ]; + + it.each(CASES)("formats %o as %o", (sec, expected) => { + expect(formatRuntime(sec)).toBe(expected); + }); + + it("never reports a non-zero duration as zero", () => { + for (let sec = 1; sec < 3600; sec++) { + expect(formatRuntime(sec), `${sec}s`).not.toBe("0m"); + } + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9f556899..2e2a0958 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -96,10 +96,16 @@ export function formatDuration(sec: number | null): string { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -/** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths. - * Empty string for a missing/zero duration (nothing to show). */ +/** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's + * length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the + * Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two + * shapes on one screen. Empty string for a missing/zero duration (nothing to show). + * The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land + * between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a + * non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */ export function formatRuntime(sec?: number | null): string { if (!sec) return ""; + if (sec < 60) return `${Math.floor(sec)}s`; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; From 3cfdb36f0694ffb3670be47355c196e3b0c91892 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 3 Sep 2026 21:51:14 +0200 Subject: [PATCH 6/7] test(format): pin the sub-minute truncation (E9 R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 mutation-checked round 1's own fix and found the one line it left uncovered: every table row was an integer, so `Math.floor(sec)` in the new seconds tier could be deleted or swapped for round/ceil with the suite still green — exactly the "later cleanup removes it as redundant" failure the fix's comment claims to prevent. Two fractional rows kill all three mutants. --- frontend/src/lib/format.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 7b738568..e9d8dcb4 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -121,9 +121,11 @@ describe("formatRuntime", () => { [0, ""], // nothing to show — the call sites suppress their separator on the empty string [1, "1s"], [45, "45s"], // one Short: the regression case + [45.7, "45s"], // truncates, so the seconds tier cannot overstate either [59, "59s"], // last second before the minute tier [60, "1m"], [90, "1m"], // floors, never rounds up — a total must not overstate + [90.9, "1m"], [3599, "59m"], [3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation [5025, "1h 23m"], From c960c07a6a8b1dc702681d9a26ee072f6bfb7c87 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 3 Sep 2026 21:59:09 +0200 Subject: [PATCH 7/7] release: 0.79.0 notes + version bump E9 Playlist module: total playtime in both playlist headers, the header compacted to two rows, and the sticky column header sitting flush. Plus the six defects the epic-end deep review confirmed. --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 2fd796a8..17fd0aed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.78.8 \ No newline at end of file +0.79.0 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 12170016..c68213a5 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,26 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.79.0", + date: "2026-09-03", + summary: + "A playlist now tells you how long it is, and its header takes half the room it used to — so you see more of the list and less of the frame around it.", + features: [ + 'Every playlist header shows the total playtime beside the video count: "32 videos · 2h 17m". It is the whole playlist, not whatever a filter is currently showing, so it answers the question you actually have — is there time for this tonight?', + "The header was three stacked rows next to a large cover; it is two tight ones next to a smaller cover. Nothing was hidden behind a menu to get there — the title, rename, the YouTube link, Play all, the sync and delete buttons, group-by-channel, the view controls and undo are all still on screen. That band never scrolls, so the height it gave back is list you can now see: about eighty pixels on a normal window.", + "Plex playlists show their total playtime in the same place, in the same words.", + ], + fixes: [ + "The column header no longer jolts upward the instant you start scrolling a playlist. It used to sit twelve pixels below the top of the list and snap flush on the first scroll, which read as the page flinching.", + 'A playlist’s length is written one way everywhere now. The list on the left said "2:15:30" while the header beside it said "2h 15m" for the very same playlist — one number in two shapes, side by side.', + 'A playlist shorter than a minute reported "0m". A single Short is 45 seconds long, and the header said it had no runtime at all; it now says "45s".', + 'A Plex playlist holding exactly one thing said "1 items" in English.', + ], + chores: [ + '12D reads the durations already in hand rather than adding an endpoint: both headers reduce over their own items, and formatRuntime grew a sub-minute tier because an aggregate is the first input that can legitimately land between 1 and 59 seconds — the fix lives in the function, not behind a `>= 60` guard at each of its seven call sites. PlaylistsRail moved off formatDuration onto the same helper so a playlist total has one renderer. 12A keeps every control and its gating (`items.length > 1 || viewActive`, editable/canWrite/canEdit, linked && dirty) byte-for-byte; 12B drops the wrapper’s pt-3 and passes fadeTop:false for the playlists page, matching the channel table. formatRuntime had no unit tests across seven call sites and now has a tier table plus a sweep asserting no non-zero second ever renders as "0m". Epic-end deep review over five dimensions: eighteen raw findings, ten distinct, two dropped after 3-of-3 adversarial refutations (meta-line segment order; the null-duration lower bound, which matches the server’s own long-shipped SUM), six fixed, two logged as deferrals. Rounds converged 6 → 1 → 0.', + ], + }, { version: "0.78.8", date: "2026-09-02",