diff --git a/.changeset/skip-buttons.md b/.changeset/skip-buttons.md new file mode 100644 index 0000000..166030f --- /dev/null +++ b/.changeset/skip-buttons.md @@ -0,0 +1,5 @@ +--- +"@react-av/controls": minor +--- + +Add `SkipBack` and `SkipForward` buttons that move playback back or forward by 10 seconds, or by a custom `seconds` step, without going past the start or end of the media. diff --git a/README.md b/README.md index 5cdbaaf..1a3dd38 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ function PlayButton() { | Package | Description | | --- | --- | | [`@react-av/core`](./packages/core) | Media root, video/audio elements, viewport, and state hooks. | -| [`@react-av/controls`](./packages/controls) | Play/pause, mute, loop, fullscreen, picture-in-picture, and timestamp controls. | +| [`@react-av/controls`](./packages/controls) | Play/pause, skip back/forward, mute, loop, fullscreen, picture-in-picture, and timestamp controls. | | [`@react-av/sliders`](./packages/sliders) | Seek and volume sliders built on Radix UI. | | [`@react-av/vtt-core`](./packages/vtt-core) | Standalone W3C WebVTT parser and renderer. | | [`@react-av/vtt`](./packages/vtt) | WebVTT tracks, cues, and caption overlays for React AV. | diff --git a/apps/docs/src/demos/PlayerDemo.tsx b/apps/docs/src/demos/PlayerDemo.tsx index c213ce2..a62b5f4 100644 --- a/apps/docs/src/demos/PlayerDemo.tsx +++ b/apps/docs/src/demos/PlayerDemo.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import * as Media from "@react-av/core"; -import { Fullscreen, Mute, PictureInPicture, PlayPause, Timestamp, toTimestampString } from "@react-av/controls"; +import { Fullscreen, Mute, PictureInPicture, PlayPause, SkipBack, SkipForward, Timestamp, toTimestampString } from "@react-av/controls"; import { ProgressBarBufferedRanges, ProgressBarRoot, ProgressBarTooltip, VolumeRoot, useMediaProgressBarTooltip } from "@react-av/sliders"; import { InterfaceOverlay, Track } from "@react-av/vtt"; import * as Slider from "@radix-ui/react-slider"; -import { ArrowsIn, ArrowsOut, CircleNotch, Pause, PictureInPicture as PipIcon, Play, SpeakerHigh, SpeakerLow, SpeakerNone, SpeakerX } from "@phosphor-icons/react"; +import { ArrowClockwise, ArrowCounterClockwise, ArrowsIn, ArrowsOut, CircleNotch, Pause, PictureInPicture as PipIcon, Play, SpeakerHigh, SpeakerLow, SpeakerNone, SpeakerX } from "@phosphor-icons/react"; import PlayerDemoHeader, { type PlayerSkin } from "./PlayerDemoHeader"; const CONTAINER_CLASSES: Record = { @@ -32,12 +32,14 @@ function InkControls() { return
+ } /> } pauseIcon={} loadingIcon={} /> + } /> / diff --git a/apps/docs/src/pages/en/controls.md b/apps/docs/src/pages/en/controls.md index ab285e7..218144f 100644 --- a/apps/docs/src/pages/en/controls.md +++ b/apps/docs/src/pages/en/controls.md @@ -1,6 +1,6 @@ --- title: React Media Player Controls -description: Add play and pause, mute, looping, fullscreen, picture-in-picture, and timestamps to a React video or audio player with custom icons and styles. +description: Add play and pause, skip back and forward, mute, looping, fullscreen, picture-in-picture, and timestamps to a React video or audio player with custom icons and styles. layout: ../../layouts/MainLayout.astro --- @@ -51,6 +51,36 @@ import * as Controls from '@react-av/controls'; ) ``` +## Controls.SkipBack and Controls.SkipForward + +The `Controls.SkipBack` and `Controls.SkipForward` components are buttons that move playback back or forward by a fixed number of seconds, without going past the start or end of the media. Pressing them before the media's duration is known does nothing. + +They are `HTMLButtonElement`s and accept all props that a `button` element accepts. + +Additionally, they accept the following props: + +- `seconds` - How many seconds to move by. Defaults to `10`. The accessible labels follow the step, for example "Back 10 seconds" and "Forward 30 seconds". +- `icon` - The icon to use. Defaults to the `ArrowCounterClockwise` icon for `Controls.SkipBack` and the `ArrowClockwise` icon for `Controls.SkipForward` from the [Phosphor Icons](https://phosphoricons.com/) library. +- `defaultIconSize` - Size of the default icon if you are using the default icon from the [Phosphor Icons](https://phosphoricons.com/) library. Defaults to `32`. + +```jsx +import * as Media from '@react-av/core'; +import * as Controls from '@react-av/controls'; + +() => ( + + + + + + + + + + +) +``` + ## Controls.PictureInPicture The `Controls.PictureInPicture` component is a button that toggles the media player between picture-in-picture and normal states. diff --git a/e2e/skip.test.tsx b/e2e/skip.test.tsx new file mode 100644 index 0000000..a3abd0f --- /dev/null +++ b/e2e/skip.test.tsx @@ -0,0 +1,57 @@ +import { SkipBack, SkipForward, Timestamp } from "@react-av/controls"; +import * as Media from "@react-av/core"; +import { StrictMode } from "react"; +import { expect, test } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import clip from "./fixtures/clip.webm"; + +async function renderSkipPlayer(videoProps: Media.VideoProps = {}) { + const screen = await render( + + + + + + + + + + + + , + ); + return screen.container.querySelector("video")!; +} + +test("the skip buttons move by their step and stop at the start and end", async () => { + const video = await renderSkipPlayer(); + await expect.poll(() => video.duration).toBe(4); + const back = page.getByRole("button", { name: "Back 3 seconds" }); + const forward = page.getByRole("button", { name: "Forward 3 seconds" }); + + await userEvent.click(forward); + await expect.element(page.getByText("00:03")).toBeVisible(); + expect(video.currentTime).toBe(3); + + await userEvent.click(forward); + await expect.element(page.getByText("00:04")).toBeVisible(); + expect(video.currentTime).toBe(4); + await expect.poll(() => video.seeking).toBe(false); + + await userEvent.click(back); + await expect.element(page.getByText("00:01")).toBeVisible(); + expect(video.currentTime).toBe(1); + + await userEvent.click(back); + await expect.element(page.getByText("00:00")).toBeVisible(); + expect(video.currentTime).toBe(0); +}); + +test("the skip buttons do nothing before the media has loaded", async () => { + const video = await renderSkipPlayer({ preload: "none" }); + + await userEvent.click(page.getByRole("button", { name: "Forward 3 seconds" })); + expect(video.currentTime).toBe(0); + expect(video.readyState).toBe(HTMLMediaElement.HAVE_NOTHING); +}); diff --git a/packages/controls/src/Skip.tsx b/packages/controls/src/Skip.tsx new file mode 100644 index 0000000..ecfcab0 --- /dev/null +++ b/packages/controls/src/Skip.tsx @@ -0,0 +1,59 @@ +import { ArrowClockwise, ArrowCounterClockwise } from '@phosphor-icons/react'; +import React, { ComponentPropsWithoutRef, forwardRef, RefAttributes } from 'react'; +import { useMediaStore } from '@react-av/core'; + +export type SkipProps = ComponentPropsWithoutRef<'button'> & { + seconds?: number; + icon?: React.ReactNode; + defaultIconSize?: number; +} + +function useSkip(offsetSeconds: number) { + const store = useMediaStore(); + + return function skip() { + const element = store.mediaElement.getState(); + if (!element) return; + store.currentTime.setState(element.currentTime + offsetSeconds); + }; +} + +export const SkipBack: React.ForwardRefExoticComponent> = forwardRef(function SkipBack(props, ref) { + const { + seconds = 10, + defaultIconSize = 32, + icon = , + ...btnProps + } = props; + const skipBack = useSkip(-seconds); + + return +}); + +export const SkipForward: React.ForwardRefExoticComponent> = forwardRef(function SkipForward(props, ref) { + const { + seconds = 10, + defaultIconSize = 32, + icon = , + ...btnProps + } = props; + const skipForward = useSkip(seconds); + + return +}); diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index da66562..e6be3c7 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -3,5 +3,6 @@ export {default as Loop} from './Loop'; export {default as Mute} from './Mute'; export {default as PictureInPicture} from './PictureInPicture'; export {default as PlayPause} from './PlayPause'; +export * from './Skip'; export {default as Timestamp} from './Timestamp'; export * from './Timestamp';