diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index e2a00d3e..d310fdb6 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -73,8 +73,10 @@ export default function Modal({ return; } const active = document.activeElement; - if (!panel.contains(active)) { - // Focus escaped the dialog (or sits on the panel itself) — pull it back to an edge. + if (!panel.contains(active) || active === panel) { + // Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by + // clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be + // checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back. e.preventDefault(); (e.shiftKey ? last : first).focus(); } else if (e.shiftKey && active === first) { diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index c8e4cd78..336984a5 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -244,7 +244,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward // restores or clears them to match the entry we landed on. setYtSearch((e.state?._yt as string) ?? null); - const chan = e.state?._chan as string | undefined; + // Fall back to the ?chan URL param (the browser restored the landed entry's URL) when the entry + // has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would + // wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic + // history.back()). Mirrors the channelView initializer's URL fallback. + const chan = + (e.state?._chan as string | undefined) ?? + new URLSearchParams(window.location.search).get("chan") ?? + undefined; setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null); } window.addEventListener("popstate", onPop); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 96e046c9..9fe25528 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -77,8 +77,12 @@ const PLEX_SUB_URL: SubviewUrl = { return arg ? { kind: "info", id: arg } : null; case "person": return arg ? { kind: "person", name: arg } : null; - case "playlist": - return arg && Number.isFinite(Number(arg)) ? { kind: "playlist", id: Number(arg) } : null; + case "playlist": { + // Positive-integer id only — a fractional/zero/negative ?v=playlist: would open a + // sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId). + const id = arg ? Number(arg) : NaN; + return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null; + } case "season": { const [showId, seasonId] = arg.split(":"); return showId && seasonId ? { kind: "season", showId, seasonId } : null; diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 109eeba8..e15a5e3a 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; +import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; @@ -24,6 +24,18 @@ const PLAYABLE_TINT: Record = { const swapBadgeReveal = (hasControl: boolean): string => hasControl ? "reveal-idle" : "group-hover:opacity-0"; +// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color +// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a +// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green), +// not-watched = Circle (empty, white). +function WatchedIcon({ watched }: { watched: boolean }) { + return watched ? ( + + ) : ( + + ); +} + export function PlexPosterCard({ card, onOpen, @@ -90,9 +102,7 @@ export function PlexPosterCard({ title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > - + )} {/* Media info page (movies/episodes). */} @@ -219,7 +229,7 @@ export function SeasonCard({ } className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > - + )} {/* Add whole season to a playlist, on hover. */} @@ -244,12 +254,12 @@ export function SeasonCard({ {t("plex.watched")} )} + {/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible + on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown + toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue + and must not be a reveal-idle swap-badge (which display:none-s on touch). */} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -430,9 +440,7 @@ export function EpisodeCard({ title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > - + )} {ep.status === "watched" && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e9d8e4b..d7769c43 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -558,7 +558,11 @@ body { .reveal:focus-visible { opacity: 1; } - .group:hover .reveal-idle { + /* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard + focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same + corner for a keyboard user. */ + .group:hover .reveal-idle, + .group:focus-within .reveal-idle { opacity: 0; } } diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 4cfe0460..7bb6f2b7 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -32,8 +32,12 @@ export function useHistorySubview( open: (next: T) => void; back: () => void; } { - const [view, setView] = useState(() => { - // Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ? param. + // Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the + // ? URL param for a fresh bookmark (whose entry carries the param but no _sub), else root. + // ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a + // bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and + // reading history.state alone would wrongly collapse the bookmarked sub-view to root. + const readView = (): T => { const fromState = window.history.state?._sub as T | undefined; if (fromState != null) return fromState; if (url) { @@ -42,13 +46,15 @@ export function useHistorySubview( if (decoded != null) return decoded; } return root; - }); + }; + const readViewRef = useRef(readView); + readViewRef.current = readView; + const [view, setView] = useState(readView); useEffect(() => { - // Back/Forward restores the sub-view from history.state (the browser restores each entry's URL too). - const onPop = () => setView((window.history.state?._sub as T) ?? root); + // Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL). + const onPop = () => setView(readViewRef.current()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { view,