From 89e65ca3317365ce2474fb79e8f55e566c7a4565 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 02:12:54 +0200 Subject: [PATCH] feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plex player: a finger's tap on the picture only shows or hides the controls, the hide timer starts with playback, a double-tap on a side third skips 10 s, the seek bar is a 2.75rem touch-none hit area with its head shown on touch. The row is play/pause, Back by position, Next only with a next item, mute, time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download) and fullscreen; Stop, Restart and the ±10 s buttons are gone. A started card shows the video card's Continue + Restart pair (one shared MediaResumeButtons, beside the picture's button), and its menu lists the restart; the player opens that item at 0 (fromStart). YouTube modal: the description opens from an ⓘ, the queue arrows sit inside the screen. --- frontend/src/components/Home.dom.test.tsx | 30 ++ frontend/src/components/Home.tsx | 34 +- .../src/components/MediaResumeButtons.tsx | 66 ++++ .../src/components/PlayerModal.dom.test.tsx | 42 ++ frontend/src/components/PlayerModal.tsx | 52 +-- frontend/src/components/PlexBrowse.tsx | 33 +- .../PlexCollectionView.dom.test.tsx | 2 + .../src/components/PlexCollectionView.tsx | 10 +- .../src/components/PlexDiscover.dom.test.tsx | 8 + frontend/src/components/PlexDiscover.tsx | 9 +- frontend/src/components/PlexInfo.tsx | 3 +- frontend/src/components/PlexMenu.dom.test.tsx | 25 +- .../components/PlexPersonView.dom.test.tsx | 2 + .../PlexPlayer.controls.dom.test.tsx | 367 ++++++++++++++++++ .../PlexPlayer.session.dom.test.tsx | 8 +- .../PlexPlayer.tabtrap.dom.test.tsx | 20 +- frontend/src/components/PlexPlayer.tsx | 352 +++++++++++------ frontend/src/components/PlexPlaylistView.tsx | 3 +- frontend/src/components/VideoCard.tsx | 49 +-- .../src/components/plexCards.dom.test.tsx | 51 +++ frontend/src/components/plexCards.tsx | 36 +- frontend/src/components/plexContext.ts | 2 + .../src/components/plexPlayerControls.tsx | 43 -- frontend/src/components/plexSubUrl.ts | 3 +- frontend/src/components/plexViews.tsx | 11 +- .../ui/pressableSelectable.contract.test.ts | 2 +- .../src/components/usePlexCardActions.tsx | 26 +- frontend/src/design-lab/batchC.tsx | 8 +- frontend/src/i18n/locales/en/plex.json | 6 +- frontend/src/i18n/locales/hu/plex.json | 6 +- .../lib/plexPlayerHooks.controls.dom.test.tsx | 22 +- frontend/src/lib/plexPlayerHooks.ts | 49 ++- frontend/src/lib/zLayer.contract.test.ts | 2 +- 33 files changed, 1100 insertions(+), 282 deletions(-) create mode 100644 frontend/src/components/MediaResumeButtons.tsx create mode 100644 frontend/src/components/PlexPlayer.controls.dom.test.tsx diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 346260b8..4454df02 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -712,6 +712,36 @@ describe("Home", () => { expect(setPage).toHaveBeenCalledWith("plex"); }); + // C5: what a tile's Play would continue, its menu can start over — the one way to on touch. + it("a started film's menu starts it over: the player opens from the start", async () => { + const movie = { + ...ytCard("m1", "A Film"), + source: "plex", + kind: "movie", + aspect: "poster", + position_seconds: 600, + video: null, + plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 }, + }; + discover.mockResolvedValue({ + budget: "any", + hero: null, + continue_watching: [movie], + comfort: [], + }); + mount(); + fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!); + const restart = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "card.restartTitle"); + fireEvent.click(restart!); + await waitFor(() => + expect(setJumpTo).toHaveBeenCalledWith( + expect.objectContaining({ id: "m1", type: "play", fromStart: true }), + ), + ); + }); + it("rotates the tier on each press so the button offers a different KIND of answer", async () => { // Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards, // never once reaching the shelf. The press count travels to the server as the rotation. diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 927ab45c..cf707397 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -10,6 +10,7 @@ import { PinOff, Play, RotateCcw, + SkipBack, Star, } from "lucide-react"; import { qk } from "../lib/queryKeys"; @@ -51,7 +52,8 @@ import { SURFACE } from "./ui/surfaces"; import clsx from "clsx"; import { IconButton } from "./ui/IconButton"; import { MoreButton } from "./ui/MoreButton"; -import { HOVER_YIELD } from "./ui/reveal"; +import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; +import { MediaResumeButtons } from "./MediaResumeButtons"; import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard"; import { MEDIA_BAR, @@ -171,6 +173,17 @@ function useHomeCardMenu({ disabled: !(target.next ?? target).video && !(target.next ?? target).plex, onSelect: () => onPlay(target), }, + // What the Play row would continue can also start over (C5), with the card menus' words. + ...((target.next ?? target).position_seconds > 0 + ? [ + { + id: "restart", + label: t("card.restartTitle"), + icon: , + onSelect: () => onPlay(target, 0), + }, + ] + : []), ...(target.video ? videoActions .items(target.video, menu.anchor, { pin: false }) @@ -344,6 +357,7 @@ function Rail({ shelf navIndex={i} onOpen={() => onPlay(c)} + onRestart={() => onPlay(c, 0)} onToggleWatched={onToggleWatched} onAdd={plexAdd} next={c.next?.plex} @@ -569,10 +583,21 @@ function Hero({ /> ) : null} -
- -
+
{!started && }
+ {/* A started hero continues on a click, and offers the start as a card does (C5): the pair + is a sibling over the picture, never inside its button. */} + {started && ( +
+ onPlay(card)} onRestart={() => onPlay(card, 0)} /> +
+ )} {/* The card's own hover row, as on every card: a sibling of the picture's button. */} {card.video ? (
@@ -851,6 +876,7 @@ export default function Home() { setJumpTo({ id: target.plex.id, type: target.plex.type === "show" ? "show" : "play", + fromStart: startAt === 0, replace: true, }); setPage("plex"); diff --git a/frontend/src/components/MediaResumeButtons.tsx b/frontend/src/components/MediaResumeButtons.tsx new file mode 100644 index 00000000..ed4e3a9f --- /dev/null +++ b/frontend/src/components/MediaResumeButtons.tsx @@ -0,0 +1,66 @@ +import type { MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, RotateCcw } from "lucide-react"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; + +// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the +// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one +// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the +// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a +// tap on the picture continues, and the card's "…" carries the restart. +export function MediaResumeButtons({ + small = false, + onContinue, + onRestart, +}: { + small?: boolean; + onContinue: (e: MouseEvent) => void; + onRestart: (e: MouseEvent) => void; +}) { + const { t } = useTranslation(); + return small ? ( + <> + + + + + + + + ) : ( + <> + + + + ); +} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 9137517a..90372f59 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -401,3 +401,45 @@ describe("PlayerModal — the watch toggle after a queue advance", () => { expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0); }); }); + +// C3: the description used to open on hovering the title, which a finger never does. It opens from +// an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing. +describe("PlayerModal — the description", () => { + it("opens from the ⓘ, not from hovering the title", async () => { + vi.spyOn(api, "videoDetail").mockResolvedValue({ + description: "A long description", + } as Awaited>); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" })); + fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" })); + await new Promise((r) => setTimeout(r, 500)); + expect(screen.queryByText("A long description")).toBeNull(); + const info = screen.getByRole("button", { name: "player.description" }); + fireEvent.click(info); + expect(await screen.findByText("A long description")).toBeTruthy(); + expect(info.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts +// them off (outside the card, a 1024 window cut them and a portrait tablet lost them). +describe("PlayerModal — the side arrows", () => { + it("stand inside the row's gutter", () => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + const B = vid("B", 0); + renderModal( + {}} onState={() => {}} />, + ); + const arrow = [...document.querySelectorAll("[aria-label='player.next']")].find( + (b) => b.className.includes("w-12"), + )!; + expect(arrow).toBeTruthy(); + expect(arrow.className).toMatch(/\bright-0\b/); + expect(arrow.className).not.toMatch(/left-full/); + expect(arrow.parentElement!.className).toMatch(/sm:px-14/); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 6e3d42c5..2422348c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -15,6 +15,7 @@ import { ChevronLeft, ChevronRight, ExternalLink, + Info, Maximize, Minimize, Minimize2, @@ -403,25 +404,23 @@ export default function PlayerModal({ } }; - // Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows - // upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still - // rescues it when there is no room up there either, which the old fixed `bottom` could not. The - // hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card) - // and the travel grace are now the platform's, along with the safePolygon bridge that replaces the - // pair of hand-managed timers. + // Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click + // or tap (C3): it used to open on hovering the title, which a finger never does — measured live, + // a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player + // rather than off-screen; `flip` still rescues it when there is no room up there. const [showDesc, setShowDesc] = useState(false); const descPop = useAnchoredPopover({ open: showDesc, onOpenChange: setShowDesc, - mode: "hover", + mode: "dialog", + role: "dialog", placement: "top-start", offset: 8, - hoverDelay: { open: 400, close: 150 }, }); const detail = useQuery({ queryKey: qk.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId), - // On hover (for the description) and eagerly when navigated, so we can link the + // On opening the description, and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. enabled: showDesc || navigated, staleTime: 5 * 60_000, @@ -574,10 +573,13 @@ export default function PlayerModal({ aria-labelledby={titleId} > {/* The card, flanked by full-height glassy strips that step through the feed's order (or a - playlist). Each strip sits just outside the card, spans its full height, and fades out at - the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens - where there's no room beside the card. */} -
+ playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades + out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut + it and a portrait tablet lost it). stopPropagation so a click steps instead of closing. + Hidden on narrow screens where there's no room beside the card. */} +
{hasQueue && ( { @@ -587,7 +589,7 @@ export default function PlayerModal({ disabled={index === 0} aria-label={t("player.previous")} title={`${t("player.previous")} · Shift+←`} - className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" + className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" > @@ -778,18 +780,20 @@ export default function PlayerModal({ )}
- {/* Title row — title (with hover description) on the left, Close on the right. */} + {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}

- {/* Hover target is the text itself (inline), not the whole row. */} - - {navigated ? (liveData?.title ?? t("player.loading")) : active.title} - + {navigated ? (liveData?.title ?? t("player.loading")) : active.title}

+ + + {navigated && ( - )} - {small ? ( - - - - ) : ( - - )} - + ) : ( { }); }); +// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play; +// they sit beside the picture's own button, never inside it. +describe("a started Plex card", () => { + const started: PlexCard = { + ...movie("in_progress"), + position_seconds: 600, + duration_seconds: 6000, + }; + + it("shows Continue + Restart, not the Play, and each does its own thing", () => { + const onOpen = vi.fn(); + const onRestart = vi.fn(); + const { container } = render( + , + ); + const picture = screen.getByRole("button", { name: "M" }); + expect(picture.querySelector(".reveal-hover.inset-0 svg")).toBeNull(); + const cont = screen.getByRole("button", { name: "card.continue" }); + const restart = screen.getByRole("button", { name: "card.restart" }); + expect(picture.contains(cont) || picture.contains(restart)).toBe(false); + fireEvent.click(restart); + expect(onRestart).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); + fireEvent.click(cont); + expect(onOpen).toHaveBeenCalledTimes(1); + // Mouse only: on touch the tap continues and the "…" has the restart. + expect(cont.closest(".reveal-hover")).not.toBeNull(); + expect(container.querySelector(".flex-col.reveal-hover")).not.toBeNull(); // a poster stacks + }); + + it("an episode's landscape picture keeps the pair side by side", () => { + const { container } = render( + , + ); + expect(screen.getByRole("button", { name: "card.restart" })).toBeTruthy(); + expect(container.querySelector(".flex-col.reveal-hover")).toBeNull(); + }); + + it("a not-started or watched card keeps the one Play", () => { + render(); + expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull(); + expect( + screen.getByRole("button", { name: "M" }).querySelector(".reveal-hover.inset-0 svg"), + ).not.toBeNull(); + }); +}); + describe("SeasonCard a11y", () => { it("names the watched-toggle and the add-season button", () => { render(); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 0293b107..75c67cb4 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -11,7 +11,8 @@ import { plexAddLabelKey } from "./usePlexCardActions"; import { IconButton } from "./ui/IconButton"; import { MoreButton } from "./ui/MoreButton"; import { Pressable } from "./ui/Pressable"; -import { HOVER_YIELD } from "./ui/reveal"; +import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; +import { MediaResumeButtons } from "./MediaResumeButtons"; import { MEDIA_BADGE, MEDIA_BAR, @@ -173,12 +174,15 @@ function CardTitle({ } /** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the - * keyboard walk), with the hover row docked beside it rather than inside it. */ + * keyboard walk), with the hover row docked beside it rather than inside it. A started item shows the + * video card's Continue + Restart pair instead of the Play (C5); like the hover row, the pair is a + * sibling laid over the picture, never inside its button. */ function PlexPicture({ aspect, thumb, label, onOpen, + onRestart, plays, small = false, pct = 0, @@ -190,6 +194,8 @@ function PlexPicture({ /** The button's name: the picture has no text of its own. */ label: string; onOpen: () => void; + /** Play from the start, where the surface can (the player's `fromStart`). */ + onRestart?: () => void; /** The click plays (a movie, an episode) — only then does the hover draw a Play. */ plays: boolean; /** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */ @@ -199,6 +205,8 @@ function PlexPicture({ /** The badges printed on the picture. */ children?: ReactNode; }) { + // The pct is the resume point's: a watched or untouched item has none, and plays from its start. + const started = plays && pct > 0 && !!onRestart; return (
{children} -
{plays && }
+
+ {plays && !started && } +
+ {started && ( +
+ +
+ )} {bar}
); @@ -243,9 +265,12 @@ export function PlexPosterCard({ shelf = false, next, onInfo, + onRestart, }: { card: PlexCard; onOpen: () => void; + /** Play the item (or the `next` episode) from its start, not its resume point (C5). */ + onRestart?: () => void; onToggleWatched?: (c: PlexCard) => void; /** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */ onAdd?: (c: PlexCard) => void; @@ -316,6 +341,7 @@ export function PlexPosterCard({ thumb={(wide && card.type !== "episode" && card.art) || card.thumb} label={card.title} onOpen={onOpen} + onRestart={onRestart} plays={isPlayable} small={shelf && landscape} pct={pct} @@ -591,6 +617,7 @@ export function RelatedStrip({ export function EpisodeCard({ ep, onPlay, + onRestart, onAdd, onToggleWatched, withShowTitle, @@ -601,6 +628,8 @@ export function EpisodeCard({ }: { ep: PlexCard; onPlay: () => void; + /** Play the episode from its start, not its resume point (C5). */ + onRestart?: () => void; onAdd?: () => void; onToggleWatched?: () => void; withShowTitle?: boolean; @@ -628,6 +657,7 @@ export function EpisodeCard({ thumb={ep.thumb} label={ep.title} onOpen={onPlay} + onRestart={onRestart} plays pct={pct} bar={ diff --git a/frontend/src/components/plexContext.ts b/frontend/src/components/plexContext.ts index f99e7852..d1a48a0c 100644 --- a/frontend/src/components/plexContext.ts +++ b/frontend/src/components/plexContext.ts @@ -22,6 +22,8 @@ export type PlexJumpTarget = { * from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a * sub-view the user really was looking at. */ replace?: boolean; + /** `play` only: from the start, not the resume point (the card's Restart, C5). */ + fromStart?: boolean; }; export type PlexState = { diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index 60cd343f..34d56888 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -4,49 +4,6 @@ import { RotateCcw } from "lucide-react"; import { IconButton } from "./ui/IconButton"; import { Slider as Range, Checkbox } from "./ui/NativeControls"; -// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native -// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double -// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end, -// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar. -export function Skip10({ dir }: { dir: "back" | "forward" }) { - const back = dir === "back"; - return ( - - ); -} - // --- settings-panel building blocks ------------------------------------------------------------ export function PanelHead({ children }: { children: ReactNode }) { return ( diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index 43d0c5b6..cc5e2735 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -11,7 +11,8 @@ export type Sub = | { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode) | { kind: "show"; id: string } | { kind: "season"; showId: string; seasonId: string } - | { kind: "player"; id: string; queue?: string[] } + // `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point. + | { kind: "player"; id: string; queue?: string[]; fromStart?: boolean } | { kind: "info"; id: string } | { kind: "person"; name: string } | { kind: "playlist"; id: number } diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index a4cb4afc..81f0b53b 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -71,7 +71,7 @@ export function PlexInfoView({ id: string; onBack: () => void; onPlay: () => void; - onPlayItem: (id: string) => void; + onPlayItem: (id: string, fromStart?: boolean) => void; onFilter: (patch: Partial) => void; onOpenPerson: (name: string) => void; }) { @@ -119,12 +119,14 @@ export function PlexPersonView({ name, onBack, onOpenCard, + onRestartCard, onInfoCard, onFilter, }: { name: string; onBack: () => void; onOpenCard: (card: PlexCard) => void; + onRestartCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; onFilter: (patch: Partial) => void; }) { @@ -170,10 +172,11 @@ export function PlexPersonView({ }); const actions = usePlexCardActions( - { onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggle }, + { onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle }, menu.open, ); const cardMenu = (c: PlexCard) => ({ + onRestart: () => onRestartCard(c), onAdd: actions.add, menuProps: menu.triggerProps, menuButtonProps: menu.buttonProps, @@ -654,7 +657,7 @@ export function PlexSeasonView({ showId: string; seasonId: string; onBack: () => void; - onPlay: (epRk: string, queue: string[]) => void; + onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -706,6 +709,7 @@ export function PlexSeasonView({ const episodes = usePlexCardActions( { onOpen: (ep) => onPlay(ep.id, allKeys), + onRestart: (ep) => onPlay(ep.id, allKeys, true), onToggleWatched: (ep) => void markEpisode(ep), }, epMenu.open, @@ -796,6 +800,7 @@ export function PlexSeasonView({ navIndex={i} ep={ep} onPlay={() => onPlay(ep.id, allKeys)} + onRestart={() => onPlay(ep.id, allKeys, true)} onAdd={() => episodes.add(ep)} onToggleWatched={() => markEpisode(ep)} menuProps={epMenu.triggerProps} diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 061ff552..58964e8e 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -27,7 +27,7 @@ const PLAIN: Record = { "PlaylistsRail.tsx": [1, "the load-error retry"], "PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], "PlexInfo.tsx": [1, "the rating filter (a number)"], - "PlexPlayer.tsx": [3, "track picker rows"], + "PlexPlayer.tsx": [4, "track picker rows and the ⋯ menu's rows"], "PlexPlayerSettings.tsx": [2, "setting options"], "PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"], "PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"], diff --git a/frontend/src/components/usePlexCardActions.tsx b/frontend/src/components/usePlexCardActions.tsx index 65c6c40c..523dfad0 100644 --- a/frontend/src/components/usePlexCardActions.tsx +++ b/frontend/src/components/usePlexCardActions.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, Info, ListPlus, Play, Tv2, Undo2 } from "lucide-react"; +import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react"; import { type PlexCard } from "../lib/api"; import { pinPlex, useComfortPin } from "./comfortPin"; @@ -18,6 +18,9 @@ export interface PlexActionHandlers { /** The media-info page. Omitted where a surface has none (the episode lists). */ onInfo?: (c: PlexCard) => void; onToggleWatched?: (c: PlexCard) => void; + /** Play from the start instead of the resume point (C5). Where given, a started title lists it + * under Play — on touch, the only way to it (the card's hover pair is mouse-only). */ + onRestart?: (c: PlexCard) => void; } const icon = "w-4 h-4"; @@ -29,6 +32,11 @@ export const plexAddLabelKey = (c: PlexCard): string => /** A show opens its drill-down; a movie or an episode plays. */ const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show"; +/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is + * stored at 0, so it never counts. */ +export const plexStarted = (c: PlexCard): boolean => + isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0; + /** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the * item builder, `add` for the card's own hover button, and the dialog to render beside the menu * (one per list, so a card does not mount a modal of its own). */ @@ -48,18 +56,30 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) c: PlexCard, { play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {}, ): ActionMenuItem[] => { - const { onOpen, onInfo, onToggleWatched } = handlers; + const { onOpen, onInfo, onToggleWatched, onRestart } = handlers; const playable = isPlexPlayable(c); const watched = c.status === "watched"; + const started = plexStarted(c); return [ ...(play ? [ { id: "play", - label: playable ? t("plex.play") : t("plex.openShow"), + label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"), icon: playable ? : , onSelect: () => onOpen(c), }, + // The same words and glyph as the video card's menu (useVideoActions). + ...(started && onRestart + ? [ + { + id: "restart", + label: t("card.restartTitle"), + icon: , + onSelect: () => onRestart(c), + }, + ] + : []), ] : []), ...(playable && onToggleWatched diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx index c4e756fe..c5895432 100644 --- a/frontend/src/design-lab/batchC.tsx +++ b/frontend/src/design-lab/batchC.tsx @@ -313,10 +313,10 @@ function MediaCard({ state }: { state: "new" | "started" | "touch" }) { {state === "started" && ( - Resume 14:02 + Continue - From the start + Restart )} @@ -352,7 +352,7 @@ function Cards() { - +
@@ -365,7 +365,7 @@ function C5() { id="c5" code="C5" title="The Plex player's controls" - rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Resume + From the start (touch: a tap resumes, the '…' has From the start)." + rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Continue + Restart, the video card's pair (touch: a tap continues, the '…' has the restart)." >
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 15b8b750..1f36b1f1 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -171,10 +171,10 @@ "skipCredits": "Skip credits", "playPause": "Play / pause (space)", "restart": "Restart from beginning", - "rewind10": "Back 10 seconds", - "forward10": "Forward 10 seconds", "prev": "Previous episode", "next": "Next episode", + "tapBack": "−{{n}} s", + "tapForward": "+{{n}} s", "mute": "Mute (m)", "volume": "Volume", "fullscreen": "Fullscreen (f)", @@ -186,9 +186,9 @@ "errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.", "errTranscode": "This file's format needs transcoding, which isn't supported yet.", "errGeneric": "Playback couldn't start. Please try again.", - "stop": "Stop", "help": "Keyboard shortcuts", "infoBtn": "Media info", + "more": "More", "upNext": { "title": "Up next", "playNow": "Play now", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 9dd8560b..1c718c8d 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -171,10 +171,10 @@ "skipCredits": "Stáblista átugrása", "playPause": "Lejátszás / szünet (szóköz)", "restart": "Újra az elejéről", - "rewind10": "10 másodperc vissza", - "forward10": "10 másodperc előre", "prev": "Előző rész", "next": "Következő rész", + "tapBack": "−{{n}} mp", + "tapForward": "+{{n}} mp", "mute": "Némítás (m)", "volume": "Hangerő", "fullscreen": "Teljes képernyő (f)", @@ -186,9 +186,9 @@ "errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.", "errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.", "errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.", - "stop": "Leállítás", "help": "Billentyűparancsok", "infoBtn": "Média infó", + "more": "Továbbiak", "upNext": { "title": "Következik", "playNow": "Lejátszás most", diff --git a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx index f6eff430..4db4896e 100644 --- a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx +++ b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx @@ -7,12 +7,13 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { useControlsVisibility } from "./plexPlayerHooks"; -const setup = () => { - const video = { paused: false } as HTMLVideoElement; +const setup = (menuOpen = false) => { + const video = document.createElement("video"); + Object.defineProperty(video, "paused", { configurable: true, value: false }); return renderHook(() => useControlsVisibility({ barAutoHideRef: { current: true }, - menuOpenRef: { current: false }, + menuOpenRef: { current: menuOpen }, videoRef: { current: video }, }), ); @@ -44,4 +45,19 @@ describe("useControlsVisibility", () => { act(() => vi.advanceTimersByTime(100)); expect(result.current.uiVisible).toBe(false); }); + + // C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer. + it("toggle hides a shown bar at once and shows a hidden one", () => { + const { result } = setup(); + act(() => result.current.toggle()); + expect(result.current.uiVisible).toBe(false); + act(() => result.current.toggle()); + expect(result.current.uiVisible).toBe(true); + }); + + it("a tap does not hide the bar under an open menu", () => { + const { result } = setup(true); + act(() => result.current.toggle()); + expect(result.current.uiVisible).toBe(true); + }); }); diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index e5c4f3e6..03693593 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -73,25 +73,50 @@ export function useFullscreen(wrapRef: RefObject): { // `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube // and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it // starts a fresh 3 s. +// +// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the +// bar stayed up until the first tap however long the video played — and a tap on the picture only +// shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip +// takes back the show/hide its first tap did. export function useControlsVisibility(opts: { barAutoHideRef: RefObject; menuOpenRef: RefObject; videoRef: RefObject; -}): { uiVisible: boolean; wake: () => void; holdOnHover: (over: boolean) => void } { +}): { + uiVisible: boolean; + wake: () => void; + holdOnHover: (over: boolean) => void; + toggle: () => void; + show: (visible: boolean) => void; +} { const { barAutoHideRef, menuOpenRef, videoRef } = opts; const [uiVisible, setUiVisible] = useState(true); + const visibleRef = useRef(true); const hideTimer = useRef(null); const hovered = useRef(false); + const setVisible = useCallback((v: boolean) => { + visibleRef.current = v; + setUiVisible(v); + }, []); const wake = useCallback(() => { - setUiVisible(true); + setVisible(true); if (hideTimer.current) window.clearTimeout(hideTimer.current); if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar hideTimer.current = window.setTimeout(() => { // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. - if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) - setUiVisible(false); + if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) setVisible(false); }, 3000); - }, [barAutoHideRef, menuOpenRef, videoRef]); + }, [barAutoHideRef, menuOpenRef, videoRef, setVisible]); + const show = useCallback( + (visible: boolean) => { + if (visible) return wake(); + if (hideTimer.current) window.clearTimeout(hideTimer.current); + // A tap on a picture with a menu open closes nothing and must not strand that menu unseen. + if (!menuOpenRef.current) setVisible(false); + }, + [wake, menuOpenRef, setVisible], + ); + const toggle = useCallback(() => show(!visibleRef.current), [show]); const holdOnHover = useCallback( (over: boolean) => { hovered.current = over; @@ -99,7 +124,19 @@ export function useControlsVisibility(opts: { }, [wake], ); - return { uiVisible, wake, holdOnHover }; + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.addEventListener("play", wake); + return () => video.removeEventListener("play", wake); + }, [videoRef, wake]); + useEffect( + () => () => { + if (hideTimer.current) window.clearTimeout(hideTimer.current); + }, + [], + ); + return { uiVisible, wake, holdOnHover, toggle, show }; } // Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the diff --git a/frontend/src/lib/zLayer.contract.test.ts b/frontend/src/lib/zLayer.contract.test.ts index 0a11ee95..6f0c2f92 100644 --- a/frontend/src/lib/zLayer.contract.test.ts +++ b/frontend/src/lib/zLayer.contract.test.ts @@ -46,7 +46,7 @@ describe("the Plex player's layers", () => { const popovers = classes( /\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/, ); - expect(popovers).toHaveLength(2); + expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel for (const c of popovers) expect(c).toMatch(/\bz-menu\b/); });