fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window

- The browser tab names the place and the object: PageHeading reports the
  view's own title (lib/documentTitle), so a channel, playlist or any Plex
  view reads "<object> · <module> · Siftlode" however it was opened; the
  Feed is named too; a season adds its show (tabTitle).
- The channel page's tab + action row wraps and lets the tag lane fold into
  "+N": at 1024 it ran past the edge and the page scrolled sideways.
- The YouTube player card's width is capped from the window height less the
  chrome measured under the video: at 133% zoom (685px) it cut its channel row.
- Plex certificate words (Not Rated, Approved, Passed) are named through the
  vocabulary on the info badge, show page, collection, filter and chips.
- DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
This commit is contained in:
2026-10-01 21:17:20 +02:00
parent 2543d44860
commit ec22eefd31
20 changed files with 265 additions and 46 deletions
+36 -2
View File
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
@@ -181,6 +189,25 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
// height, measured (it wraps with the title and the text size), and caps the card's width below.
const [below, setBelow] = useState(0);
useLayoutEffect(() => {
const card = cardRef.current;
const stage = stageRef.current;
if (!card || !stage) return;
const measure = () => {
// A maximised stage is fixed to the viewport, out of the card's flow: keep the last value.
if (getComputedStyle(stage).position === "fixed") return;
setBelow(card.scrollHeight - stage.offsetHeight);
};
measure();
const ro = new ResizeObserver(measure);
for (const child of card.children) ro.observe(child);
return () => ro.disconnect();
}, []);
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
@@ -579,7 +606,14 @@ export default function PlayerModal({
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. */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
style={{
maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
hasQueue ? " + 7rem" : ""
}))`,
}}
>
{hasQueue && (
<Pressable