Compare commits

...
8 Commits
Author SHA1 Message Date
peter 7339a0b84b release: 0.79.0 (improvement/e9-playlist)
gate / backend (push) Successful in 2m48s
gate / frontend (push) Failing after 1m43s
2026-09-03 21:59:31 +02:00
peter c960c07a6a 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.
2026-09-03 21:59:09 +02:00
peter 3cfdb36f06 test(format): pin the sub-minute truncation (E9 R2)
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.
2026-09-03 21:51:14 +02:00
peter ada95d04a4 fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
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).
2026-09-03 21:43:04 +02:00
peter 1b004e6ccf 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.
2026-09-02 18:30:06 +02:00
peter 95c33e0b0f refactor(playlists): compact the playlist header to two rows (E9 12A)
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.
2026-09-02 03:45:07 +02:00
peter 4e6bcbc078 fix(playlists): stop the table header shifting up on first scroll (E9 12B)
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.
2026-09-02 03:39:17 +02:00
peter e17fb5bbf5 feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
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.
2026-09-02 03:39:17 +02:00
10 changed files with 272 additions and 171 deletions
+1 -1
View File
@@ -1 +1 @@
0.78.8 0.79.0
+174 -150
View File
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName"; import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort"; import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView"; import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@@ -259,6 +260,13 @@ export default function Playlists() {
return (v: Video) => m.get(v.id) ?? 0; return (v: Video) => m.get(v.id) ?? 0;
}, [items]); }, [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( const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }), () => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit], [t, storedIndex, onPlay, onRemove, canEdit],
@@ -591,176 +599,192 @@ export default function Playlists() {
} }
return ( return (
<div className="px-4 pb-4 pt-3"> // 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.
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */} fixed band, so only the video list scrolls. */}
<PageToolbar> <PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto"> {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
<div className="flex gap-3 items-start mb-4"> view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0"> 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. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && ( {items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{renaming ? ( <div className="flex items-center gap-2 min-w-0">
<input {renaming ? (
autoFocus <input
value={renameValue} autoFocus
onChange={(e) => setRenameValue(e.target.value)} value={renameValue}
onBlur={saveRename} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { onBlur={saveRename}
if (e.key === "Enter") saveRename(); onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(false); 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" aria-label={t("playlists.rename")}
/> 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"
) : ( />
<div className="flex items-center gap-2 min-w-0"> ) : (
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <>
{editable && ( <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
<button {editable && (
onClick={() => { <button
setRenameValue(detail.name); onClick={() => {
setRenaming(true); setRenameValue(detail.name);
}} setRenaming(true);
title={t("playlists.rename")} }}
aria-label={t("playlists.rename")} title={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg" aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
> >
<Pencil className="w-3.5 h-3.5" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button> </button>
)} )}
{detail.yt_playlist_id && ( {linked && detail.dirty && (
<a <button
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`} onClick={revertToYoutube}
target="_blank" disabled={reverting}
rel="noreferrer noopener" title={t("playlists.revert")}
title={t("playlists.openOnYoutube")} aria-label={t("playlists.revert")}
aria-label={t("playlists.openOnYoutube")} className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
className="shrink-0 text-muted hover:text-accent"
> >
<ExternalLink className="w-3.5 h-3.5" /> <RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</a> </button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)} )}
</div> </div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div> </div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button <div className="flex items-center gap-2 mt-1 flex-wrap">
onClick={() => shown.length && setPlayingId(shown[0]!.id)} <span className="text-xs text-muted">
disabled={!shown.length} {t("playlists.itemCount", { count: items.length })}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition" {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
> {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")} </span>
</button> {(items.length > 1 || viewActive) && (
{editable && canWrite && ( <>
<button <span aria-hidden="true" className="text-xs text-muted/40">
onClick={pushToYoutube} ·
disabled={pushing} </span>
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")} <label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
aria-label={ <input
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube") type="checkbox"
} checked={groupBy}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${ onChange={(e) => setGroupBy(e.target.checked)}
detail.dirty className="accent-accent"
? "border-accent text-accent hover:bg-accent/10" />
: "border-border text-muted hover:text-fg hover:border-accent" {t("playlists.groupByChannel")}
}`} </label>
> {viewActive && (
{pushing ? ( <button
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> onClick={clearView}
) : linked ? ( className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
<Youtube className="w-3.5 h-3.5" /> >
) : ( {t("playlists.clearView")}
<Upload className="w-3.5 h-3.5" /> </button>
)} )}
</button> {canSaveOrder && (
)} <button
{linked && detail.dirty && ( onClick={saveOrder}
<button className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
onClick={revertToYoutube} >
disabled={reverting} <Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
title={t("playlists.revert")} </button>
aria-label={t("playlists.revert")} )}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40" {/* 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
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} /> question a user actually has — "did I just change my playlist?". Worse,
</button> 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.
{editable && ( Now the line says which of the three situations you are in; the disabled grip
<button still explains itself on hover/focus. */}
onClick={deletePlaylist} {viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
title={t("playlists.delete")} {canEdit && (
aria-label={t("playlists.delete")} <div className="ml-auto">
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition" <UndoToolbar
> canUndo={order.canUndo}
<Trash2 className="w-3.5 h-3.5" /> canRedo={order.canRedo}
</button> onUndo={order.undo}
)} onRedo={order.redo}
{linked && detail.dirty && ( />
<span </div>
title={t("playlists.unsyncedHint")} )}
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1" </>
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)} )}
</div> </div>
</div> </div>
</div> </div>
{(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* 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 && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</div>
)}
</div> </div>
</PageToolbar> </PageToolbar>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage"; import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })} {t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 && {pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`} ` · ${formatRuntime(pl.total_duration_seconds)}`}
</div> </div>
</div> </div>
</button> </button>
+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}
+1 -1
View File
@@ -94,7 +94,7 @@ export type PageDef = {
export const PAGE_CONTENT: Record<Page, PageDef> = { export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage }, feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" }, channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" }, playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" }, plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage }, messages: { Component: MessagesPage },
+2 -1
View File
@@ -350,7 +350,8 @@
"browse": "Browse" "browse": "Browse"
}, },
"browse": { "browse": {
"items": "{{count}} items" "items_one": "{{count}} item",
"items_other": "{{count}} items"
}, },
"discover": { "discover": {
"tab": "Discover", "tab": "Discover",
+2 -1
View File
@@ -350,7 +350,8 @@
"browse": "Böngészés" "browse": "Böngészés"
}, },
"browse": { "browse": {
"items": "{{count}} elem" "items_one": "{{count}} elem",
"items_other": "{{count}} elem"
}, },
"discover": { "discover": {
"tab": "Felfedezés", "tab": "Felfedezés",
+35 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json"; import hu from "../i18n/locales/hu/time.json";
@@ -108,3 +108,37 @@ describe("relativeFromMs", () => {
expect(hu).toHaveProperty("justNow"); 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");
}
});
});
+8 -2
View File
@@ -96,10 +96,16 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; 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. /** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
* Empty string for a missing/zero duration (nothing to show). */ * 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 { export function formatRuntime(sec?: number | null): string {
if (!sec) return ""; if (!sec) return "";
if (sec < 60) return `${Math.floor(sec)}s`;
const h = Math.floor(sec / 3600); const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60); const m = Math.floor((sec % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`; return h ? `${h}h ${m}m` : `${m}m`;
+20
View File
@@ -14,6 +14,26 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: 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", version: "0.78.8",
date: "2026-09-02", date: "2026-09-02",