diff --git a/frontend/src/components/Carousel.tsx b/frontend/src/components/Carousel.tsx index 31834026..46f4affa 100644 --- a/frontend/src/components/Carousel.tsx +++ b/frontend/src/components/Carousel.tsx @@ -77,8 +77,10 @@ export function Carousel({ const label = ariaLabel ?? (typeof title === "string" ? title : undefined); + // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` + // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. const arrowBtn = - "absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 77adbcab..67ca30ad 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -31,7 +31,7 @@ import { import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; @@ -337,7 +337,10 @@ export default function PlayerModal({ if (e.key === "Escape") { // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // anchored popover): it takes the Escape and we do nothing — so we don't also step out of - // maximise or close the player underneath it. + // maximise or close the player underneath it. The consumed-event flag is the reliable signal + // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the + // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. + if (escapeHandled(e)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 1fe3eef3..76a7d8de 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -53,7 +53,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { useBackToClose } from "../lib/history"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -602,7 +602,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { case "Escape": // Defer to any layer opened above the player (Modal or anchored popover) — let it take the // Escape, not us. A player MENU registers as a layer too, so it closes first and the next - // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). + // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the + // consumed-event flag first (UX-8): a layer's `document` listener runs before this `window` + // one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it. + if (escapeHandled(e)) break; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break; if (skipProgressRef.current != null) cancelAutoSkipRef.current(); else if (menuOpenRef.current) { diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index f4b40dab..e502722c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -262,9 +262,12 @@ function Thumb({ )} - {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */} + {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only: + reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and + `reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a + permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */} {onOpen && ( -
+
{inProgress ? ( <>