diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index 869d293d..802b8a1d 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -11,10 +11,10 @@ import {
import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
-import { LS, setAccountRaw } from "../lib/storage";
+import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
-import { channelViewFrom, loadInitialPage, type ChannelView } from "./navigationInit";
+import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
@@ -27,8 +27,6 @@ import { channelViewFrom, loadInitialPage, type ChannelView } from "./navigation
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
// every existing card.
-const PAGE_KEY = LS.page;
-
export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts
index a57b0c03..da5640bb 100644
--- a/frontend/src/components/navigationInit.ts
+++ b/frontend/src/components/navigationInit.ts
@@ -5,7 +5,7 @@
import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
-const PAGE_KEY = LS.page;
+export const PAGE_KEY = LS.page;
export interface ChannelView {
id: string;
diff --git a/frontend/src/components/playlistsInit.test.ts b/frontend/src/components/playlistsInit.test.ts
index bdaf7aaa..5e67c970 100644
--- a/frontend/src/components/playlistsInit.test.ts
+++ b/frontend/src/components/playlistsInit.test.ts
@@ -48,6 +48,14 @@ describe("readSelectedId ?pl guard", () => {
expect(readSelectedId()).toBe(9);
});
+ it.each(["", "0", "-2", "1.5", "abc"])(
+ "rejects a non-positive-integer STORED value %s (guarded like the URL)",
+ (raw) => {
+ stored.v = raw;
+ expect(readSelectedId()).toBeNull();
+ },
+ );
+
it("returns null when neither ?pl nor a stored selection is present", () => {
expect(readSelectedId()).toBeNull();
});
diff --git a/frontend/src/components/playlistsInit.ts b/frontend/src/components/playlistsInit.ts
index bc3c256b..9ff88506 100644
--- a/frontend/src/components/playlistsInit.ts
+++ b/frontend/src/components/playlistsInit.ts
@@ -1,14 +1,14 @@
import { getAccountRaw, LS } from "../lib/storage";
+import { parsePositiveIntId } from "../lib/parseId";
// The Playlists module's selected-id seed, split out of PlaylistsProvider so its ?pl guard is
// unit-testable and the provider stays react-refresh-clean (component-only exports).
export function readSelectedId(): number | null {
- // A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. Accept only a
- // positive integer id — a bare "?pl=" gives Number("")===0 (finite!) and "?pl=1.5" a fractional id,
- // both of which would select a non-existent playlist.
- const fromUrl = new URLSearchParams(window.location.search).get("pl");
- const n = fromUrl ? Number(fromUrl) : NaN;
- if (Number.isInteger(n) && n > 0) return n;
- const s = getAccountRaw(LS.playlist);
- return s ? Number(s) : null;
+ // A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. BOTH must be a
+ // positive-integer id (a bare "?pl=" gives Number("")===0, "?pl=1.5" a fractional id) — either would
+ // select a non-existent playlist, so the stored fallback is guarded exactly like the URL.
+ return (
+ parsePositiveIntId(new URLSearchParams(window.location.search).get("pl")) ??
+ parsePositiveIntId(getAccountRaw(LS.playlist))
+ );
}
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index ffc58495..18595777 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -50,6 +50,7 @@ export function PlexPosterCard({
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
+ const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
@@ -99,8 +100,8 @@ export function PlexPosterCard({
e.stopPropagation();
onToggleWatched(card);
}}
- title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
- aria-label={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
+ title={watchedLabel}
+ aria-label={watchedLabel}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>