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();
|
||||
|
||||
Reference in New Issue
Block a user