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:
+4
-4
@@ -556,10 +556,10 @@ of a full-width panel and nothing on the right.
|
|||||||
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
|
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
|
||||||
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
|
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
|
||||||
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
|
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
|
||||||
ends are white, a focused end's ring and the playhead amber (a light theme's accent all but vanished
|
ends are white, a focused end's ring and the playhead the media amber `--media-mark` (the same in both
|
||||||
there). A typed time that cannot be read stays in its field, marked invalid; one past the end shows
|
themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read
|
||||||
where it landed (decimal point or comma). The strip's window
|
stays in its field, marked invalid; one past the end shows where it landed (decimal point or comma).
|
||||||
follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
|
The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
|
||||||
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
|
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
|
||||||
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
|
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
|
||||||
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
|
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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.
|
// 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";
|
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
|
||||||
const END_RING = "focus-visible:outline-warn";
|
const PLAYHEAD = "bg-media-mark";
|
||||||
|
const END_RING = "focus-visible:outline-media-mark";
|
||||||
|
|
||||||
export default function ClipPanel({
|
export default function ClipPanel({
|
||||||
src,
|
src,
|
||||||
|
|||||||
@@ -1533,7 +1533,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
{detail?.markers.map((m, i) => (
|
{detail?.markers.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute inset-y-0 bg-warn/50"
|
className="absolute inset-y-0 bg-media-mark/50"
|
||||||
style={{
|
style={{
|
||||||
left: `${(m.start_s / duration) * 100}%`,
|
left: `${(m.start_s / duration) * 100}%`,
|
||||||
width: `${((m.end_s - 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();
|
split();
|
||||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||||
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
|
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
|
||||||
|
act(() => remove.focus());
|
||||||
fireEvent.click(remove);
|
fireEvent.click(remove);
|
||||||
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
|
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", () => {
|
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
|
||||||
split();
|
split();
|
||||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
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 qc = useQueryClient();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const splitRef = useRef<HTMLButtonElement>(null);
|
||||||
const sid = useRef(1);
|
const sid = useRef(1);
|
||||||
const nid = () => sid.current++;
|
const nid = () => sid.current++;
|
||||||
|
|
||||||
@@ -132,6 +133,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
// --- cut-list mutations ---
|
// --- cut-list mutations ---
|
||||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
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 = () => {
|
const splitAtPlayhead = () => {
|
||||||
if (creating.current) return;
|
if (creating.current) return;
|
||||||
const time = current;
|
const time = current;
|
||||||
@@ -332,6 +345,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
ref={splitRef}
|
||||||
onClick={splitAtPlayhead}
|
onClick={splitAtPlayhead}
|
||||||
icon={<SplitSquareHorizontal />}
|
icon={<SplitSquareHorizontal />}
|
||||||
>
|
>
|
||||||
@@ -420,8 +434,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
adornment: (
|
adornment: (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={() => deleteBoundary(bi)}
|
onClick={() => {
|
||||||
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
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")}
|
aria-label={t("editor.removeCut")}
|
||||||
title={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).
|
// 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 };
|
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
|
// 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.
|
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both — the media
|
||||||
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
// 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) {
|
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
--color-live: var(--live);
|
--color-live: var(--live);
|
||||||
--color-live-fg: var(--live-fg);
|
--color-live-fg: var(--live-fg);
|
||||||
--color-media-chrome: var(--media-chrome);
|
--color-media-chrome: var(--media-chrome);
|
||||||
|
--color-media-mark: var(--media-mark);
|
||||||
|
|
||||||
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
|
||||||
@@ -1154,6 +1155,9 @@ html[data-theme="light"] {
|
|||||||
--live-fg: #ffffff;
|
--live-fg: #ffffff;
|
||||||
--rating-media: #fcd34d; /* a score on a poster's black scrim */
|
--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 */
|
--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) */
|
/* 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", () => {
|
it("the player chrome's white text holds 4.5:1", () => {
|
||||||
expect(hexContrast("#ffffff", media()["media-chrome"]!)).toBeGreaterThanOrEqual(4.5);
|
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", () => {
|
it("styles exactly the schemes the app offers", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user