diff --git a/packages/core/src/dom/ui/tests/transition.test.ts b/packages/core/src/dom/ui/tests/transition.test.ts index 7d8c30a8..b3f59909 100644 --- a/packages/core/src/dom/ui/tests/transition.test.ts +++ b/packages/core/src/dom/ui/tests/transition.test.ts @@ -29,6 +29,28 @@ describe('createTransition', () => { await promise; expect(handler.state.current).toEqual({ active: true, status: 'idle' }); }); + + it('cancels active animations and flushes styles when restarting an active transition', async () => { + const handler = createTransition(); + const el = document.createElement('div'); + const getOffsetHeight = vi.spyOn(el, 'offsetHeight', 'get'); + const cancel = vi.fn(); + Object.defineProperty(el, 'getAnimations', { + value: vi.fn(() => [{ cancel }]), + }); + + await handler.open(); + await vi.waitFor(() => { + expect(handler.state.current.status).toBe('idle'); + }); + + handler.open(el); + + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalled(); + }); + expect(getOffsetHeight).toHaveBeenCalled(); + }); }); describe('close', () => { diff --git a/packages/core/src/dom/ui/transition.ts b/packages/core/src/dom/ui/transition.ts index 242c62f9..1ba91560 100644 --- a/packages/core/src/dom/ui/transition.ts +++ b/packages/core/src/dom/ui/transition.ts @@ -4,7 +4,7 @@ import type { TransitionState } from '../../core/ui/transition'; export interface TransitionApi { state: State; - open(): Promise; + open(el?: HTMLElement | null): Promise; close(el: HTMLElement | null): Promise; cancel(): void; destroy(): void; @@ -15,7 +15,8 @@ export interface TransitionApi { * * **Open:** patches `{ active: true, status: 'starting' }`, then after a * double-RAF patches `{ status: 'idle' }` so the browser paints the - * initial ("from") state before transitioning. + * initial ("from") state before transitioning. Reopening an active transition + * flushes styles first so CSS transitions can restart. * * **Close:** patches `{ status: 'ending' }` (keeping `active: true` so the * element stays mounted), then after a double-RAF waits for @@ -28,17 +29,27 @@ export function createTransition(): TransitionApi { let rafId1 = 0; let rafId2 = 0; - function open(): Promise { + function open(el: HTMLElement | null = null): Promise { cancelAnimationFrame(rafId1); cancelAnimationFrame(rafId2); rafId1 = 0; rafId2 = 0; + const restarting = state.current.active; + + if (restarting) { + state.patch({ status: 'idle' }); + } + state.patch({ active: true, status: 'starting' }); return new Promise((resolve) => { rafId1 = requestAnimationFrame(() => { rafId1 = 0; + if (restarting) { + cancelAnimations(el); + flushStyles(el); + } rafId2 = requestAnimationFrame(() => { rafId2 = 0; if (destroyed || !state.current.active) return resolve(); @@ -96,6 +107,19 @@ export function createTransition(): TransitionApi { }; } +function flushStyles(el: HTMLElement | null): void { + if (!el) return; + void el.offsetHeight; +} + +function cancelAnimations(el: HTMLElement | null): void { + const animations = el?.getAnimations?.({ subtree: true }) ?? []; + + for (const animation of animations) { + animation.cancel(); + } +} + function waitForAnimations(el: HTMLElement | null): Promise { if (!el) return Promise.resolve(); diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index 72dacbe8..b95b00cd 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -9,6 +9,7 @@ import { iconFlipped, iconState, menu, + playButton, playbackRate, popup, root, @@ -48,15 +49,20 @@ function getTemplateHTML() {
- + + + ${renderIcon('spinner', { class: icon })} + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - + + + + diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 3899f5ad..08560616 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -31,15 +31,20 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} - ${renderIcon('play', { class: 'media-icon media-icon--play' })} - ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - - - - - + + + ${renderIcon('spinner', { class: 'media-icon' })} + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + + diff --git a/packages/html/src/define/audio/minimal-ui.ts b/packages/html/src/define/audio/minimal-ui.ts index f1cf0a8d..5a1d3250 100644 --- a/packages/html/src/define/audio/minimal-ui.ts +++ b/packages/html/src/define/audio/minimal-ui.ts @@ -2,6 +2,7 @@ // used by the minimal skin without creating a skin element. Use this entry // when building an ejected (light DOM) player layout. import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; @@ -32,6 +33,7 @@ defineTime(); defineMenu(); // Standalone elements. +safeDefine(BufferingIndicatorElement); safeDefine(HotkeyElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 0d683112..a846fd53 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -9,6 +9,7 @@ import { iconFlipped, iconState, menu, + playButton, playbackRate, popup, root, @@ -48,15 +49,20 @@ function getTemplateHTML() {
- + + + ${renderIcon('spinner', { class: icon })} + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - + + + + diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index ebc0718c..1cf9d510 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -31,15 +31,20 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} - ${renderIcon('play', { class: 'media-icon media-icon--play' })} - ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - - - - - + + + ${renderIcon('spinner', { class: 'media-icon' })} + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + + diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index 2e645c14..39e4e917 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -2,6 +2,7 @@ // without creating a skin element. Use this entry when building an ejected // (light DOM) player layout. import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; @@ -35,6 +36,7 @@ defineMenu(); // Standalone elements. safeDefine(GestureElement); safeDefine(HotkeyElement); +safeDefine(BufferingIndicatorElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts index c82093d3..2687c896 100644 --- a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts @@ -6,6 +6,7 @@ import { error, icon, iconState, + playButton, popup, root, slider, @@ -40,7 +41,11 @@ function getTemplateHTML() {
- + + + ${renderIcon('spinner', { class: icon })} + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} @@ -49,6 +54,7 @@ function getTemplateHTML() { +
diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts index d7c0ccf6..5973bec8 100644 --- a/packages/html/src/define/live-audio/minimal-skin.ts +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -29,15 +29,20 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} - ${renderIcon('play', { class: 'media-icon media-icon--play' })} - ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - - - - - + + + ${renderIcon('spinner', { class: 'media-icon' })} + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + +
diff --git a/packages/html/src/define/live-audio/minimal-ui.ts b/packages/html/src/define/live-audio/minimal-ui.ts index 40a90747..7b429948 100644 --- a/packages/html/src/define/live-audio/minimal-ui.ts +++ b/packages/html/src/define/live-audio/minimal-ui.ts @@ -3,6 +3,7 @@ // this entry when building an ejected (light DOM) player layout for live // HLS / DASH streams. import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; @@ -27,6 +28,7 @@ defineVolumeSlider(); defineTime(); // Standalone elements. +safeDefine(BufferingIndicatorElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 495c4468..0c530de2 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -6,6 +6,7 @@ import { error, icon, iconState, + playButton, popup, root, slider, @@ -40,15 +41,20 @@ function getTemplateHTML() {
- + + + ${renderIcon('spinner', { class: icon })} + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - + + + +
diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index 54206b55..4ac75d01 100644 --- a/packages/html/src/define/live-audio/skin.ts +++ b/packages/html/src/define/live-audio/skin.ts @@ -29,15 +29,20 @@ function getTemplateHTML() {
- - ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} - ${renderIcon('play', { class: 'media-icon media-icon--play' })} - ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - - - - - + + + ${renderIcon('spinner', { class: 'media-icon' })} + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + +
diff --git a/packages/html/src/define/live-audio/ui.ts b/packages/html/src/define/live-audio/ui.ts index 6854ffcd..e7945090 100644 --- a/packages/html/src/define/live-audio/ui.ts +++ b/packages/html/src/define/live-audio/ui.ts @@ -2,6 +2,7 @@ // elements without creating a skin element. Use this entry when building an // ejected (light DOM) player layout for live HLS / DASH streams. import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; @@ -30,6 +31,7 @@ defineTime(); // Standalone elements. safeDefine(GestureElement); safeDefine(HotkeyElement); +safeDefine(BufferingIndicatorElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index d89b9e2b..05c5c4b5 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -36,9 +36,7 @@ function getTemplateHTML() { -
- ${renderIcon('spinner')} -
+ ${renderIcon('spinner', { class: icon })}
diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 8991ee78..aa5ef218 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -19,9 +19,7 @@ function getTemplateHTML() { -
- ${renderIcon('spinner', { class: 'media-icon' })} -
+ ${renderIcon('spinner', { class: 'media-icon' })}
diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 25775475..10688540 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -44,9 +44,7 @@ function getTemplateHTML() { -
- ${renderIcon('spinner')} -
+ ${renderIcon('spinner', { class: icon })}
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index ead2f4db..ff73b799 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -21,9 +21,7 @@ function getTemplateHTML() { -
- ${renderIcon('spinner', { class: 'media-icon' })} -
+ ${renderIcon('spinner', { class: 'media-icon' })}
diff --git a/packages/html/src/ui/input-indicators/input-indicator-element.ts b/packages/html/src/ui/input-indicators/input-indicator-element.ts index b892abf5..55b97dde 100644 --- a/packages/html/src/ui/input-indicators/input-indicator-element.ts +++ b/packages/html/src/ui/input-indicators/input-indicator-element.ts @@ -29,10 +29,17 @@ export interface InputIndicatorCoreApi extends MediaElement { protected abstract get core(): InputIndicatorCoreApi; protected abstract get transition(): TransitionApi; protected abstract get liveIndicator(): LiveIndicator; + protected get options(): InputIndicatorOptions { + return {}; + } protected abstract syncCoreProps(): void; @@ -119,7 +126,12 @@ export abstract class InputIndicatorElement { @@ -26,6 +26,7 @@ export class VolumeIndicatorElement extends InputIndicatorElement
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
diff --git a/packages/react/src/presets/live-audio/minimal-skin.tsx b/packages/react/src/presets/live-audio/minimal-skin.tsx index 13dc27dd..2d55daf5 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tsx @@ -1,7 +1,16 @@ import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; -import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal'; +import { + PauseIcon, + PlayIcon, + RestartIcon, + SpinnerIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; +import { BufferingIndicator } from '@/ui/buffering-indicator'; import { ErrorDialog } from '@/ui/error-dialog'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; @@ -85,21 +94,30 @@ export function MinimalLiveAudioSkin(props: MinimalLiveAudioSkinProps): ReactNod
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
diff --git a/packages/react/src/presets/live-audio/skin.tailwind.tsx b/packages/react/src/presets/live-audio/skin.tailwind.tsx index 4f38e920..d3ac0052 100644 --- a/packages/react/src/presets/live-audio/skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/skin.tailwind.tsx @@ -5,14 +5,16 @@ import { error, icon, iconState, + playButton, popup, root, slider, } from '@videojs/skins/default/tailwind/audio.tailwind'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; -import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; +import { PauseIcon, PlayIcon, RestartIcon, SpinnerIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; import { Container, usePlayer } from '@/player/context'; +import { BufferingIndicator } from '@/ui/buffering-indicator'; import { ErrorDialog } from '@/ui/error-dialog'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; @@ -119,21 +121,30 @@ export function LiveAudioSkinTailwind(props: LiveAudioSkinProps): ReactNode {
- - }> - - - - - } + + ( +
+ +
+ )} /> - - - - -
+ + }> + + + + + } + /> + + + + + +
diff --git a/packages/react/src/presets/live-audio/skin.tsx b/packages/react/src/presets/live-audio/skin.tsx index bfceec27..9eb8a792 100644 --- a/packages/react/src/presets/live-audio/skin.tsx +++ b/packages/react/src/presets/live-audio/skin.tsx @@ -1,7 +1,8 @@ import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; -import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; +import { PauseIcon, PlayIcon, RestartIcon, SpinnerIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; import { Container, usePlayer } from '@/player/context'; +import { BufferingIndicator } from '@/ui/buffering-indicator'; import { ErrorDialog } from '@/ui/error-dialog'; import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; @@ -93,18 +94,27 @@ export function LiveAudioSkin(props: LiveAudioSkinProps): ReactNode {
- - }> - - - - - } + + ( +
+ +
+ )} /> - -
+ + }> + + + + + } + /> + + +
diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index 8d25ff0a..98dec51e 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -217,9 +217,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { (
-
- -
+
)} /> diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index 507a7df1..6caec37a 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -178,9 +178,7 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { (
-
- -
+
)} /> diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 8de60d0a..3398a273 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -425,9 +425,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { (
-
- -
+
)} /> diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 1447cae3..1502a8bb 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -360,9 +360,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { (
-
- -
+
)} /> diff --git a/packages/react/src/ui/input-indicators/tests/use-rendered-indicator-state.test.tsx b/packages/react/src/ui/input-indicators/tests/use-rendered-indicator-state.test.tsx new file mode 100644 index 00000000..adb787bf --- /dev/null +++ b/packages/react/src/ui/input-indicators/tests/use-rendered-indicator-state.test.tsx @@ -0,0 +1,99 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { IndicatorLifecycleState, TransitionState } from '@videojs/core'; +import { createState } from '@videojs/store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const transitionMock = vi.hoisted(() => ({ + createTransition: vi.fn(), +})); + +vi.mock('@videojs/core/dom', async (importOriginal) => ({ + ...(await importOriginal()), + createTransition: transitionMock.createTransition, +})); + +import { useRenderedIndicatorState } from '../use-rendered-indicator-state'; + +interface TestIndicatorState extends IndicatorLifecycleState { + value: string; +} + +function makeState(generation: number): TestIndicatorState { + return { + open: true, + generation, + value: String(generation), + transitionStarting: false, + transitionEnding: false, + }; +} + +function mockTransition() { + const state = createState({ active: false, status: 'idle' }); + const open = vi.fn(() => { + state.patch({ active: true, status: 'starting' }); + return Promise.resolve(); + }); + const close = vi.fn(() => Promise.resolve()); + const cancel = vi.fn(); + const destroy = vi.fn(); + + transitionMock.createTransition.mockReturnValue({ state, open, close, cancel, destroy }); + + return { state, open, close, cancel, destroy }; +} + +beforeEach(() => { + transitionMock.createTransition.mockReset(); +}); + +describe('useRenderedIndicatorState', () => { + it('replays the open transition on updates by default', async () => { + const transition = mockTransition(); + const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current), { + initialProps: { current: makeState(1) }, + }); + + await waitFor(() => { + expect(transition.open).toHaveBeenCalledTimes(1); + }); + + rerender({ current: makeState(2) }); + + await waitFor(() => { + expect(transition.open).toHaveBeenCalledTimes(2); + }); + }); + + it('skips replaying the open transition on updates when disabled', async () => { + const transition = mockTransition(); + const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), { + initialProps: { current: makeState(1) }, + }); + + await waitFor(() => { + expect(transition.open).toHaveBeenCalledTimes(1); + }); + + rerender({ current: makeState(2) }); + + expect(transition.open).toHaveBeenCalledTimes(1); + }); + + it('cancels an ending transition when replay is disabled and an update arrives', async () => { + const transition = mockTransition(); + const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), { + initialProps: { current: makeState(1) }, + }); + + await waitFor(() => { + expect(transition.open).toHaveBeenCalledTimes(1); + }); + + transition.state.patch({ active: true, status: 'ending' }); + rerender({ current: makeState(2) }); + + expect(transition.open).toHaveBeenCalledTimes(1); + expect(transition.cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/react/src/ui/input-indicators/use-input-indicator-root.ts b/packages/react/src/ui/input-indicators/use-input-indicator-root.ts index 70a99048..85fae6e8 100644 --- a/packages/react/src/ui/input-indicators/use-input-indicator-root.ts +++ b/packages/react/src/ui/input-indicators/use-input-indicator-root.ts @@ -7,7 +7,7 @@ import { useState, useSyncExternalStore } from 'react'; import { useDestroy } from '../../utils/use-destroy'; import { useIndicatorVisibility } from './use-indicator-visibility'; import { useInputActionSubscription } from './use-input-action-subscription'; -import { useRenderedIndicatorState } from './use-rendered-indicator-state'; +import { type RenderedIndicatorOptions, useRenderedIndicatorState } from './use-rendered-indicator-state'; interface InputIndicatorRootCore { readonly state: StoreState; @@ -19,7 +19,8 @@ interface InputIndicatorRootCore( createCore: () => InputIndicatorRootCore, - props: Props + props: Props, + options?: RenderedIndicatorOptions ) { const [core] = useState(createCore); useDestroy(core); @@ -36,5 +37,5 @@ export function useInputIndicatorRoot core.state.current ); - return useRenderedIndicatorState(currentState); + return useRenderedIndicatorState(currentState, options); } diff --git a/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts b/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts index caab2f52..020afc09 100644 --- a/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts +++ b/packages/react/src/ui/input-indicators/use-rendered-indicator-state.ts @@ -2,11 +2,18 @@ import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core'; import { createTransition } from '@videojs/core/dom'; -import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; import { useDestroy } from '../../utils/use-destroy'; -export function useRenderedIndicatorState(currentState: State) { +export interface RenderedIndicatorOptions { + replayOnUpdate?: boolean | undefined; +} + +export function useRenderedIndicatorState( + currentState: State, + options: RenderedIndicatorOptions = {} +) { const elementRef = useRef(null); const currentStateRef = useRef(currentState); const snapshotRef = useRef(currentState); @@ -22,13 +29,18 @@ export function useRenderedIndicatorState const { generation, open } = currentState; - useEffect(() => { + useLayoutEffect(() => { if (open) { const nextState = currentStateRef.current; if (nextState.generation !== generation) return; snapshotRef.current = nextState; - void transition.open(); + const transitionState = transition.state.current; + if (!transitionState.active || options.replayOnUpdate !== false) { + void transition.open(elementRef.current); + } else if (transitionState.status === 'ending') { + transition.cancel(); + } return; } @@ -36,7 +48,7 @@ export function useRenderedIndicatorState if (active && status !== 'ending') { void transition.close(elementRef.current); } - }, [generation, open, transition]); + }, [generation, open, options.replayOnUpdate, transition]); return { elementRef, diff --git a/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx b/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx index bd12a4de..7846f797 100644 --- a/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx +++ b/packages/react/src/ui/volume-indicator/volume-indicator-root.tsx @@ -18,7 +18,11 @@ export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot( forwardedRef: ForwardedRef ) { const { render, className, style, closeDelay, ...elementProps } = componentProps; - const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay }); + const { elementRef, present, state } = useInputIndicatorRoot( + () => new VolumeIndicatorCore(), + { closeDelay }, + { replayOnUpdate: false } + ); if (!present) return null; diff --git a/packages/skins/src/default/css/audio.css b/packages/skins/src/default/css/audio.css index 56abd887..3ad339fd 100644 --- a/packages/skins/src/default/css/audio.css +++ b/packages/skins/src/default/css/audio.css @@ -103,6 +103,26 @@ color: var(--media-text-color); } +/* ========================================================================== + Buffering (spinner over play button — overlay only, no layout change) + ========================================================================== */ + +.media-default-skin--audio .media-button--play__wrapper { + position: relative; + display: inline-flex; +} + +.media-default-skin--audio .media-button--play__wrapper .media-buffering-indicator { + color: inherit; +} + +.media-default-skin--audio + .media-button--play__wrapper:has(.media-buffering-indicator[data-visible]) + .media-button--play + .media-icon { + opacity: 0; +} + /* ========================================================================== Sliders ========================================================================== */ diff --git a/packages/skins/src/default/css/components/buffering.css b/packages/skins/src/default/css/components/buffering.css index 169cc195..e55484fe 100644 --- a/packages/skins/src/default/css/components/buffering.css +++ b/packages/skins/src/default/css/components/buffering.css @@ -5,9 +5,9 @@ .media-default-skin .media-buffering-indicator { position: absolute; inset: 0; + z-index: 10; display: none; - align-items: center; - justify-content: center; + place-content: center; color: oklch(1 0 0); pointer-events: none; @@ -16,11 +16,6 @@ } &[data-visible] { - display: flex; - } - - .media-surface { - padding: 0.25rem; - border-radius: 100%; + display: grid; } } diff --git a/packages/skins/src/default/css/components/input-feedback.css b/packages/skins/src/default/css/components/input-feedback.css index 08f02c5b..cf29b70e 100644 --- a/packages/skins/src/default/css/components/input-feedback.css +++ b/packages/skins/src/default/css/components/input-feedback.css @@ -105,124 +105,125 @@ border-radius: inherit; transition: --media-progress-fill 200ms linear; } + + &[data-level="high"] .media-icon--volume-high, + &[data-level="low"] .media-icon--volume-low, + &[data-level="off"] .media-icon--volume-off { + display: block; + } + + @media (prefers-reduced-motion: no-preference) { + &[data-min], + &[data-max] { + animation: media-shake 300ms ease-in-out; + } + } } -.media-default-skin .media-input-feedback-island--volume[data-level="high"] .media-icon--volume-high, -.media-default-skin .media-input-feedback-island--volume[data-level="low"] .media-icon--volume-low, -.media-default-skin .media-input-feedback-island--volume[data-level="off"] .media-icon--volume-off { - display: block; -} - -.media-default-skin .media-input-feedback-island--status[data-status="captions-on"] .media-icon--captions-on, -.media-default-skin .media-input-feedback-island--status[data-status="captions-off"] .media-icon--captions-off, -.media-default-skin .media-input-feedback-island--status[data-status="fullscreen"] .media-icon--fullscreen-enter, -.media-default-skin .media-input-feedback-island--status[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, -.media-default-skin .media-input-feedback-island--status[data-status="pip"] .media-icon--pip-enter, -.media-default-skin .media-input-feedback-island--status[data-status="exit-pip"] .media-icon--pip-exit { - display: block; -} - -/* --- Boundary shake ------------------------------------------------------- */ - -@media (prefers-reduced-motion: no-preference) { - .media-default-skin .media-input-feedback-island--volume[data-min], - .media-default-skin .media-input-feedback-island--volume[data-max] { - animation: media-shake 300ms ease-in-out; +.media-default-skin .media-input-feedback-island--status { + &[data-status="captions-on"] .media-icon--captions-on, + &[data-status="captions-off"] .media-icon--captions-off, + &[data-status="fullscreen"] .media-icon--fullscreen-enter, + &[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, + &[data-status="pip"] .media-icon--pip-enter, + &[data-status="exit-pip"] .media-icon--pip-exit { + display: block; } } /* --- Bubble ---------------------------------------------------------------- */ .media-default-skin .media-input-feedback-bubble { - display: flex; - flex-direction: column; + display: grid; grid-row: 1; - grid-column: 2; /* default to center for status bubbles and undirected seeks */ - align-items: center; - justify-content: center; - padding: 1rem; - transition: opacity 250ms ease-out; - - @container media-root (width > 24rem) { - padding: 2rem; - } - - &[data-starting-style], - &[data-ending-style] { - opacity: 0; - transition-timing-function: ease-in; - transition-duration: 200ms; - } -} - -/* Direction placement — seek bubbles move to the side implied by their direction. */ -.media-default-skin .media-input-feedback-bubble[data-direction="backward"] { - grid-column: 1; - justify-self: left; -} - -.media-default-skin .media-input-feedback-bubble:not([data-direction]) { grid-column: 2; - transition-timing-function: - ease-out, linear(0, 0.12 1.5%, 1.35 9.7%, 2.2 13.9%, 3 19.9%, 2.7 21.8%, 0.62 37.5%, 0.96 50.9%, 1); - transition-duration: 600ms; - transition-property: opacity, scale; + place-content: center; + padding: 1rem; + text-align: center; - @media (prefers-reduced-motion: reduce) { - transition: opacity 100ms ease-out; - } - - &[data-starting-style], - &[data-ending-style] { - opacity: 0; - scale: 0.8; - transition-timing-function: ease-in; + /* Central bubble (play, pause) */ + &:not([data-direction]) { + background: oklch(0 0 0 / 0.35); + border-radius: 100%; + backdrop-filter: blur(8px); + transition-timing-function: ease-out; transition-duration: 200ms; + transition-property: opacity, scale; + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + scale: 0.85; + } + + &[data-ending-style] { + transition-timing-function: ease-in; + transition-duration: 100ms; + } + + @media (prefers-reduced-motion: reduce) { + transition-duration: 50ms; + transition-property: opacity; + } } -} -.media-default-skin .media-input-feedback-bubble[data-direction="forward"] { - grid-column: 3; - justify-self: right; -} + /* Directional bubbles (seek) */ + &[data-direction] { + gap: 0.25rem; -/* --- Bubble icons ---------------------------------------------------------- */ + @container media-root (width > 24rem) { + padding: 1.5rem; + } + } -.media-default-skin .media-input-feedback-bubble .media-icon { - display: none; - width: 36px; - height: 36px; -} + &[data-direction="backward"] { + grid-column: 1; + justify-self: left; + } -/* seek: seek icon, flipped for backward */ -.media-default-skin .media-input-feedback-bubble[data-direction] .media-icon--seek { - display: block; -} + &[data-direction="forward"] { + grid-column: 3; + justify-self: right; + } -.media-default-skin .media-input-feedback-bubble[data-direction="backward"] .media-icon--seek { - transform: scaleX(-1); -} + /* Icons */ + .media-icon { + display: none; + width: calc(var(--media-icon-size) * 1.5); + height: calc(var(--media-icon-size) * 1.5); -@media (prefers-reduced-motion: no-preference) { - .media-default-skin - .media-input-feedback-bubble[data-direction="forward"]:not([data-starting-style]) + &.media-icon--play { + translate: 1px 0; + } + } + + &[data-direction] .media-icon--seek, + &[data-status="pause"] .media-icon--pause, + &[data-status="play"] .media-icon--play { + display: block; + } + + &[data-direction="backward"] .media-icon--seek { + scale: -1 1; + } + + @media (prefers-reduced-motion: no-preference) { .media-icon--seek { - animation: media-slide-in-forward 300ms ease-in-out; - } + transition-timing-function: ease-in-out; + transition-duration: 200ms; + transition-property: translate, opacity; + } - .media-default-skin - .media-input-feedback-bubble[data-direction="backward"]:not([data-starting-style]) - .media-icon--seek { - animation: media-slide-in-backward 300ms ease-in-out; - } + &[data-starting-style] .media-icon--seek, + &[data-ending-style] .media-icon--seek { + opacity: 0; + } - .media-default-skin .media-input-feedback-island--status[data-status]:not([data-starting-style]) .media-icon, - .media-default-skin .media-input-feedback-bubble[data-status]:not([data-starting-style]) .media-icon { - animation: media-pop-in 250ms ease-out; + &[data-direction="forward"][data-starting-style] .media-icon--seek { + translate: -60% 0; + } + &[data-direction="backward"][data-starting-style] .media-icon--seek { + translate: 60% 0; + } } } - -.media-default-skin .media-input-feedback-bubble[data-status="pause"] .media-icon--pause, -.media-default-skin .media-input-feedback-bubble[data-status="play"] .media-icon--play { - display: block; -} diff --git a/packages/skins/src/default/css/components/overlay.css b/packages/skins/src/default/css/components/overlay.css index fb2e0c25..d0abf00a 100644 --- a/packages/skins/src/default/css/components/overlay.css +++ b/packages/skins/src/default/css/components/overlay.css @@ -25,6 +25,12 @@ opacity: 1; } +.media-default-skin .media-buffering-indicator[data-visible] ~ .media-overlay { + background: oklch(0 0 0 / 0.35); + opacity: 1; + backdrop-filter: blur(8px); +} + .media-default-skin .media-error[data-open] ~ .media-overlay { backdrop-filter: blur(16px) saturate(1.5); } diff --git a/packages/skins/src/default/tailwind/audio.tailwind.ts b/packages/skins/src/default/tailwind/audio.tailwind.ts index 6f893477..e8ed93a9 100644 --- a/packages/skins/src/default/tailwind/audio.tailwind.ts +++ b/packages/skins/src/default/tailwind/audio.tailwind.ts @@ -1,5 +1,4 @@ import { cn } from '@videojs/utils/style'; -import { bufferingIndicator as baseBufferingIndicator } from './components/buffering'; import { controls as baseControls } from './components/controls'; import { error as baseError } from './components/error'; import { popup as basePopup } from './components/popup'; @@ -50,6 +49,18 @@ export const root = cn( export const controls = cn(baseControls, surface, 'text-(--media-text-color)', 'peer-data-open/error:**:invisible'); +export const playButton = { + wrapper: 'group/play inline-flex relative', + /** `peer/play-buffering` on `bufferingRoot`; merge onto the play trigger after the peer in DOM. */ + control: 'peer-data-visible/play-buffering:[&>svg]:opacity-0', + bufferingRoot: cn( + 'peer/play-buffering', + 'absolute inset-0 z-10 hidden place-content-center pointer-events-none text-inherit', + 'not-data-visible:[--media-spinner-animation:none]', + 'data-visible:grid' + ), +}; + /* ========================================================================== Sliders ========================================================================== */ @@ -69,15 +80,6 @@ export const popup = { tooltip: cn(surface, basePopup.tooltip), }; -/* ========================================================================== - Buffering (with audio surface) - ========================================================================== */ - -export const bufferingIndicator = { - ...baseBufferingIndicator, - container: cn(baseBufferingIndicator.container, surface), -}; - /* ========================================================================== Error (with audio surface) ========================================================================== */ @@ -104,6 +106,7 @@ export const error = { export { iconState } from '../../shared/tailwind/icon-state'; export { badge } from './components/badge'; +export { bufferingIndicator } from './components/buffering'; export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; diff --git a/packages/skins/src/default/tailwind/components/buffering.ts b/packages/skins/src/default/tailwind/components/buffering.ts index 8456873a..7eddf128 100644 --- a/packages/skins/src/default/tailwind/components/buffering.ts +++ b/packages/skins/src/default/tailwind/components/buffering.ts @@ -1,4 +1,3 @@ export const bufferingIndicator = { - root: 'absolute inset-0 hidden items-center justify-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:flex', - container: 'p-1 rounded-full', + root: 'peer/buffering absolute inset-0 z-10 hidden place-content-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:grid', }; diff --git a/packages/skins/src/default/tailwind/components/input-feedback.ts b/packages/skins/src/default/tailwind/components/input-feedback.ts index b2d41bb4..684bb42d 100644 --- a/packages/skins/src/default/tailwind/components/input-feedback.ts +++ b/packages/skins/src/default/tailwind/components/input-feedback.ts @@ -77,74 +77,54 @@ export const inputFeedback = { // Captions state → which icon shows shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block', shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block', - shownFullscreenEnter: cn( - 'group-data-[status=fullscreen]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in' - ), - shownFullscreenExit: cn( - 'group-data-[status=exit-fullscreen]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in' - ), - shownPipEnter: cn( - 'group-data-[status=pip]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in' - ), - shownPipExit: cn( - 'group-data-[status=exit-pip]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in' - ), + shownFullscreenEnter: 'group-data-[status=fullscreen]/input-indicator:block', + shownFullscreenExit: 'group-data-[status=exit-fullscreen]/input-indicator:block', + shownPipEnter: 'group-data-[status=pip]/input-indicator:block', + shownPipExit: 'group-data-[status=exit-pip]/input-indicator:block', value: 'ml-auto', }, bubble: { base: cn( 'group/input-indicator', - // Default placement — center column for status bubbles and undirected seeks + // Default placement 'col-start-2 row-start-1', - 'flex flex-col items-center justify-center p-4', - 'transition-opacity duration-250 ease-out', - 'data-starting-style:opacity-0', - 'data-ending-style:opacity-0', - 'data-starting-style:duration-200', - 'data-starting-style:ease-in', - 'data-ending-style:duration-200', - 'data-ending-style:ease-in', - '@2xl/media-root:p-8', + 'grid place-content-center text-center p-4', + 'data-direction:gap-1', + '@2xl/media-root:data-direction:p-6', + // Central bubble (play, pause) + 'not-data-direction:bg-black/35 not-data-direction:rounded-full not-data-direction:backdrop-blur-sm', 'not-data-direction:[transition-property:opacity,scale]', - 'not-data-direction:duration-600', - 'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]', + 'not-data-direction:duration-200 not-data-direction:ease-out', 'motion-reduce:not-data-direction:transition-opacity', - 'motion-reduce:not-data-direction:duration-100', - 'motion-reduce:not-data-direction:ease-out', - 'not-data-direction:data-starting-style:scale-80', - 'not-data-direction:data-ending-style:scale-80', - 'not-data-direction:data-starting-style:duration-200', - 'not-data-direction:data-starting-style:ease-in', - 'not-data-direction:data-ending-style:duration-200', + 'motion-reduce:not-data-direction:duration-50', + 'not-data-direction:data-starting-style:opacity-0', + 'not-data-direction:data-ending-style:opacity-0', + 'not-data-direction:data-starting-style:scale-[0.85]', + 'not-data-direction:data-ending-style:scale-[0.85]', + 'not-data-direction:data-ending-style:duration-100', 'not-data-direction:data-ending-style:ease-in', // Direction placement 'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start', 'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end' ), // Icons in the bubble - icon: 'hidden w-9 h-9', - // seek icon: shown for seekStep + seekToPercent; flipped for backward; slides in on active + icon: 'hidden size-[calc(var(--media-icon-size)*1.5)]', + // Seek icon: shown for seekStep + seekToPercent; flipped for backward. shownSeek: cn( 'group-data-direction/input-indicator:block', - 'group-data-[direction=backward]/input-indicator:-scale-x-100', - // Slide animation (keyframes registered in companion CSS) - 'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward', - 'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward', - 'motion-reduce:group-data-direction/input-indicator:animate-none' + 'group-data-[direction=backward]/input-indicator:[scale:-1_1]', + 'motion-safe:transition-[translate,opacity] motion-safe:duration-200 motion-safe:ease-in-out', + 'motion-safe:group-data-starting-style/input-indicator:opacity-0', + 'motion-safe:group-data-ending-style/input-indicator:opacity-0', + 'motion-safe:group-data-[direction=forward]/input-indicator:group-data-starting-style/input-indicator:[translate:-60%_0]', + 'motion-safe:group-data-[direction=backward]/input-indicator:group-data-starting-style/input-indicator:[translate:60%_0]' ), // togglePaused: pause icon when paused, play icon when playing - shownPause: cn( - 'group-data-[status=pause]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in' - ), + shownPause: 'group-data-[status=pause]/input-indicator:block', shownPlay: cn( 'group-data-[status=play]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in' + 'group-data-[status=play]/input-indicator:translate-x-px' ), time: 'tabular-nums', }, diff --git a/packages/skins/src/default/tailwind/components/overlay.ts b/packages/skins/src/default/tailwind/components/overlay.ts index 6411f4cd..e113b9a6 100644 --- a/packages/skins/src/default/tailwind/components/overlay.ts +++ b/packages/skins/src/default/tailwind/components/overlay.ts @@ -14,6 +14,10 @@ export const overlay = cn( 'ease-out', // Shown when controls visible 'peer-data-visible/controls:opacity-100', + // Shown when buffering visible + 'peer-data-visible/buffering:bg-black/35', + 'peer-data-visible/buffering:opacity-100', + 'peer-data-visible/buffering:backdrop-blur-sm', // Shown when error visible (+ blur) // Light DOM: peer/error is a direct sibling (React) 'peer-data-open/error:opacity-100', diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index 3b6493f3..e63c03c0 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -205,13 +205,10 @@ export const menu = { }; /* ========================================================================== - Buffering (with video surface) + Buffering ========================================================================== */ -export const bufferingIndicator = { - ...baseBufferingIndicator, - container: cn(baseBufferingIndicator.container, surface), -}; +export const bufferingIndicator = baseBufferingIndicator; /* ========================================================================== Error (with video surface) diff --git a/packages/skins/src/minimal/css/audio.css b/packages/skins/src/minimal/css/audio.css index 057b9fbd..1d931a08 100644 --- a/packages/skins/src/minimal/css/audio.css +++ b/packages/skins/src/minimal/css/audio.css @@ -108,6 +108,26 @@ box-shadow: 0 0 0 1px var(--media-controls-border-color); } +/* ========================================================================== + Buffering (spinner over play button — overlay only, no layout change) + ========================================================================== */ + +.media-minimal-skin--audio .media-button--play__wrapper { + position: relative; + display: inline-flex; +} + +.media-minimal-skin--audio .media-button--play__wrapper .media-buffering-indicator { + color: inherit; +} + +.media-minimal-skin--audio + .media-button--play__wrapper:has(.media-buffering-indicator[data-visible]) + .media-button--play + .media-icon { + opacity: 0; +} + /* ========================================================================== Popups & Animations ========================================================================== */ diff --git a/packages/skins/src/minimal/css/components/buffering.css b/packages/skins/src/minimal/css/components/buffering.css index 8849ca20..98596f1b 100644 --- a/packages/skins/src/minimal/css/components/buffering.css +++ b/packages/skins/src/minimal/css/components/buffering.css @@ -5,9 +5,9 @@ .media-minimal-skin .media-buffering-indicator { position: absolute; inset: 0; + z-index: 10; display: none; - align-items: center; - justify-content: center; + place-content: center; color: oklch(1 0 0); pointer-events: none; @@ -16,6 +16,6 @@ } &[data-visible] { - display: flex; + display: grid; } } diff --git a/packages/skins/src/minimal/css/components/input-feedback.css b/packages/skins/src/minimal/css/components/input-feedback.css index 1c48ab8d..b0a6a4fe 100644 --- a/packages/skins/src/minimal/css/components/input-feedback.css +++ b/packages/skins/src/minimal/css/components/input-feedback.css @@ -1,6 +1,6 @@ /* ========================================================================== - Input Feedback - ========================================================================== */ + Input Feedback + ========================================================================== */ .media-minimal-skin .media-input-feedback { position: absolute; @@ -116,123 +116,123 @@ border-radius: calc(Infinity * 1px); box-shadow: 0 1px 0 var(--media-current-shadow-color-subtle); } + + &[data-level="high"] .media-icon--volume-high, + &[data-level="low"] .media-icon--volume-low, + &[data-level="off"] .media-icon--volume-off { + display: block; + } + + @media (prefers-reduced-motion: no-preference) { + &[data-min] .media-input-feedback-island__content, + &[data-max] .media-input-feedback-island__content { + animation: media-shake 300ms ease-in-out; + } + } } -.media-minimal-skin .media-input-feedback-island--volume[data-level="high"] .media-icon--volume-high, -.media-minimal-skin .media-input-feedback-island--volume[data-level="low"] .media-icon--volume-low, -.media-minimal-skin .media-input-feedback-island--volume[data-level="off"] .media-icon--volume-off { - display: block; -} - -.media-minimal-skin .media-input-feedback-island--status[data-status="captions-on"] .media-icon--captions-on, -.media-minimal-skin .media-input-feedback-island--status[data-status="captions-off"] .media-icon--captions-off, -.media-minimal-skin .media-input-feedback-island--status[data-status="fullscreen"] .media-icon--fullscreen-enter, -.media-minimal-skin .media-input-feedback-island--status[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, -.media-minimal-skin .media-input-feedback-island--status[data-status="pip"] .media-icon--pip-enter, -.media-minimal-skin .media-input-feedback-island--status[data-status="exit-pip"] .media-icon--pip-exit { - display: block; -} - -/* --- Boundary shake ------------------------------------------------------- */ - -@media (prefers-reduced-motion: no-preference) { - .media-minimal-skin .media-input-feedback-island--volume[data-min] .media-input-feedback-island__content, - .media-minimal-skin .media-input-feedback-island--volume[data-max] .media-input-feedback-island__content { - animation: media-shake 300ms ease-in-out; +.media-minimal-skin .media-input-feedback-island--status { + &[data-status="captions-on"] .media-icon--captions-on, + &[data-status="captions-off"] .media-icon--captions-off, + &[data-status="fullscreen"] .media-icon--fullscreen-enter, + &[data-status="exit-fullscreen"] .media-icon--fullscreen-exit, + &[data-status="pip"] .media-icon--pip-enter, + &[data-status="exit-pip"] .media-icon--pip-exit { + display: block; } } /* --- Bubble ---------------------------------------------------------------- */ .media-minimal-skin .media-input-feedback-bubble { - display: flex; - flex-direction: column; + display: grid; grid-row: 1; - grid-column: 2; /* default to center for status bubbles and undirected seeks */ - align-items: center; - justify-content: center; - padding: 1rem; - transition: opacity 250ms ease-out; - - @container media-root (width > 24rem) { - padding: 2rem; - } - - &[data-starting-style], - &[data-ending-style] { - opacity: 0; - transition-timing-function: ease-in; - transition-duration: 200ms; - } -} -/* Direction placement — seek bubbles move to the side implied by their direction. */ -.media-minimal-skin .media-input-feedback-bubble[data-direction="backward"] { - grid-column: 1; - justify-self: left; -} - -.media-minimal-skin .media-input-feedback-bubble:not([data-direction]) { grid-column: 2; - transition-timing-function: - ease-out, linear(0, 0.12 1.5%, 1.35 9.7%, 2.2 13.9%, 3 19.9%, 2.7 21.8%, 0.62 37.5%, 0.96 50.9%, 1); - transition-duration: 600ms; - transition-property: opacity, scale; + place-content: center; + padding: 1rem; + text-align: center; - @media (prefers-reduced-motion: reduce) { - transition: opacity 100ms ease-out; - } - - &[data-starting-style], - &[data-ending-style] { - opacity: 0; - scale: 0.8; - transition-timing-function: ease-in; + /* Central bubble (play, pause) */ + &:not([data-direction]) { + transition-timing-function: ease-out; transition-duration: 200ms; + transition-property: opacity, scale; + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + scale: 0.85; + } + + &[data-ending-style] { + transition-timing-function: ease-in; + transition-duration: 100ms; + } + + @media (prefers-reduced-motion: reduce) { + transition-duration: 50ms; + transition-property: opacity; + } } -} -.media-minimal-skin .media-input-feedback-bubble[data-direction="forward"] { - grid-column: 3; - justify-self: right; -} + /* Directional bubbles (seek) */ + &[data-direction] { + gap: 0.25rem; -/* --- Bubble icons ---------------------------------------------------------- */ + @container media-root (width > 24rem) { + padding: 1.5rem; + } + } -.media-minimal-skin .media-input-feedback-bubble .media-icon { - display: none; - width: 36px; - height: 36px; -} + &[data-direction="backward"] { + grid-column: 1; + justify-self: left; + } -/* seek: seek icon, flipped for backward */ -.media-minimal-skin .media-input-feedback-bubble[data-direction] .media-icon--seek { - display: block; -} + &[data-direction="forward"] { + grid-column: 3; + justify-self: right; + } -.media-minimal-skin .media-input-feedback-bubble[data-direction="backward"] .media-icon--seek { - transform: scaleX(-1); -} + /* Icons */ + .media-icon { + display: none; + width: calc(var(--media-icon-size) * 2); + height: calc(var(--media-icon-size) * 2); + } -@media (prefers-reduced-motion: no-preference) { - .media-minimal-skin - .media-input-feedback-bubble[data-direction="forward"]:not([data-starting-style]) + .media-icon--seek { + width: calc(var(--media-icon-size) * 1.5); + height: calc(var(--media-icon-size) * 1.5); + } + + &[data-direction] .media-icon--seek, + &[data-status="pause"] .media-icon--pause, + &[data-status="play"] .media-icon--play { + display: block; + } + + &[data-direction="backward"] .media-icon--seek { + scale: -1 1; + } + + @media (prefers-reduced-motion: no-preference) { .media-icon--seek { - animation: media-slide-in-forward 300ms ease-in-out; - } + transition-timing-function: ease-in-out; + transition-duration: 200ms; + transition-property: translate, opacity; + } - .media-minimal-skin - .media-input-feedback-bubble[data-direction="backward"]:not([data-starting-style]) - .media-icon--seek { - animation: media-slide-in-backward 300ms ease-in-out; - } + &[data-starting-style] .media-icon--seek, + &[data-ending-style] .media-icon--seek { + opacity: 0; + } - .media-minimal-skin .media-input-feedback-island--status[data-status]:not([data-starting-style]) .media-icon, - .media-minimal-skin .media-input-feedback-bubble[data-status]:not([data-starting-style]) .media-icon { - animation: media-pop-in 250ms ease-out; + &[data-direction="forward"][data-starting-style] .media-icon--seek { + translate: -60% 0; + } + &[data-direction="backward"][data-starting-style] .media-icon--seek { + translate: 60% 0; + } } } - -.media-minimal-skin .media-input-feedback-bubble[data-status="pause"] .media-icon--pause, -.media-minimal-skin .media-input-feedback-bubble[data-status="play"] .media-icon--play { - display: block; -} diff --git a/packages/skins/src/minimal/css/components/overlay.css b/packages/skins/src/minimal/css/components/overlay.css index 0dd4d058..b04dc487 100644 --- a/packages/skins/src/minimal/css/components/overlay.css +++ b/packages/skins/src/minimal/css/components/overlay.css @@ -25,6 +25,12 @@ opacity: 1; } +.media-minimal-skin .media-buffering-indicator[data-visible] ~ .media-overlay { + background: oklch(0 0 0 / 0.35); + opacity: 1; + backdrop-filter: blur(8px); +} + .media-minimal-skin .media-error[data-open] ~ .media-overlay { backdrop-filter: blur(16px) saturate(1.2); } diff --git a/packages/skins/src/minimal/tailwind/audio.tailwind.ts b/packages/skins/src/minimal/tailwind/audio.tailwind.ts index 337dbd86..f9389748 100644 --- a/packages/skins/src/minimal/tailwind/audio.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/audio.tailwind.ts @@ -58,6 +58,18 @@ export const controls = cn( 'ring-1 ring-(color:--media-controls-border-color)' ); +export const playButton = { + wrapper: 'group/play inline-flex relative', + /** `peer/play-buffering` on `bufferingRoot`; merge onto the play trigger after the peer in DOM. */ + control: 'peer-data-visible/play-buffering:[&>svg]:opacity-0', + bufferingRoot: cn( + 'peer/play-buffering', + 'absolute inset-0 z-10 hidden place-content-center pointer-events-none text-inherit', + 'not-data-visible:[--media-spinner-animation:none]', + 'data-visible:grid' + ), +}; + /* ========================================================================== Popup ========================================================================== */ diff --git a/packages/skins/src/minimal/tailwind/components/buffering.ts b/packages/skins/src/minimal/tailwind/components/buffering.ts index c3852bb8..b102d717 100644 --- a/packages/skins/src/minimal/tailwind/components/buffering.ts +++ b/packages/skins/src/minimal/tailwind/components/buffering.ts @@ -1,2 +1,2 @@ export const bufferingIndicator = - 'absolute inset-0 hidden items-center justify-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:flex'; + 'peer/buffering absolute inset-0 z-10 hidden place-content-center pointer-events-none text-white not-data-visible:[--media-spinner-animation:none] data-visible:grid'; diff --git a/packages/skins/src/minimal/tailwind/components/input-feedback.ts b/packages/skins/src/minimal/tailwind/components/input-feedback.ts index 170d4beb..4cf2d32a 100644 --- a/packages/skins/src/minimal/tailwind/components/input-feedback.ts +++ b/packages/skins/src/minimal/tailwind/components/input-feedback.ts @@ -83,72 +83,50 @@ export const inputFeedback = { shownVolumeOff: 'group-data-[level=off]/input-indicator:block', shownCaptionsOn: 'group-data-[status=captions-on]/input-indicator:block', shownCaptionsOff: 'group-data-[status=captions-off]/input-indicator:block', - shownFullscreenEnter: cn( - 'group-data-[status=fullscreen]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=fullscreen]/input-indicator:animate-media-pop-in' - ), - shownFullscreenExit: cn( - 'group-data-[status=exit-fullscreen]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-fullscreen]/input-indicator:animate-media-pop-in' - ), - shownPipEnter: cn( - 'group-data-[status=pip]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pip]/input-indicator:animate-media-pop-in' - ), - shownPipExit: cn( - 'group-data-[status=exit-pip]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=exit-pip]/input-indicator:animate-media-pop-in' - ), + shownFullscreenEnter: 'group-data-[status=fullscreen]/input-indicator:block', + shownFullscreenExit: 'group-data-[status=exit-fullscreen]/input-indicator:block', + shownPipEnter: 'group-data-[status=pip]/input-indicator:block', + shownPipExit: 'group-data-[status=exit-pip]/input-indicator:block', value: 'ml-auto', }, bubble: { base: cn( 'group/input-indicator', - // Default placement — center column for status bubbles and undirected seeks + // Default placement 'col-start-2 row-start-1', - 'flex flex-col items-center justify-center p-4', - 'transition-opacity duration-250 ease-out', - 'data-starting-style:opacity-0', - 'data-ending-style:opacity-0', - 'data-starting-style:duration-200', - 'data-starting-style:ease-in', - 'data-ending-style:duration-200', - 'data-ending-style:ease-in', - '@2xl/media-root:p-8', + 'grid place-content-center text-center p-4', + 'data-direction:gap-1', + '@2xl/media-root:data-direction:p-6', + // Central bubble (play, pause) 'not-data-direction:[transition-property:opacity,scale]', - 'not-data-direction:duration-600', - 'not-data-direction:[transition-timing-function:ease-out,linear(0,0.12_1.5%,1.35_9.7%,2.2_13.9%,3_19.9%,2.7_21.8%,0.62_37.5%,0.96_50.9%,1)]', + 'not-data-direction:duration-200 not-data-direction:ease-out', 'motion-reduce:not-data-direction:transition-opacity', - 'motion-reduce:not-data-direction:duration-100', - 'motion-reduce:not-data-direction:ease-out', - 'not-data-direction:data-starting-style:scale-80', - 'not-data-direction:data-ending-style:scale-80', - 'not-data-direction:data-starting-style:duration-200', - 'not-data-direction:data-starting-style:ease-in', - 'not-data-direction:data-ending-style:duration-200', + 'motion-reduce:not-data-direction:duration-50', + 'not-data-direction:data-starting-style:opacity-0', + 'not-data-direction:data-ending-style:opacity-0', + 'not-data-direction:data-starting-style:scale-[0.85]', + 'not-data-direction:data-ending-style:scale-[0.85]', + 'not-data-direction:data-ending-style:duration-100', 'not-data-direction:data-ending-style:ease-in', // Direction placement 'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start', 'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end' ), - icon: 'hidden w-9 h-9', + icon: 'hidden size-[calc(var(--media-icon-size)*2)]', shownSeek: cn( + // Avoid two arbitrary `size-*` utilities on one element (order-dependent); mirror CSS `[data-direction] .media-icon--seek` specificity. + 'group-data-direction/input-indicator:size-[calc(var(--media-icon-size)*1.5)]', 'group-data-direction/input-indicator:block', - 'group-data-[direction=backward]/input-indicator:-scale-x-100', - // Slide animation (keyframes registered in companion CSS) - 'group-not-data-starting-style/input-indicator:group-data-[direction=forward]/input-indicator:animate-media-slide-in-forward', - 'group-not-data-starting-style/input-indicator:group-data-[direction=backward]/input-indicator:animate-media-slide-in-backward', - 'motion-reduce:group-data-direction/input-indicator:animate-none' - ), - shownPause: cn( - 'group-data-[status=pause]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=pause]/input-indicator:animate-media-pop-in' - ), - shownPlay: cn( - 'group-data-[status=play]/input-indicator:block', - 'motion-safe:group-not-data-starting-style/input-indicator:group-data-[status=play]/input-indicator:animate-media-pop-in' + 'group-data-[direction=backward]/input-indicator:[scale:-1_1]', + 'motion-safe:transition-[translate,opacity] motion-safe:duration-200 motion-safe:ease-in-out', + 'motion-safe:group-data-starting-style/input-indicator:opacity-0', + 'motion-safe:group-data-ending-style/input-indicator:opacity-0', + 'motion-safe:group-data-[direction=forward]/input-indicator:group-data-starting-style/input-indicator:[translate:-60%_0]', + 'motion-safe:group-data-[direction=backward]/input-indicator:group-data-starting-style/input-indicator:[translate:60%_0]' ), + shownPause: 'group-data-[status=pause]/input-indicator:block', + shownPlay: 'group-data-[status=play]/input-indicator:block', time: 'tabular-nums', }, }; diff --git a/packages/skins/src/minimal/tailwind/components/overlay.ts b/packages/skins/src/minimal/tailwind/components/overlay.ts index 5884d680..2d23c0b1 100644 --- a/packages/skins/src/minimal/tailwind/components/overlay.ts +++ b/packages/skins/src/minimal/tailwind/components/overlay.ts @@ -14,6 +14,10 @@ export const overlay = cn( 'ease-out', // Shown when controls visible 'peer-data-visible/controls:opacity-100', + // Shown when buffering visible + 'peer-data-visible/buffering:bg-black/35', + 'peer-data-visible/buffering:opacity-100', + 'peer-data-visible/buffering:backdrop-blur-sm', // Shown when error visible (+ blur) // Light DOM: peer/error is a direct sibling (React) 'peer-data-open/error:opacity-100', diff --git a/packages/skins/src/shared/global/video/keyframes.css b/packages/skins/src/shared/global/video/keyframes.css index f7b78787..1fe49a86 100644 --- a/packages/skins/src/shared/global/video/keyframes.css +++ b/packages/skins/src/shared/global/video/keyframes.css @@ -20,24 +20,3 @@ translate: 1px 0; } } - -@keyframes media-slide-in-forward { - from { - translate: -60% 0; - opacity: 0; - } -} - -@keyframes media-slide-in-backward { - from { - translate: 60% 0; - opacity: 0; - } -} - -@keyframes media-pop-in { - from { - scale: 0.8; - opacity: 0; - } -} diff --git a/packages/skins/src/shared/tailwind.css b/packages/skins/src/shared/tailwind.css index abe3cc02..f2235be1 100644 --- a/packages/skins/src/shared/tailwind.css +++ b/packages/skins/src/shared/tailwind.css @@ -7,7 +7,4 @@ @theme { --animate-media-shake: media-shake 300ms ease-in-out; - --animate-media-slide-in-forward: media-slide-in-forward 300ms ease-in-out; - --animate-media-slide-in-backward: media-slide-in-backward 300ms ease-in-out; - --animate-media-pop-in: media-pop-in 250ms ease-out; }