Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7339a0b84b | ||
|
|
c960c07a6a | ||
|
|
3cfdb36f06 | ||
|
|
ada95d04a4 | ||
|
|
1b004e6ccf | ||
|
|
95c33e0b0f | ||
|
|
4e6bcbc078 | ||
|
|
e17fb5bbf5 |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user