chore(plex): dedup frontend formatters + LS key, drop dead wasPlaying pref
- format.ts: new formatRuntime() ("1h 30m"); PlexBrowse dur() + PlexInfo
fmtDur() were byte-identical copies, now both call it.
- PlexPlayer fmt() delegates to formatDuration (keeps the NaN/negative clamp
the live media clock needs); local reimplementation gone.
- Register LS.plexPlayerPrefs; PlexPlayer uses it instead of a bare string.
- Remove the dead PlexPlayerPrefs.wasPlaying field (written on play/pause, never
read) + its two patchPrefs writes.
- PlexBrowse item page: drop the redundant onStateChange={() => q.refetch()} —
PlexInfo.setState already invalidates ["plex-item", id], so q refetches itself.
This commit is contained in:
parent
f17bad9870
commit
97088d5393
5 changed files with 26 additions and 37 deletions
|
|
@ -26,6 +26,7 @@ import {
|
||||||
type PlexSeasonDetail,
|
type PlexSeasonDetail,
|
||||||
type PlexUnifiedResult,
|
type PlexUnifiedResult,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||||
|
|
@ -67,13 +68,6 @@ type Props = {
|
||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
|
|
||||||
function dur(n?: number | null): string {
|
|
||||||
if (!n) return "";
|
|
||||||
const h = Math.floor(n / 3600);
|
|
||||||
const m = Math.floor((n % 3600) / 60);
|
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlexBrowse({
|
export default function PlexBrowse({
|
||||||
q,
|
q,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
|
|
@ -483,7 +477,7 @@ function PlexPosterCard({
|
||||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
|
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
|
||||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
<div className="text-xs text-muted">{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -526,7 +520,8 @@ function PlexInfoView({
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
onStateChange={() => q.refetch()}
|
// No onStateChange: PlexInfo.setState already invalidates ["plex-item", id] (this very
|
||||||
|
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
@ -845,7 +840,7 @@ function EpisodeCard({
|
||||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||||
{ep.title}
|
{ep.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
<div className="text-[11px] text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||||
</div>
|
</div>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
|
|
@ -28,13 +29,6 @@ type Props = {
|
||||||
library?: string;
|
library?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtDur(s?: number | null): string {
|
|
||||||
if (!s) return "";
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlexInfo({
|
export default function PlexInfo({
|
||||||
detail,
|
detail,
|
||||||
variant,
|
variant,
|
||||||
|
|
@ -170,7 +164,7 @@ export default function PlexInfo({
|
||||||
{detail.content_rating}
|
{detail.content_rating}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{detail.duration_seconds ? <span className={mutedCls}>{fmtDur(detail.duration_seconds)}</span> : null}
|
{detail.duration_seconds ? <span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span> : null}
|
||||||
{detail.studio && (
|
{detail.studio && (
|
||||||
<Filterable
|
<Filterable
|
||||||
className={mutedCls}
|
className={mutedCls}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexMarker } from "../lib/api";
|
import { api, type PlexMarker } from "../lib/api";
|
||||||
import { useAccountPersistedObject } from "../lib/storage";
|
import { formatDuration } from "../lib/format";
|
||||||
|
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
|
|
@ -63,7 +64,6 @@ function subOrdForLang(subs: SubStream[], lang: string): number | null {
|
||||||
type PlexPlayerPrefs = {
|
type PlexPlayerPrefs = {
|
||||||
volume: number; // 0..1
|
volume: number; // 0..1
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
|
|
||||||
audioLang: string; // "" = default first audio
|
audioLang: string; // "" = default first audio
|
||||||
subLang: string; // "" = subtitles off
|
subLang: string; // "" = subtitles off
|
||||||
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
|
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
|
||||||
|
|
@ -90,7 +90,6 @@ type PlexPlayerPrefs = {
|
||||||
const DEFAULT_PREFS: PlexPlayerPrefs = {
|
const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||||
volume: 1,
|
volume: 1,
|
||||||
muted: false,
|
muted: false,
|
||||||
wasPlaying: true,
|
|
||||||
audioLang: "",
|
audioLang: "",
|
||||||
subLang: "",
|
subLang: "",
|
||||||
subOffset: 0,
|
subOffset: 0,
|
||||||
|
|
@ -165,13 +164,10 @@ const HLS_SUB_LAG = 1.0;
|
||||||
// the queue instead of the item's own episode neighbours.
|
// the queue instead of the item's own episode neighbours.
|
||||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||||
|
|
||||||
|
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
|
||||||
|
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
|
||||||
function fmt(t: number): string {
|
function fmt(t: number): string {
|
||||||
if (!isFinite(t) || t < 0) t = 0;
|
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
|
||||||
const s = Math.floor(t % 60);
|
|
||||||
const m = Math.floor((t / 60) % 60);
|
|
||||||
const h = Math.floor(t / 3600);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
|
|
@ -217,7 +213,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
|
|
||||||
// Per-account persisted player prefs — survive F5, carry across items.
|
// Per-account persisted player prefs — survive F5, carry across items.
|
||||||
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
|
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
|
||||||
"siftlode.plexPlayerPrefs",
|
LS.plexPlayerPrefs,
|
||||||
DEFAULT_PREFS,
|
DEFAULT_PREFS,
|
||||||
);
|
);
|
||||||
const { volume, muted } = prefs;
|
const { volume, muted } = prefs;
|
||||||
|
|
@ -419,18 +415,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onPlay = () => {
|
const onPlay = () => setPlaying(true);
|
||||||
setPlaying(true);
|
const onPause = () => setPlaying(false);
|
||||||
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
|
|
||||||
};
|
|
||||||
const onPause = () => {
|
|
||||||
setPlaying(false);
|
|
||||||
patchPrefs({ wasPlaying: false });
|
|
||||||
};
|
|
||||||
// A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
|
// A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
|
||||||
// which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
|
// which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
|
||||||
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
|
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
|
||||||
// so the icon can never contradict the video. (Does not touch the wasPlaying pref.)
|
// so the icon can never contradict the video.
|
||||||
const syncPlaying = () => setPlaying(!video.paused);
|
const syncPlaying = () => setPlaying(!video.paused);
|
||||||
video.addEventListener("timeupdate", onTime);
|
video.addEventListener("timeupdate", onTime);
|
||||||
video.addEventListener("play", onPlay);
|
video.addEventListener("play", onPlay);
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,15 @@ 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.
|
||||||
|
* Empty string for a missing/zero duration (nothing to show). */
|
||||||
|
export function formatRuntime(sec?: number | null): string {
|
||||||
|
if (!sec) return "";
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
return h ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
||||||
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
||||||
// key for any unmapped/legacy value.
|
// key for any unmapped/legacy value.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const LS = {
|
||||||
plexFilters: "siftlode.plexFilters",
|
plexFilters: "siftlode.plexFilters",
|
||||||
plexQ: "siftlode.plexQ",
|
plexQ: "siftlode.plexQ",
|
||||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||||||
|
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
||||||
notifHistory: "siftlode.notifications",
|
notifHistory: "siftlode.notifications",
|
||||||
notifSettings: "siftlode.notifSettings",
|
notifSettings: "siftlode.notifSettings",
|
||||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue