fix(clip): marks on the player chrome keep their amber in a light theme, and a removed cut hands the focus on
- A media amber (`--media-mark`), the same in both themes, for what marks a place on the always-dark chrome or over a video: the clip strip's playhead and focused end, the crop frame's ring, Plex's intro / credits markers. A light theme's warn was a dark amber there, under 3:1 (contract-tested). - The editor's remove-cut button rings inside itself (the track clipped its ring), and once it goes with its cut the focus moves to the cut beside it, or to Split here, not to <body>.
This commit is contained in:
@@ -90,8 +90,9 @@ const LAST_MOMENT_S = 0.25;
|
||||
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
|
||||
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
||||
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
|
||||
const PLAYHEAD = "bg-warn";
|
||||
const END_RING = "focus-visible:outline-warn";
|
||||
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
|
||||
const PLAYHEAD = "bg-media-mark";
|
||||
const END_RING = "focus-visible:outline-media-mark";
|
||||
|
||||
export default function ClipPanel({
|
||||
src,
|
||||
|
||||
@@ -1533,7 +1533,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
{detail?.markers.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute inset-y-0 bg-warn/50"
|
||||
className="absolute inset-y-0 bg-media-mark/50"
|
||||
style={{
|
||||
left: `${(m.start_s / duration) * 100}%`,
|
||||
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
|
||||
|
||||
@@ -147,10 +147,31 @@ describe("VideoEditor — a cut's remove button", () => {
|
||||
split();
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
|
||||
act(() => remove.focus());
|
||||
fireEvent.click(remove);
|
||||
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
|
||||
split();
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
|
||||
act(() => second!.focus());
|
||||
fireEvent.click(second!);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
|
||||
);
|
||||
const last = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
act(() => last.focus());
|
||||
fireEvent.click(last);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
|
||||
);
|
||||
});
|
||||
|
||||
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
|
||||
split();
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const qc = useQueryClient();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const splitRef = useRef<HTMLButtonElement>(null);
|
||||
const sid = useRef(1);
|
||||
const nid = () => sid.current++;
|
||||
|
||||
@@ -132,6 +133,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
// --- cut-list mutations ---
|
||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
|
||||
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
|
||||
const refocusAfterRemove = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const bi = refocusAfterRemove.current;
|
||||
if (bi == null) return;
|
||||
refocusAfterRemove.current = null;
|
||||
const cut = trackRef.current?.querySelector<HTMLElement>(
|
||||
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
|
||||
);
|
||||
(cut ?? splitRef.current)?.focus();
|
||||
}, [boundaries.length]);
|
||||
const splitAtPlayhead = () => {
|
||||
if (creating.current) return;
|
||||
const time = current;
|
||||
@@ -332,6 +345,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
ref={splitRef}
|
||||
onClick={splitAtPlayhead}
|
||||
icon={<SplitSquareHorizontal />}
|
||||
>
|
||||
@@ -420,8 +434,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
adornment: (
|
||||
<Pressable
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => deleteBoundary(bi)}
|
||||
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
||||
onClick={() => {
|
||||
refocusAfterRemove.current = bi;
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger focus-visible:-outline-offset-2"
|
||||
aria-label={t("editor.removeCut")}
|
||||
title={t("editor.removeCut")}
|
||||
>
|
||||
|
||||
@@ -79,8 +79,10 @@ const SMALL_FRAME_PX = 64;
|
||||
// moves it (two origins at once made the frame jump between them).
|
||||
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
|
||||
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
|
||||
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
|
||||
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
||||
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both — the media
|
||||
// amber (`--media-mark`), the same in both themes: a light theme's warn is too dark over a picture.
|
||||
const FOCUS =
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-media-mark";
|
||||
|
||||
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
--color-live: var(--live);
|
||||
--color-live-fg: var(--live-fg);
|
||||
--color-media-chrome: var(--media-chrome);
|
||||
--color-media-mark: var(--media-mark);
|
||||
|
||||
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
|
||||
@@ -1154,6 +1155,9 @@ html[data-theme="light"] {
|
||||
--live-fg: #ffffff;
|
||||
--rating-media: #fcd34d; /* a score on a poster's black scrim */
|
||||
--media-chrome: #171717; /* the always-dark player's menus and bars, under white text */
|
||||
/* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop
|
||||
frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
|
||||
--media-mark: #fbbf24;
|
||||
}
|
||||
|
||||
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
|
||||
|
||||
@@ -48,6 +48,13 @@ describe("the media colours", () => {
|
||||
it("the player chrome's white text holds 4.5:1", () => {
|
||||
expect(hexContrast("#ffffff", media()["media-chrome"]!)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
// The amber that marks a place on the always-dark chrome and over a video — a playhead, a focused
|
||||
// end's ring, the crop frame's ring — is the same in both themes: a light theme's warn is a dark
|
||||
// amber, which fell under 3:1 on the clip strip (white 10% over the chrome). [WCAG 1.4.11]
|
||||
it("a mark on the player chrome holds 3:1 on the clip strip", () => {
|
||||
const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1);
|
||||
expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("styles exactly the schemes the app offers", () => {
|
||||
|
||||
Reference in New Issue
Block a user