diff --git a/VERSION b/VERSION index fd9620c..7d07a19 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.1 +0.33.0 \ No newline at end of file diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index bf16f46..129e050 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -43,37 +43,119 @@ class HlsSession: self.key = key self.dir = directory self.proc = proc - self.start_s = start_s + self.start_s = start_s # the REQUESTED seek offset + # The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier + # keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X). + # We measure K from the first segment's absolute PTS (kept absolute by `-copyts`) and report + # THAT as the session start, so the client's absolute clock (start + currentTime) and the + # subtitle cue-shift both key off the true content position — no drift. Provisional = X until + # the first segment is probed. + self.media_start_s = start_s self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8) self.last_access = time.time() -def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]: +def _seek_opts(start_s: float) -> list[str]: + # -noaccurate_seek: with `-ss` before -i, video (stream-copy) backs up to the keyframe K<=X, + # but the re-encoded audio is normally trimmed to the exact X — so audio starts (X-K) AFTER + # video → seconds of silence at the start of every seek/audio-switch. noaccurate_seek makes + # audio ALSO start at K, aligned with the video (K is our real clock anchor anyway). + return ["-noaccurate_seek", "-ss", f"{start_s:.3f}"] if start_s > 0 else [] + + +_HLS_TAIL = [ + "-f", "hls", + "-hls_time", str(_SEG_SECONDS), + "-hls_list_size", "0", + # EVENT (not VOD): ffmpeg writes/appends the playlist AS segments complete, so playback can + # start immediately from this session's offset. VOD only writes the playlist at the end. The + # full seekbar comes from our known duration + the seek-restart model, not from the playlist. + "-hls_playlist_type", "event", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments+temp_file", +] + + +def _probe_audio_langs(src: Path) -> list[str]: + """One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count. + Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path.""" + try: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream_tags=language", + "-of", "csv=p=0", str(src)], + capture_output=True, text=True, timeout=10, + ) + return [ln.strip() for ln in out.stdout.splitlines()] + except (subprocess.SubprocessError, OSError): + return [] + + +def _ffmpeg_cmd( + src: Path, start_s: float, out_dir: Path, audio_ord: int | None, + aoff: float = 0.0, audio_langs: list[str] | None = None, +) -> list[str]: args = ["ffmpeg", "-nostdin", "-loglevel", "error"] - if start_s > 0: - args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i - args += ["-i", str(src)] - # Video always stream-copied; the selected audio track (default = first) is transcoded to AAC. - # Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that - # the browser overlays, so choosing a subtitle no longer restarts this session (and external - # sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those). - args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"] - args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"] - args += [ - "-f", "hls", - "-hls_time", str(_SEG_SECONDS), - "-hls_list_size", "0", - # EVENT (not VOD): ffmpeg writes/appends the playlist AS segments complete, so playback can - # start immediately from this session's offset. VOD only writes the playlist at the end. The - # full seekbar comes from our known duration + the seek-restart model, not from the playlist. - "-hls_playlist_type", "event", - "-hls_segment_type", "mpegts", - "-hls_flags", "independent_segments+temp_file", - ] - args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")] + # Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the + # keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back + # (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is + # rewritten toward 0 and the true offset is unknowable, which caused the ~seconds subtitle lead. + args += _seek_opts(start_s) + ["-copyts", "-i", str(src)] + ai = 0 # input index the audio maps come from + if aoff: + # A/V-sync offset: read the file a SECOND time for audio only, with `-itsoffset` shifting the + # audio PTS by ±aoff relative to the (input-0) video. Maps audio from input 1 then. + args += ["-itsoffset", f"{aoff:.3f}"] + _seek_opts(start_s) + ["-copyts", "-i", str(src)] + ai = 1 + # Video always stream-copied; audio → AAC. Subtitles are NOT muxed here — they're separate WebVTT + # tracks (GET /subtitle) the browser overlays, so choosing a subtitle doesn't restart this session. + if audio_langs: + # MULTI-RENDITION: map EVERY audio track as an alternate HLS rendition in one group, so hls.js + # switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium + # audio-switch path. Produces master.m3u8 + stream_%v.m3u8 (v0=video, v1..=audio) + seg_%v_%d.ts. + args += ["-map", "0:v:0"] + for i in range(len(audio_langs)): + args += ["-map", f"{ai}:a:{i}?"] + args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"] + var = ["v:0,agroup:aud"] + for i, lang in enumerate(audio_langs): + entry = f"a:{i},agroup:aud,name:a{i}" + if lang: + entry += f",language:{lang}" + if i == 0: + entry += ",default:yes" + var.append(entry) + args += _HLS_TAIL + [ + "-master_pl_name", "master.m3u8", + "-var_stream_map", " ".join(var), + "-hls_segment_filename", str(out_dir / "seg_%v_%d.ts"), str(out_dir / "stream_%v.m3u8"), + ] + else: + ao = audio_ord if audio_ord is not None else 0 + args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"] + args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"] + args += _HLS_TAIL + ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")] return args +def _probe_first_pts(seg: Path) -> float | None: + """Read the first video packet's absolute PTS from a finished HLS segment (`-copyts` kept it + absolute). This is the true content position of the segment's first frame — the value hls.js + zero-bases the timeline to. Returns None on any failure (caller falls back to the requested offset).""" + try: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", "%+#1", + "-show_entries", "packet=pts_time", "-of", "csv=p=0", str(seg)], + capture_output=True, text=True, timeout=10, + ) + for line in out.stdout.splitlines(): + tok = line.strip().strip(",") + if tok: + return float(tok) + except (subprocess.SubprocessError, ValueError, OSError): + pass + return None + + def _kill(s: HlsSession) -> None: try: s.proc.terminate() @@ -100,32 +182,51 @@ def start_session( item: PlexItem, start_s: float, audio_ord: int | None = None, + aoff: float = 0.0, + multi: bool = False, ) -> HlsSession | None: - """(Re)start the HLS remux for an item at the given offset, with an optional selected audio track - (ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns - None if the local file can't be read.""" + """(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps + every audio track as an HLS rendition so the client switches audio without a restart; otherwise a + single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). + Returns None if the local file can't be read.""" src = paths.local_media_path(db, item.file_path) if src is None: return None key = item.rating_key start_s = max(0.0, float(start_s)) + audio_langs = _probe_audio_langs(src) if multi else [] + is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks with _lock: old = _sessions.pop(key, None) if old is not None: _kill(old) - directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}" + tag = "multi" if is_multi else str(audio_ord) + directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" shutil.rmtree(directory, ignore_errors=True) directory.mkdir(parents=True, exist_ok=True) proc = subprocess.Popen( - _ffmpeg_cmd(src, start_s, directory, audio_ord), + _ffmpeg_cmd(src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - s = HlsSession(key, directory, proc, start_s, "index.m3u8") + s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8") _sessions[key] = s _enforce_cap() - log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord) - return s + # Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on + # ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so + # its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On + # timeout/probe failure we keep the provisional requested offset (old behaviour, small drift). + if start_s > 0: + seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0 + if wait_for(seg0, timeout=25.0): + k = _probe_first_pts(seg0) + if k is not None and k >= 0: + s.media_start_s = k + log.info( + "plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s", + key, start_s, s.media_start_s, is_multi, audio_ord, + ) + return s def current_session(key: str) -> HlsSession | None: diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 9da6260..1f63726 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -943,6 +943,63 @@ def _srt_to_vtt(text: str) -> str: return "WEBVTT\n\n" + body.strip() + "\n" +def _vtt_ts_to_s(ts: str) -> float: + parts = ts.split(":") + h = "0" if len(parts) == 2 else parts[0] + m, rest = parts[-2], parts[-1] + sec, _, ms = rest.partition(".") + return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0 + + +def _vtt_s_to_ts(x: float) -> str: + x = max(0.0, x) + h = int(x // 3600); x -= h * 3600 + m = int(x // 60); x -= m * 60 + s = int(x); ms = int(round((x - s) * 1000)) + if ms >= 1000: + s += 1; ms -= 1000 + return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" + + +def _shift_vtt(vtt: str, offset: float) -> str: + """Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times, + but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s), + so a subtitle overlaid on that session must be shifted by that offset to line up with speech. + + Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely. + (Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0 + — where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue + straddling 0 is clamped to start at 0.""" + if not offset: + return vtt + out_blocks: list[str] = [] + for block in re.split(r"\n[ \t]*\n", vtt): + if "-->" not in block: + out_blocks.append(block) # header / NOTE / STYLE — keep verbatim + continue + lines = block.split("\n") + keep = True + for i, ln in enumerate(lines): + if "-->" not in ln: + continue + left, _, right = ln.partition("-->") + rparts = right.strip().split(None, 1) + settings = (" " + rparts[1]) if len(rparts) > 1 else "" + try: + st = _vtt_ts_to_s(left.strip()) - offset + en = _vtt_ts_to_s(rparts[0]) - offset + except (ValueError, IndexError): + break # unparseable timing → leave the block as-is + if en <= 0: + keep = False # whole cue is before the session start → drop the block + else: + lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0 + break + if keep: + out_blocks.append("\n".join(lines)) + return "\n\n".join(out_blocks) + + def _ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str: """Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams.""" @@ -1047,16 +1104,18 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response @router.get("/subtitle/{rating_key}/{sub_ord}") -def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response: +def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response: """Serve the ord-th subtitle track as WebVTT for the browser's — so choosing a subtitle overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file) AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached.""" _require_session(request) + # Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way + # out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion). cache_key = f"sub_{rating_key}_{sub_ord}" hit = _sub_cache_read(cache_key) if hit is not None: - return Response(hit, media_type="text/vtt") + return Response(_shift_vtt(hit, offset), media_type="text/vtt") try: with SessionLocal() as db: it = _item_or_404(db, rating_key) @@ -1094,7 +1153,7 @@ def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response: except PlexError as e: raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}") _sub_cache_write(cache_key, vtt) - return Response(vtt, media_type="text/vtt") + return Response(_shift_vtt(vtt, offset), media_type="text/vtt") # --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ---------------------------- @@ -1439,6 +1498,8 @@ def stream_session( rating_key: str, start: float = 0.0, audio: int | None = None, + aoff: float = 0.0, + multi: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -1450,14 +1511,18 @@ def stream_session( it = _item_or_404(db, rating_key) if it.playable == "transcode": raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).") - if it.playable == "direct" and audio is None: + # `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and + # the client can switch audio without a restart — even for otherwise direct-playable files. + if it.playable == "direct" and audio is None and not aoff and not multi: return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0} if plex_paths.local_media_path(db, it.file_path) is None: raise HTTPException(status_code=404, detail="Media file not found on disk") - s = plex_stream.start_session(db, it, start, audio) + s = plex_stream.start_session(db, it, start, audio, aoff, multi) if s is None: raise HTTPException(status_code=404, detail="Media file not found on disk") - return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"} + # Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the + # requested offset — the client's absolute clock and the subtitle cue-shift both key off this. + return {"mode": "hls", "start_s": s.media_start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"} @router.get("/stream/{rating_key}/file") @@ -1500,11 +1565,15 @@ def stream_hls_file( raise HTTPException(status_code=404, detail="Not found") s = plex_stream.current_session(str(rating_key)) if s is None: - # No session yet: only the entry playlist for a remux item may auto-start one at 0. + # No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a + # multi-audio item, index.m3u8 for a single-audio remux item. it = _item_or_404(db, rating_key) - if filename != "index.m3u8" or it.playable != "remux": + if filename == "master.m3u8": + s = plex_stream.start_session(db, it, 0.0, multi=True) + elif filename == "index.m3u8" and it.playable == "remux": + s = plex_stream.start_session(db, it, 0.0) + else: raise HTTPException(status_code=404, detail="No active playback session") - s = plex_stream.start_session(db, it, 0.0) if s is None: raise HTTPException(status_code=404, detail="Media file not found on disk") path = s.dir / filename diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 127844e..0a573e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -260,6 +260,12 @@ export default function App() { } }, [plexFiltersRaw]); const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f)); + // The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed + // `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted, + // greeted you with a stale query (colliding with a persisted collection filter → confusing + // "0 matches") after a reload. Kept in App so it survives page switches within a session, but + // resets on reload. + const [plexQ, setPlexQ] = useState(""); // Bumped to tell the channel manager to drop a stale column filter when we send the user // there to see a specific set (the header's "without full history" link). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -767,6 +773,8 @@ export default function App() { me={meQuery.data!} filters={filters} setFilters={setFilters} + plexQ={plexQ} + setPlexQ={setPlexQ} page={page} onYtSearch={enterYtSearch} /> @@ -819,8 +827,8 @@ export default function App() { /> ) : page === "plex" && meQuery.data!.plex_enabled ? ( setFilters({ ...filters, q: "" })} + q={plexQ} + onClearSearch={() => setPlexQ("")} library={plexLib} show={plexShowFilter} sort={plexSort} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index a5b93a4..2038bbf 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -12,12 +12,17 @@ export default function Header({ me, filters, setFilters, + plexQ, + setPlexQ, page, onYtSearch, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; + // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not. + plexQ: string; + setPlexQ: (q: string) => void; page: Page; // Trigger a live YouTube search for the current term (hidden for the demo account, which // can't spend the shared quota). @@ -25,11 +30,14 @@ export default function Header({ }) { const { t } = useTranslation(); const canSearchYt = !me.is_demo; - const trimmedQ = filters.q.trim(); // The search box serves both the YouTube feed and the Plex module (integrated search); the live - // YouTube-search escalation (Enter / button) is feed-only. + // YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed + // the persisted `filters.q`. const isSearchPage = page === "feed" || page === "plex"; + const isPlex = page === "plex"; const isYtCapable = page === "feed" && canSearchYt; + const searchValue = isPlex ? plexQ : filters.q; + const trimmedQ = searchValue.trim(); return (
@@ -38,14 +46,18 @@ export default function Header({
{ const q = e.target.value; + if (isPlex) { + setPlexQ(q); + return; + } // When a search first appears, rank the feed by relevance — set atomically with // the query (race-free vs. per-keystroke updates). Only overrides the default // "newest" sort; a custom sort the user chose is left alone. Cleared in Feed. const startSearch = - page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest"; + !filters.q.trim() && !!q.trim() && filters.sort === "newest"; setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); }} onKeyDown={(e) => { @@ -56,9 +68,9 @@ export default function Header({ placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")} className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" /> - {filters.q && ( + {searchValue && ( +
+ ) : ( +

{dq ? t("plex.noMatches") : t("plex.empty")}

+ ) ) : (
{items.map((c) => ( diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index d1bdd48..792b2dd 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,4 +1,14 @@ -import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { + Fragment, + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, + type WheelEvent as ReactWheelEvent, +} from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; @@ -8,6 +18,7 @@ import { Download, Info, Keyboard, + Languages, Maximize, Minimize, Pause, @@ -22,10 +33,128 @@ import { X, } from "lucide-react"; import { api, type PlexMarker } from "../lib/api"; +import { useAccountPersistedObject } from "../lib/storage"; +import { useDismiss } from "../lib/useDismiss"; +import { useBackToClose } from "../lib/history"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); +// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track +// ordinal means different languages in different files, so we re-resolve the saved language to this +// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language). +type AudioStream = { ord: number; language?: string | null; default: boolean }; +type SubStream = { ord: number; language?: string | null; text: boolean }; +function audioOrdForLang(streams: AudioStream[], lang: string): number | null { + if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS) + const i = streams.findIndex((s) => (s.language ?? "") === lang); + // absent (-1) or already the FIRST track (0, which the backend plays by default) → no override. + // (Match by index, NOT the `default` flag — some files mark a non-first track default, which made + // the restored language silently fall back to track 0.) + return i <= 0 ? null : streams[i].ord; +} +function subOrdForLang(subs: SubStream[], lang: string): number | null { + if (!lang) return null; // "" = subtitles off + const m = subs.find((s) => s.text && (s.language ?? "") === lang); + return m ? m.ord : null; // preferred language not available here → off +} + +// Per-account player preferences, persisted so they survive F5 and carry across items. +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) + audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset) + barAutoHide: boolean; // auto-hide the control bar during playback + seekStep: number; // plain ←/→ jump (s) + fineSeekStep: number; // Ctrl+←/→ jump (s) + subSize: number; // subtitle font size (%) + subColor: string; // subtitle text color (hex) + subBg: string; // subtitle background ("transparent" or hex) + subPos: number; // subtitle vertical position (cue line %, higher = lower on screen) + subShadow: number; // subtitle text-shadow blur px (0 = off) + subShadowColor: string; // subtitle text-shadow color (hex) + clockShow: boolean; // top-right wall clock + clock24h: boolean; // 24h (European) vs 12h am/pm + clockSize: number; // clock font size (%) + clockColor: string; // clock color (hex) + clockDate: boolean; // show the date alongside the time + clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd vs YYYY-MON-DD Ddd + autoSkipIntro: boolean; // auto-skip the intro marker after a countdown + autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge) + autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate) +}; +const DEFAULT_PREFS: PlexPlayerPrefs = { + volume: 1, + muted: false, + wasPlaying: true, + audioLang: "", + subLang: "", + subOffset: 0, + audioOffset: 0, + barAutoHide: true, + seekStep: 10, + fineSeekStep: 1, + subSize: 100, + subColor: "#ffffff", + subBg: "transparent", + subPos: 88, + subShadow: 0, + subShadowColor: "#000000", + clockShow: true, + clock24h: true, + clockSize: 100, + clockColor: "#ffffff", + clockDate: false, + clockDateStyle: "hu", + autoSkipIntro: false, + autoSkipCredits: false, + autoSkipDelay: 5, +}; +const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); + +// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even +// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the +// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a +// snow-white frame. Empty string when the slider is 0 (shadow off). +function subShadowCss(px: number, color: string): string { + if (px <= 0) return ""; + const o = px; + const b = Math.max(1, Math.round(px / 2)); + const dirs: [number, number][] = [ + [o, 0], [-o, 0], [0, o], [0, -o], + [o, o], [o, -o], [-o, o], [-o, -o], + ]; + const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(","); + return `text-shadow:${outline},0 0 ${o * 2}px ${color};`; +} + +// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed +// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday). +function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } { + const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: !p.clock24h, + }); + if (!p.clockDate) return { time, date: null }; + const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB"; + const dd = String(now.getDate()).padStart(2, "0"); + const yyyy = now.getFullYear(); + const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase(); + const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, ""); + const date = p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`; + return { time, date }; +} +// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by +// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and +// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we +// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock. +const HLS_SUB_LAG = 1.0; + // The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw // (native
- {/* Skip intro / credits */} + {/* Skip intro / credits (with the auto-skip countdown fill, if armed) */} {activeMarker && ( )} @@ -563,14 +1045,45 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { uiVisible ? "opacity-100" : "opacity-0 pointer-events-none" }`} > - {/* Seek bar */} + {/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on + release so a long drag doesn't restart the stream on every step. */}
{ - const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); - seekTo(((e.clientX - r.left) / r.width) * duration); + onPointerDown={(e) => { + const el = e.currentTarget as HTMLElement; + try { + el.setPointerCapture(e.pointerId); + } catch { + /* capture unavailable — dragging still works via buttons state */ + } + const r = el.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - r.left, r.width)); + setScrub((x / r.width) * duration); }} + onPointerMove={(e) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1)); + const tt = frac * duration; + setHover({ frac, t: tt }); + if (e.buttons & 1) setScrub(tt); // dragging with the primary button held + }} + onPointerUp={() => { + if (scrub != null) { + seekTo(scrub); + setScrub(null); + } + }} + onMouseLeave={() => setHover(null)} > + {/* Hover timestamp tooltip — follows the cursor along the bar. */} + {hover && duration > 0 && ( +
+ {fmt(hover.t)} +
+ )}
{/* intro/credit marker regions */} {detail?.markers.map((m, i) => ( @@ -580,15 +1093,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }} /> ))} -
+
- + {playing ? : } seekTo(0)}> @@ -608,56 +1123,86 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )} -
- - setMuted((m) => { - applyVolume(volume, !m); - return !m; - }) - } - > +
+ {muted || volume === 0 ? : } - { - const nv = Number(e.target.value); - setVolume(nv); - setMuted(nv === 0); - applyVolume(nv, false); + {/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0–100 + value under the cursor; click/drag sets it. */} +
{ + try { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } catch { + /* capture unavailable — dragging still works via buttons state */ + } + const v = volAt(e.clientX); + setVol(v, v === 0); }} - className="w-20 accent-accent" - /> + onPointerMove={(e) => { + const v = volAt(e.clientX); + setVolHover(v); + if (e.buttons & 1) setVol(v, v === 0); // dragging with the primary button held + }} + onMouseLeave={() => setVolHover(null)} + > + {/* filled level */} +
+ {/* thumb */} +
+ {/* hover value tooltip (0–100), like the seek bar */} + {volHover != null && ( +
+ {Math.round(volHover * 100)} +
+ )} +
{fmt(abs)} / {fmt(duration)} -{fmt(Math.max(0, duration - abs))} + {/* Projected finish time (wall clock now + time remaining), in the clock's own format — + pushes later while paused, since the remaining time then rides the advancing clock. */} + {duration > 0 && ( + + ({clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs).time}) + + )}
+ {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */} {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
- {menuOpen && ( -
+ {tracksOpen && ( +
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 && ( <> -
- {t("plex.player.audio")} -
+ {t("plex.player.audio")} {detail.audio_streams.map((a) => (
)} + + {/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */} + {detail && ( +
+ + {menuOpen && ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="glass-menu absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl" + > +
+ {settingsTabs.map((tab) => ( + + ))} +
+ + {activeTab === "sync" && ( + <> + patchPrefs({ subOffset: v })} + onReset={() => patchPrefs({ subOffset: 0 })} + /> + changeAudioOffset(0)} + /> + + )} + + {activeTab === "playback" && ( + <> + patchPrefs({ seekStep: v })} + /> + patchPrefs({ fineSeekStep: v })} + /> + patchPrefs({ barAutoHide: v })} + /> + patchPrefs({ autoSkipIntro: v })} + /> + patchPrefs({ autoSkipCredits: v })} + /> + {(prefs.autoSkipIntro || prefs.autoSkipCredits) && ( + patchPrefs({ autoSkipDelay: v })} + /> + )} + + )} + + {activeTab === "subtitle" && ( + <> + patchPrefs({ subSize: v })} + /> + patchPrefs({ subPos: v })} + /> +
+ {t("plex.player.subColor")} + patchPrefs({ subColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded bg-transparent" + /> +
+
+ {t("plex.player.subBg")} +
+ + {prefs.subBg !== "transparent" && ( + patchPrefs({ subBg: e.target.value })} + className="h-6 w-10 cursor-pointer rounded bg-transparent" + /> + )} +
+
+ patchPrefs({ subShadow: v })} + /> + {prefs.subShadow > 0 && ( +
+ {t("plex.player.subShadowColor")} + patchPrefs({ subShadowColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded bg-transparent" + /> +
+ )} + + )} + + {activeTab === "clock" && ( + <> + patchPrefs({ clockShow: v })} + /> + patchPrefs({ clock24h: v })} + /> + patchPrefs({ clockSize: v })} + /> +
+ {t("plex.player.clockColor")} + patchPrefs({ clockColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded bg-transparent" + /> +
+ patchPrefs({ clockDate: v })} + /> + {prefs.clockDate && ( +
+ {(["hu", "en"] as const).map((st) => ( + + ))} +
+ )} + + )} +
+ )} +
+ )} setInfoOpen((v) => !v)}> @@ -710,7 +1490,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { - + {fs ? : }
@@ -747,11 +1527,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { [ [["Space", "K"], "plex.player.keys.playPause"], [["←", "→"], "plex.player.keys.seek"], + [["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"], ...(detail?.kind === "episode" ? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]] : []), [["↑", "↓"], "plex.player.keys.volume"], [["M"], "plex.player.keys.mute"], + [["A"], "plex.player.keys.audio"], + [["S"], "plex.player.keys.subtitle"], [["F"], "plex.player.keys.fullscreen"], [["I"], "plex.player.keys.info"], [["⌫", "Esc"], "plex.player.keys.back"], @@ -800,19 +1583,90 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { ); } +// Renders nothing; while mounted it claims one history entry so the browser/mouse Back button runs +// `onBack` (close the panel / cancel the skip) instead of navigating out of the player. +function BackClose({ onBack }: { onBack: () => void }) { + useBackToClose(onBack); + return null; +} + +// --- settings-panel building blocks ------------------------------------------------------------ +function PanelHead({ children }: { children: ReactNode }) { + return
{children}
; +} +function Slider({ + label, + value, + min, + max, + step, + suffix, + onChange, + onReset, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + suffix?: string; + onChange: (v: number) => void; + onReset?: () => void; +}) { + return ( +
+
+ {label} + + + {Number.isInteger(value) ? value : value.toFixed(1)} + {suffix ?? ""} + + {onReset && ( + + )} + +
+ onChange(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ ); +} +function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + // A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the // viewport, unlike a native title tooltip on the bottom control bar). function Ctrl({ label, onClick, disabled, + align = "center", children, }: { label: string; onClick: () => void; disabled?: boolean; + // Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport. + align?: "start" | "center" | "end"; children: ReactNode; }) { + const pos = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; return (
- + {label}
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 91489ca..d2fb8d0 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -57,6 +57,8 @@ "count": "{{count}} Titel", "searchCount": "{{count}} Treffer", "noMatches": "Keine Treffer.", + "noMatchesFiltered_one": "Keine Treffer — ein Filter schränkt die Ergebnisse zusätzlich ein.", + "noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.", "loading": "Wird geladen…", "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "loadMore": "Mehr laden", @@ -120,8 +122,36 @@ "fullscreen": "Vollbild", "info": "Medieninfo", "back": "Stopp & zurück zum Feed", - "help": "Diese Hilfe anzeigen" - } + "help": "Diese Hilfe anzeigen", + "audio": "Audiospur wechseln", + "subtitle": "Untertitel wechseln", + "fineSeek": "Feines Springen" + }, + "settings": "Einstellungen", + "sync": "Offsets", + "subOffset": "Untertitel-Verzögerung", + "audioOffset": "Audio-Verzögerung", + "playback": "Wiedergabe", + "seekStep": "Sprungweite", + "fineSeek": "Feines Springen (Strg)", + "autoHide": "Steuerung ausblenden", + "subStyle": "Untertitel", + "subSize": "Schriftgröße", + "subPos": "Position", + "subColor": "Textfarbe", + "subBg": "Hintergrund", + "subBgNone": "Keiner", + "clock": "Uhr", + "clockShow": "Uhr anzeigen", + "clock24h": "24-Stunden", + "clockSize": "Größe", + "clockColor": "Farbe", + "clockDate": "Datum anzeigen", + "subShadow": "Schatten", + "subShadowColor": "Schattenfarbe", + "autoSkipIntro": "Intro autom. überspringen", + "autoSkipCredits": "Abspann autom. überspringen", + "autoSkipDelay": "Auto-Skip-Verzögerung" }, "info": { "title": "Medieninfo", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 8601de1..9c9dfba 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -57,6 +57,8 @@ "count": "{{count}} titles", "searchCount": "{{count}} matches", "noMatches": "No matches.", + "noMatchesFiltered_one": "No matches — a filter is also narrowing the results.", + "noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.", "loading": "Loading…", "empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "loadMore": "Load more", @@ -120,8 +122,36 @@ "fullscreen": "Fullscreen", "info": "Media info", "back": "Stop & back to feed", - "help": "Show this help" - } + "help": "Show this help", + "audio": "Cycle audio track", + "subtitle": "Cycle subtitle", + "fineSeek": "Fine seek" + }, + "settings": "Settings", + "sync": "Offsets", + "subOffset": "Subtitle delay", + "audioOffset": "Audio delay", + "playback": "Playback", + "seekStep": "Seek step", + "fineSeek": "Fine seek (Ctrl)", + "autoHide": "Auto-hide controls", + "subStyle": "Subtitles", + "subSize": "Text size", + "subPos": "Position", + "subColor": "Text color", + "subBg": "Background", + "subBgNone": "None", + "clock": "Clock", + "clockShow": "Show clock", + "clock24h": "24-hour", + "clockSize": "Size", + "clockColor": "Color", + "clockDate": "Show date", + "subShadow": "Shadow", + "subShadowColor": "Shadow color", + "autoSkipIntro": "Auto-skip intro", + "autoSkipCredits": "Auto-skip credits", + "autoSkipDelay": "Auto-skip delay" }, "info": { "title": "Media info", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index a753fbe..6cfeb88 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -57,6 +57,8 @@ "count": "{{count}} cím", "searchCount": "{{count}} találat", "noMatches": "Nincs találat.", + "noMatchesFiltered_one": "Nincs találat — egy szűrő is szűkíti az eredményt.", + "noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.", "loading": "Betöltés…", "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", "loadMore": "Több betöltése", @@ -120,8 +122,36 @@ "fullscreen": "Teljes képernyő", "info": "Média infó", "back": "Leállítás és vissza a feedre", - "help": "Súgó megjelenítése" - } + "help": "Súgó megjelenítése", + "audio": "Hangsáv váltása", + "subtitle": "Felirat váltása", + "fineSeek": "Finom léptetés" + }, + "settings": "Beállítások", + "sync": "Eltolások", + "subOffset": "Felirat-eltolás", + "audioOffset": "Hang-eltolás", + "playback": "Lejátszás", + "seekStep": "Léptetés", + "fineSeek": "Finom léptetés (Ctrl)", + "autoHide": "Vezérlők elrejtése", + "subStyle": "Feliratok", + "subSize": "Szövegméret", + "subPos": "Pozíció", + "subColor": "Szövegszín", + "subBg": "Háttér", + "subBgNone": "Nincs", + "clock": "Óra", + "clockShow": "Óra megjelenítése", + "clock24h": "24 órás", + "clockSize": "Méret", + "clockColor": "Szín", + "clockDate": "Dátum", + "subShadow": "Árnyék", + "subShadowColor": "Árnyék színe", + "autoSkipIntro": "Intro auto-átugrás", + "autoSkipCredits": "Stáblista auto-átugrás", + "autoSkipDelay": "Auto-skip késleltetés" }, "info": { "title": "Média infó", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 431a3f4..51ff4ff 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1218,19 +1218,44 @@ export const api = { req(`/api/plex/show/${encodeURIComponent(id)}`), plexItem: (id: string): Promise => req(`/api/plex/item/${encodeURIComponent(id)}`), - plexSession: (id: string, start = 0, audio?: number | null): Promise => { + plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise => { const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) }); if (audio != null) p.set("audio", String(audio)); + if (aoff) p.set("aoff", String(aoff)); + if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch) return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" }); }, // WebVTT URL for a text subtitle track (used directly as a ). Same-origin → cookie-authed. - plexSubtitleUrl: (id: string, ord: number): string => - `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`, + // `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute + // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek. + plexSubtitleUrl: (id: string, ord: number, offset = 0): string => + `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`, plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise => req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { method: "POST", body: JSON.stringify({ position_seconds, duration_seconds }), }), + // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is + // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position + // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we + // replicate req()'s credentials + account header (no retry — there's no page left to retry on). + plexProgressBeacon: (id: string, position_seconds: number, duration_seconds: number): void => { + const active = getActiveAccount(); + try { + void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, { + method: "POST", + keepalive: true, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + }, + body: JSON.stringify({ position_seconds, duration_seconds }), + }).catch(() => {}); + } catch { + /* ignore */ + } + }, plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise => req(`/api/plex/item/${encodeURIComponent(id)}/state`, { method: "POST", diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 90bbf39..109012a 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,21 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.33.0", + date: "2026-07-09", + summary: "Watch your Plex library right here — a full player — plus smarter Plex search.", + features: [ + "Plex player: play any film or episode straight from Siftlode. It resumes where you left off, remembers your volume, and — when the file's format needs it — streams through an on-the-fly conversion so it just plays.", + "Player controls: scrub the timeline by clicking or dragging it, hover for a timestamp, switch audio tracks and subtitles, and see the expected finish time in brackets next to the time remaining. A live clock sits in the top corner.", + "Skip Intro / Skip Credits buttons appear on marked titles, with an optional auto-skip countdown (which pauses when you pause); credits can auto-advance to the next episode.", + "Personalise the player from the gear menu — subtitle/audio sync offsets, seek step, subtitle style (size, position, colour, background, shadow) and the corner clock. The whole player is 25% larger for lean-back viewing, and Back / Esc (keyboard or mouse) step out of an open menu before leaving the player.", + ], + fixes: [ + "Plex search now has its own box: it no longer leaks into — or gets left behind by — the feed search, and it clears on reload instead of greeting you with a stale query.", + 'A Plex search that comes up empty while a filter is active now says so and offers a one-click "Clear filters", instead of a bare "No matches".', + ], + }, { version: "0.32.1", date: "2026-07-07", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 48162ce..4347802 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; // Central registry of every localStorage key the app uses. One place to see them all, rename // safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across @@ -158,3 +158,24 @@ export function useAccountPersistedState( }; return [value, set]; } + +/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall + * back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of + * related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */ +export function useAccountPersistedObject( + base: string, + defaults: T, +): [T, (patch: Partial) => void] { + const [value, setValue] = useState(() => readAccountMerged(base, defaults)); + const patch = useCallback( + (p: Partial) => { + setValue((prev) => { + const next = { ...prev, ...p }; + writeAccount(base, next); + return next; + }); + }, + [base], + ); + return [value, patch]; +}