mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(react): add playback rate button component (#639)
This commit is contained in:
@@ -346,7 +346,9 @@
|
||||
/* Icon button variant */
|
||||
.media-minimal-skin .media-button--icon {
|
||||
display: grid;
|
||||
padding: 0.625rem;
|
||||
width: 2.375rem;
|
||||
padding: 0;
|
||||
aspect-ratio: 1;
|
||||
background: transparent;
|
||||
color: oklch(1 0 0);
|
||||
}
|
||||
@@ -367,6 +369,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback rate button */
|
||||
.media-minimal-skin .media-button--playback-rate {
|
||||
padding: 0;
|
||||
}
|
||||
.media-minimal-skin .media-button--playback-rate span {
|
||||
width: 4ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Icons
|
||||
========================================================================== */
|
||||
@@ -375,6 +386,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.media-minimal-skin .media-icon {
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { PlaybackRateButton } from '@/ui/playback-rate-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { MinimalVideoSkinProps } from './minimal-skin';
|
||||
@@ -53,14 +54,14 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
type="button"
|
||||
className={cn(
|
||||
// Shared
|
||||
'shrink-0 border-none cursor-pointer select-none',
|
||||
'items-center justify-center shrink-0 border-none cursor-pointer select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'transition-[background-color,color,outline-offset] duration-150 ease-out',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
|
||||
// Variant
|
||||
variant === 'icon'
|
||||
? cn(
|
||||
'grid p-2.5 bg-transparent rounded-md',
|
||||
'grid w-[2.375rem] aspect-square bg-transparent rounded-md',
|
||||
'text-white',
|
||||
'hover:text-white/80 hover:no-underline',
|
||||
'focus-visible:text-white/80',
|
||||
@@ -68,7 +69,7 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
'aria-expanded:text-white/80'
|
||||
)
|
||||
: cn(
|
||||
'flex items-center justify-center py-2 px-4 bg-white rounded-lg',
|
||||
'flex py-2 px-4 bg-white rounded-lg',
|
||||
'text-black font-medium',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2'
|
||||
),
|
||||
@@ -297,6 +298,14 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
</span>
|
||||
|
||||
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props} className="p-0">
|
||||
<span className="w-[4ch] tabular-nums">{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { PlaybackRateButton } from '@/ui/playback-rate-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { BaseSkinProps } from '../types';
|
||||
@@ -163,6 +164,14 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
</span>
|
||||
|
||||
<span className="media-button-group">
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon media-button--playback-rate">
|
||||
<span>{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
|
||||
@@ -358,7 +358,9 @@
|
||||
/* Icon button variant */
|
||||
.media-default-skin .media-button--icon {
|
||||
display: grid;
|
||||
padding: 0.5rem;
|
||||
width: 2.125rem;
|
||||
padding: 0;
|
||||
aspect-ratio: 1;
|
||||
background: transparent;
|
||||
color: oklch(1 0 0 / 0.9);
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
|
||||
@@ -381,6 +383,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback rate button */
|
||||
.media-default-skin .media-button--playback-rate {
|
||||
padding: 0;
|
||||
}
|
||||
.media-default-skin .media-button--playback-rate span {
|
||||
width: 4ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Icons
|
||||
========================================================================== */
|
||||
@@ -389,6 +400,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.media-default-skin .media-icon {
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { PlaybackRateButton } from '@/ui/playback-rate-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { VideoSkinProps } from './skin';
|
||||
@@ -66,14 +67,14 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
type="button"
|
||||
className={cn(
|
||||
// Shared
|
||||
'shrink-0 border-none cursor-pointer select-none',
|
||||
'items-center justify-center shrink-0 border-none cursor-pointer select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'transition-[background-color,color,outline-offset] duration-150 ease-out',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
|
||||
// Variant
|
||||
variant === 'icon'
|
||||
? cn(
|
||||
'grid p-2 bg-transparent rounded-full',
|
||||
'grid w-[2.125rem] aspect-square bg-transparent rounded-full',
|
||||
'text-white/90',
|
||||
'text-shadow-2xs text-shadow-black/25',
|
||||
'hover:bg-white/10 hover:text-white hover:no-underline',
|
||||
@@ -82,7 +83,7 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
'aria-expanded:bg-white/10 aria-expanded:text-white'
|
||||
)
|
||||
: cn(
|
||||
'flex items-center justify-center py-2 px-4 bg-white rounded-full',
|
||||
'flex py-2 px-4 bg-white rounded-full',
|
||||
'text-black font-medium',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2'
|
||||
),
|
||||
@@ -304,6 +305,14 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
|
||||
</Time.Group>
|
||||
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<span className="w-[4ch] tabular-nums">{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-button';
|
||||
import { PlayButton } from '@/ui/play-button';
|
||||
import { PlaybackRateButton } from '@/ui/playback-rate-button';
|
||||
import { SeekButton } from '@/ui/seek-button';
|
||||
import { Time } from '@/ui/time';
|
||||
import type { BaseSkinProps } from '../types';
|
||||
@@ -158,6 +159,14 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
<Time.Value type="duration" className="media-time__value" />
|
||||
</Time.Group>
|
||||
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon media-button--playback-rate">
|
||||
<span>{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PlaybackRateButton,
|
||||
type PlaybackRateButtonProps,
|
||||
type PlaybackRateButtonState,
|
||||
} from './playback-rate-button';
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import type { StateAttrMap } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useButton } from '../hooks/use-button';
|
||||
|
||||
const RATES = [0.5, 1, 1.2, 1.5, 1.7, 2] as const;
|
||||
|
||||
// FIXME: Replace with state/props from core.
|
||||
export type PlaybackRateButtonState = {
|
||||
rate: (typeof RATES)[number];
|
||||
};
|
||||
type PlaybackRateButtonCoreProps = {
|
||||
/** Custom label for the button. */
|
||||
label?: string | ((state: PlaybackRateButtonState) => string) | undefined;
|
||||
/** Whether the button is disabled. */
|
||||
disabled?: boolean | undefined;
|
||||
};
|
||||
const PlaybackRateButtonDataAttrs = {
|
||||
/** Present when the playback rate is active. */
|
||||
rate: 'data-playback-rate',
|
||||
} as const satisfies StateAttrMap<PlaybackRateButtonState>;
|
||||
|
||||
export interface PlaybackRateButtonProps
|
||||
extends UIComponentProps<'button', PlaybackRateButtonState>,
|
||||
PlaybackRateButtonCoreProps {}
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
/**
|
||||
* A button that toggles playback rate.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PlaybackRateButton />
|
||||
*
|
||||
* <PlaybackRateButton
|
||||
* render={(props, state) => (
|
||||
* <button {...props}>
|
||||
* {state.rate}×
|
||||
* </button>
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const PlaybackRateButton = forwardRef(function PlaybackRateButton(
|
||||
componentProps: PlaybackRateButtonProps,
|
||||
forwardedRef: ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
const { render, className, style, label, disabled, ...elementProps } = componentProps;
|
||||
|
||||
// FIXME: Replace with actual playback rate state
|
||||
const [rate, setRate] = useState<PlaybackRateButtonState['rate']>(1);
|
||||
|
||||
const { getButtonProps, buttonRef } = useButton({
|
||||
displayName: 'PlaybackRateButton',
|
||||
onActivate: () =>
|
||||
setRate((currentRate) => {
|
||||
// FIXME: Replace with actual toggle logic
|
||||
const rates: PlaybackRateButtonState['rate'][] = [...RATES];
|
||||
const currentIndex = rates.indexOf(currentRate);
|
||||
const nextIndex = (currentIndex + 1) % rates.length;
|
||||
return rates[nextIndex] ?? 1;
|
||||
}),
|
||||
isDisabled: () => disabled ?? false,
|
||||
});
|
||||
|
||||
if (!DEBUG) return null;
|
||||
|
||||
return renderElement(
|
||||
'button',
|
||||
{ render, className, style },
|
||||
{
|
||||
state: { rate }, // FIXME: Replace with actual toggle logic
|
||||
stateAttrMap: PlaybackRateButtonDataAttrs,
|
||||
ref: [forwardedRef, buttonRef],
|
||||
props: [elementProps, getButtonProps()],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace PlaybackRateButton {
|
||||
export type Props = PlaybackRateButtonProps;
|
||||
export type State = PlaybackRateButtonState;
|
||||
}
|
||||
Reference in New Issue
Block a user