Merge chore/code-hygiene: Phase 2 #9 Plex follow-up (frontend player bugs + FE dedup)

Bugs: PP1 marker-skip reset on item change, PP2 lang-switch session reload from
stale position, PP3 tracks-menu wheel→volume, PS1 sidebar badge clamp.
Cleanup: formatRuntime dedup (PlexBrowse/PlexInfo), fmt→formatDuration, LS key
registration, dead wasPlaying pref, redundant onStateChange refetch.
This commit is contained in:
npeter83 2026-07-11 23:22:54 +02:00
commit 358142ca9a
6 changed files with 45 additions and 43 deletions

View file

@ -26,6 +26,7 @@ import {
type PlexSeasonDetail,
type PlexUnifiedResult,
} from "../lib/api";
import { formatRuntime } from "../lib/format";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
@ -67,13 +68,6 @@ type Props = {
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({
q,
onClearSearch,
@ -483,7 +477,7 @@ function PlexPosterCard({
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-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>
);
@ -526,7 +520,8 @@ function PlexInfoView({
onPlay={onPlay}
onPlayItem={onPlayItem}
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>
)}
@ -845,7 +840,7 @@ function EpisodeCard({
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</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>
{onAdd && (
<button

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@ -28,13 +29,6 @@ type Props = {
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({
detail,
variant,
@ -170,7 +164,7 @@ export default function PlexInfo({
{detail.content_rating}
</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 && (
<Filterable
className={mutedCls}

View file

@ -33,7 +33,8 @@ import {
X,
} from "lucide-react";
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 { useBackToClose } from "../lib/history";
@ -63,7 +64,6 @@ function subOrdForLang(subs: SubStream[], lang: string): number | null {
type PlexPlayerPrefs = {
volume: number; // 0..1
muted: boolean;
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
audioLang: string; // "" = default first audio
subLang: string; // "" = subtitles off
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
@ -90,7 +90,6 @@ type PlexPlayerPrefs = {
const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
muted: false,
wasPlaying: true,
audioLang: "",
subLang: "",
subOffset: 0,
@ -165,17 +164,20 @@ const HLS_SUB_LAG = 1.0;
// the queue instead of the item's own episode neighbours.
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 {
if (!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)}`;
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
}
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const { t } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useRef(t);
tRef.current = t;
const qc = useQueryClient();
const [id, setId] = useState(itemId);
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
@ -217,7 +219,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Per-account persisted player prefs — survive F5, carry across items.
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
"siftlode.plexPlayerPrefs",
LS.plexPlayerPrefs,
DEFAULT_PREFS,
);
const { volume, muted } = prefs;
@ -288,10 +290,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// needs full transcoding (a later phase).
setLoadError(
e?.status === 501
? t("plex.player.errTranscode")
? tRef.current("plex.player.errTranscode")
: e?.status === 404
? t("plex.player.errNotFound")
: t("plex.player.errGeneric"),
? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"),
);
return;
}
@ -343,7 +345,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead.
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoadError(t("plex.player.errGeneric"));
if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
@ -358,7 +360,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.addEventListener("loadedmetadata", onMeta);
}
},
[id, t],
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
);
// Start playback when the detail arrives (resume from the saved position unless already watched).
@ -419,18 +421,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/* ignore */
}
};
const onPlay = () => {
setPlaying(true);
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
};
const onPause = () => {
setPlaying(false);
patchPrefs({ wasPlaying: false });
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
// 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
// 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);
video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay);
@ -851,6 +847,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [skipProgress, setSkipProgress] = useState<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(null);
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
// the previous one (auto-skip silently dead for the rest of a binge).
useEffect(() => {
cancelledMarkerRef.current = null;
}, [id]);
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
const skipTimerRef = useRef<number | null>(null);
@ -1222,6 +1224,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div
ref={tracksRef}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
>
{detail.audio_streams.length > 1 && (

View file

@ -133,7 +133,7 @@ export default function PlexSidebar({
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount}
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>

View file

@ -70,6 +70,15 @@ 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). */
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
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
// key for any unmapped/legacy value.

View file

@ -32,6 +32,7 @@ export const LS = {
plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",