From f3e2929d2568ee6123a7a0e2b2104afd1a2e9c08 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 28 Jul 2026 14:22:43 +0200 Subject: [PATCH 1/2] fix: keep SABR seek sliders interactive --- apps/web/src/components/audio-time-slider.tsx | 15 ++-- apps/web/src/components/sabr-time-slider.tsx | 15 ++-- .../src/components/video-player-layout.tsx | 4 +- apps/web/src/styles/sabr-seek-state.css | 7 +- apps/web/tests/sabr-time-slider.test.tsx | 73 +++++++++++++++++++ 5 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 apps/web/tests/sabr-time-slider.test.tsx diff --git a/apps/web/src/components/audio-time-slider.tsx b/apps/web/src/components/audio-time-slider.tsx index 0672e95e..acf04bd7 100644 --- a/apps/web/src/components/audio-time-slider.tsx +++ b/apps/web/src/components/audio-time-slider.tsx @@ -4,30 +4,29 @@ import { requestSabrSeek } from "../lib/sabr-vidstack-bridge"; import { TimeSlider, useMediaRemote, useMediaState } from "../lib/vidstack"; type Props = { - disabled?: boolean; + seeking?: boolean; video?: HTMLVideoElement | null; }; -export function AudioTimeSlider({ disabled = false, video = null }: Props) { +export function AudioTimeSlider({ seeking = false, video = null }: Props) { const [seekTarget, setSeekTarget] = useState(null); const remote = useMediaRemote(); const mediaDuration = useMediaState("duration"); useEffect(() => { - if (!disabled) setSeekTarget(null); - }, [disabled]); + if (!seeking) setSeekTarget(null); + }, [seeking]); const style = seekTarget === null ? undefined : { "--typetype-seek-target": `${seekTarget}%` }; return ( { setSeekTarget(percent); const seconds = secondsFromSliderPercent(video?.duration ?? mediaDuration, percent); - if (disabled || seconds === null) return; + if (seconds === null) return; if (video && requestSabrSeek(video, seconds)) return; remote.seek(seconds); }} diff --git a/apps/web/src/components/sabr-time-slider.tsx b/apps/web/src/components/sabr-time-slider.tsx index a4991aef..ec395e68 100644 --- a/apps/web/src/components/sabr-time-slider.tsx +++ b/apps/web/src/components/sabr-time-slider.tsx @@ -4,16 +4,16 @@ import { requestSabrSeek } from "../lib/sabr-vidstack-bridge"; import { TimeSlider } from "../lib/vidstack"; type Props = { - disabled?: boolean; + seeking?: boolean; thumbnails?: string; video: HTMLVideoElement | null; }; -export function SabrTimeSlider({ disabled = false, thumbnails, video }: Props) { +export function SabrTimeSlider({ seeking = false, thumbnails, video }: Props) { const [seekTarget, setSeekTarget] = useState(null); useEffect(() => { - if (!disabled) setSeekTarget(null); - }, [disabled]); + if (!seeking) setSeekTarget(null); + }, [seeking]); const style = seekTarget === null ? undefined : { "--typetype-seek-target": `${seekTarget}%` }; return ( @@ -21,13 +21,12 @@ export function SabrTimeSlider({ disabled = false, thumbnails, video }: Props) { className="vds-time-slider vds-slider" style={style} aria-label="Seek" - aria-busy={disabled} - data-seeking={disabled ? "true" : undefined} - disabled={disabled} + aria-busy={seeking} + data-seeking={seeking ? "true" : undefined} onDragEnd={(percent) => { setSeekTarget(percent); const seconds = video ? secondsFromMediaSliderPercent(video, percent) : null; - if (video && !disabled && seconds !== null) requestSabrSeek(video, seconds); + if (video && seconds !== null) requestSabrSeek(video, seconds); }} > diff --git a/apps/web/src/components/video-player-layout.tsx b/apps/web/src/components/video-player-layout.tsx index 15d494a6..5a2e2620 100644 --- a/apps/web/src/components/video-player-layout.tsx +++ b/apps/web/src/components/video-player-layout.tsx @@ -50,7 +50,7 @@ export function VideoPlayerLayout({ const forwardButton = ( ); - const timeSlider = ; + const timeSlider = ; if (audioUsesVideoProvider) { return ( + ) : undefined, settingsMenuItemsStart: ( <> diff --git a/apps/web/src/styles/sabr-seek-state.css b/apps/web/src/styles/sabr-seek-state.css index 3e05c5f8..3a291d9a 100644 --- a/apps/web/src/styles/sabr-seek-state.css +++ b/apps/web/src/styles/sabr-seek-state.css @@ -1,9 +1,3 @@ -.vds-time-slider[data-seeking], -.typetype-audio-time-slider[data-seeking] { - cursor: wait; - pointer-events: none; -} - .vds-time-slider[data-seeking] .vds-slider-track-fill { background: #fff; width: var(--typetype-seek-target, var(--slider-fill, 0%)); @@ -39,6 +33,7 @@ 0 0 0 1px rgb(255 255 255 / 12%), 0 1px 5px rgb(0 0 0 / 35%); content: ""; + pointer-events: none; transform: translateY(-50%); animation: typetype-seek-stripes 900ms linear infinite; } diff --git a/apps/web/tests/sabr-time-slider.test.tsx b/apps/web/tests/sabr-time-slider.test.tsx new file mode 100644 index 00000000..0993a38e --- /dev/null +++ b/apps/web/tests/sabr-time-slider.test.tsx @@ -0,0 +1,73 @@ +import { expect, mock, test } from "bun:test"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +let sliderProps: Record = {}; +const requestSabrSeek = mock(() => true); +const remoteSeek = mock(() => undefined); + +const Root = ({ children, ...props }: { children?: ReactNode } & Record) => { + sliderProps = props; + return <>{children}; +}; +const Slot = ({ children }: { children?: ReactNode }) => <>{children}; + +mock.module("../src/lib/sabr-vidstack-bridge", () => ({ requestSabrSeek })); +mock.module("../src/lib/vidstack", () => ({ + TimeSlider: { + Root, + Preview: Slot, + Progress: Slot, + Thumb: Slot, + Track: Slot, + TrackFill: Slot, + Value: Slot, + Thumbnail: { + Root: Slot, + Img: () => null, + }, + }, + useMediaRemote: () => ({ seek: remoteSeek }), + useMediaState: () => 600, +})); + +const { AudioTimeSlider } = await import("../src/components/audio-time-slider"); +const { SabrTimeSlider } = await import("../src/components/sabr-time-slider"); + +const video = { + duration: 600, + seekable: { + length: 0, + start: () => 0, + end: () => 0, + }, +} as HTMLVideoElement; + +test("keeps the video SABR slider interactive while a seek is pending", () => { + requestSabrSeek.mockClear(); + renderToStaticMarkup(); + + expect(sliderProps.disabled).toBeUndefined(); + expect(sliderProps["aria-busy"]).toBe(true); + expect(sliderProps["data-seeking"]).toBe("true"); + + const onDragEnd = sliderProps.onDragEnd as (percent: number) => void; + onDragEnd(80); + + expect(requestSabrSeek).toHaveBeenCalledWith(video, 480); +}); + +test("keeps the audio SABR slider interactive while a seek is pending", () => { + requestSabrSeek.mockClear(); + renderToStaticMarkup(); + + expect(sliderProps.disabled).toBeUndefined(); + expect(sliderProps["aria-busy"]).toBe(true); + expect(sliderProps["data-seeking"]).toBe("true"); + + const onDragEnd = sliderProps.onDragEnd as (percent: number) => void; + onDragEnd(20); + + expect(requestSabrSeek).toHaveBeenCalledWith(video, 120); + expect(remoteSeek).not.toHaveBeenCalled(); +}); From e698214f1f91d78a878ecf69d28c049fc5230f16 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 28 Jul 2026 15:26:16 +0200 Subject: [PATCH 2/2] test: isolate SABR slider controls --- apps/web/tests/sabr-time-slider.test.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/sabr-time-slider.test.tsx b/apps/web/tests/sabr-time-slider.test.tsx index 0993a38e..fbab869c 100644 --- a/apps/web/tests/sabr-time-slider.test.tsx +++ b/apps/web/tests/sabr-time-slider.test.tsx @@ -1,9 +1,10 @@ import { expect, mock, test } from "bun:test"; import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { registerSabrVidstackControls } from "../src/lib/sabr-vidstack-bridge"; let sliderProps: Record = {}; -const requestSabrSeek = mock(() => true); +const sabrSeek = mock(() => undefined); const remoteSeek = mock(() => undefined); const Root = ({ children, ...props }: { children?: ReactNode } & Record) => { @@ -12,7 +13,6 @@ const Root = ({ children, ...props }: { children?: ReactNode } & Record <>{children}; -mock.module("../src/lib/sabr-vidstack-bridge", () => ({ requestSabrSeek })); mock.module("../src/lib/vidstack", () => ({ TimeSlider: { Root, @@ -44,7 +44,12 @@ const video = { } as HTMLVideoElement; test("keeps the video SABR slider interactive while a seek is pending", () => { - requestSabrSeek.mockClear(); + sabrSeek.mockClear(); + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: sabrSeek, + }); renderToStaticMarkup(); expect(sliderProps.disabled).toBeUndefined(); @@ -54,11 +59,17 @@ test("keeps the video SABR slider interactive while a seek is pending", () => { const onDragEnd = sliderProps.onDragEnd as (percent: number) => void; onDragEnd(80); - expect(requestSabrSeek).toHaveBeenCalledWith(video, 480); + expect(sabrSeek).toHaveBeenCalledWith(480); + unregister(); }); test("keeps the audio SABR slider interactive while a seek is pending", () => { - requestSabrSeek.mockClear(); + sabrSeek.mockClear(); + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: sabrSeek, + }); renderToStaticMarkup(); expect(sliderProps.disabled).toBeUndefined(); @@ -68,6 +79,7 @@ test("keeps the audio SABR slider interactive while a seek is pending", () => { const onDragEnd = sliderProps.onDragEnd as (percent: number) => void; onDragEnd(20); - expect(requestSabrSeek).toHaveBeenCalledWith(video, 120); + expect(sabrSeek).toHaveBeenCalledWith(120); expect(remoteSeek).not.toHaveBeenCalled(); + unregister(); });