diff --git a/apps/web/src/components/audio-time-slider.tsx b/apps/web/src/components/audio-time-slider.tsx index 0672e95..acf04bd 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 a4991ae..ec395e6 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 15d494a..5a2e262 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 3e05c5f..3a291d9 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 0000000..fbab869 --- /dev/null +++ b/apps/web/tests/sabr-time-slider.test.tsx @@ -0,0 +1,85 @@ +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 sabrSeek = mock(() => undefined); +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/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", () => { + sabrSeek.mockClear(); + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: sabrSeek, + }); + 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(sabrSeek).toHaveBeenCalledWith(480); + unregister(); +}); + +test("keeps the audio SABR slider interactive while a seek is pending", () => { + sabrSeek.mockClear(); + const unregister = registerSabrVidstackControls(video, { + play: async () => {}, + pause: () => {}, + seek: sabrSeek, + }); + 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(sabrSeek).toHaveBeenCalledWith(120); + expect(remoteSeek).not.toHaveBeenCalled(); + unregister(); +});