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/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 977e2926..da772928 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],
@@ -591,176 +599,192 @@ 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. */}
-
-
-
+ {/* 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. */}
+
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
= {
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 },
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..e9d8dcb4 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,37 @@ 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
+ [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"],
+ [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`;
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",