mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(skin): implement default and minimal skins for HTML player (#698)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9a5dfab4e8
commit
c5cafae57f
@@ -89,6 +89,8 @@
|
||||
"dependencies": {
|
||||
"@videojs/core": "workspace:*",
|
||||
"@videojs/element": "workspace:*",
|
||||
"@videojs/icons": "workspace:*",
|
||||
"@videojs/skins": "workspace:*",
|
||||
"@videojs/store": "workspace:*",
|
||||
"@videojs/utils": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.media-minimal-skin {
|
||||
color: var(--media-color, red);
|
||||
}
|
||||
|
||||
audio-player {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
audio-minimal-skin {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `<div></div>`;
|
||||
}
|
||||
|
||||
export class MinimalAudioSkinElement extends ReactiveElement {
|
||||
static readonly tagName = 'audio-minimal-skin';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const children = [...this.childNodes];
|
||||
this.innerHTML = getTemplateHTML();
|
||||
const container = this.firstElementChild;
|
||||
if (container) for (const child of children) container.append(child);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(MinimalAudioSkinElement.tagName, MinimalAudioSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[MinimalAudioSkinElement.tagName]: MinimalAudioSkinElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "@videojs/skins/audio/default.css";
|
||||
|
||||
audio-player {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
audio-skin {
|
||||
display: contents;
|
||||
}
|
||||
@@ -1 +1,26 @@
|
||||
// TODO: Implement AudioSkinElement and then register it here
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `<div></div>`;
|
||||
}
|
||||
|
||||
export class AudioSkinElement extends ReactiveElement {
|
||||
static readonly tagName = 'audio-skin';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const children = [...this.childNodes];
|
||||
this.innerHTML = getTemplateHTML();
|
||||
const container = this.firstElementChild;
|
||||
if (container) for (const child of children) container.append(child);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(AudioSkinElement.tagName, AudioSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AudioSkinElement.tagName]: AudioSkinElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactiveElement } from '@videojs/element';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
/**
|
||||
* Mixin for skin elements that renders the template from a static
|
||||
* `getTemplateHTML` method and resolves `<slot name="media">` placeholders
|
||||
* by replacing them with the actual media element children.
|
||||
*/
|
||||
export function SkinMixin<Base extends Constructor<ReactiveElement>>(BaseClass: Base): Base {
|
||||
class SkinElement extends (BaseClass as Constructor<ReactiveElement>) {
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
const ctor = this.constructor as { getTemplateHTML?: () => string };
|
||||
|
||||
if (ctor.getTemplateHTML) {
|
||||
const children = [...this.childNodes];
|
||||
this.innerHTML = ctor.getTemplateHTML();
|
||||
this.#resolveSlots(children);
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
// During innerHTML parsing, children aren't available in the constructor.
|
||||
// Resolve any remaining slotted elements before the container connects.
|
||||
this.#resolveSlots();
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
#resolveSlots(nodes?: ChildNode[]): void {
|
||||
const slot = this.querySelector('slot[name="media"]');
|
||||
if (!slot) return;
|
||||
|
||||
// Collect media from either the provided node list (constructor) or
|
||||
// from direct children that haven't been placed yet (connectedCallback).
|
||||
const media = nodes
|
||||
? nodes.filter((n): n is HTMLElement => n instanceof HTMLElement && n.getAttribute('slot') === 'media')
|
||||
: [...this.querySelectorAll<HTMLElement>(':scope > [slot="media"]')];
|
||||
|
||||
for (const el of media) slot.before(el);
|
||||
slot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return SkinElement as unknown as Base;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "@videojs/skins/video/minimal.css";
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import {
|
||||
bufferingIndicator,
|
||||
button,
|
||||
buttonGroup,
|
||||
controls,
|
||||
error,
|
||||
icon,
|
||||
iconContainer,
|
||||
iconFlipped,
|
||||
iconState,
|
||||
overlay,
|
||||
popup,
|
||||
root,
|
||||
seek,
|
||||
slider,
|
||||
time,
|
||||
} from '@videojs/skins/video/minimal.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
import '../media/container';
|
||||
import '../ui/buffering-indicator';
|
||||
import '../ui/controls';
|
||||
import '../ui/fullscreen-button';
|
||||
import '../ui/mute-button';
|
||||
import '../ui/pip-button';
|
||||
import '../ui/play-button';
|
||||
import '../ui/playback-rate-button';
|
||||
import '../ui/popover';
|
||||
import '../ui/seek-button';
|
||||
import '../ui/time';
|
||||
import '../ui/time-slider';
|
||||
import '../ui/volume-slider';
|
||||
import { playbackRate } from '@videojs/skins/video/default.tailwind';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root}">
|
||||
<media-buffering-indicator class="${bufferingIndicator}">
|
||||
${renderIcon('spinner')}
|
||||
</media-buffering-indicator>
|
||||
|
||||
<div class="${error.root}" role="alertdialog" aria-labelledby="media-error-title" aria-describedby="media-error-description">
|
||||
<div class="${error.dialog}">
|
||||
<div class="${error.content}">
|
||||
<p id="media-error-title" class="${error.title}">Something went wrong.</p>
|
||||
<p id="media-error-description">An error occurred while trying to play the video. Please try again.</p>
|
||||
</div>
|
||||
<div class="${error.actions}">
|
||||
<button type="button" class="${cn(button.base, button.default)}">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<media-controls data-controls="" class="${controls}">
|
||||
<span class="${buttonGroup}">
|
||||
<media-play-button class="${cn(button.base, button.icon, iconState.play.button)}">
|
||||
${renderIcon('restart', { class: cn(icon, iconState.play.restart) })}
|
||||
${renderIcon('play', { class: cn(icon, iconState.play.play) })}
|
||||
${renderIcon('pause', { class: cn(icon, iconState.play.pause) })}
|
||||
</media-play-button>
|
||||
|
||||
<media-seek-button seconds="${-SEEK_TIME}" class="${cn(button.base, button.icon, seek.button)}">
|
||||
<span class="${iconContainer}">
|
||||
${renderIcon('seek', { class: cn(icon, iconFlipped) })}
|
||||
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-seek-button seconds="${SEEK_TIME}" class="${cn(button.base, button.icon, seek.button)}">
|
||||
<span class="${iconContainer}">
|
||||
${renderIcon('seek', { class: icon })}
|
||||
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
</span>
|
||||
|
||||
<span class="${time.controls}">
|
||||
<media-time-group class="${time.group}">
|
||||
<media-time type="current" class="${time.current}"></media-time>
|
||||
<media-time-separator class="${time.separator}"></media-time-separator>
|
||||
<media-time type="duration" class="${time.duration}"></media-time>
|
||||
</media-time-group>
|
||||
|
||||
<media-time-slider class="${slider.root}">
|
||||
<media-slider-track class="${slider.track}">
|
||||
<media-slider-fill class="${cn(slider.fill.base, slider.fill.fill)}"></media-slider-fill>
|
||||
<media-slider-buffer class="${cn(slider.fill.base, slider.fill.buffer)}"></media-slider-buffer>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="${cn(slider.thumb.base, slider.thumb.interactive)}"></media-slider-thumb>
|
||||
</media-time-slider>
|
||||
</span>
|
||||
|
||||
<span class="${buttonGroup}">
|
||||
<media-playback-rate-button class="${cn(button.base, button.icon, playbackRate.button)}">
|
||||
</media-playback-rate-button>
|
||||
|
||||
<media-mute-button commandfor="volume-popover" class="${cn(button.base, button.icon, iconState.mute.button)}">
|
||||
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
|
||||
${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })}
|
||||
${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })}
|
||||
</media-mute-button>
|
||||
|
||||
<media-popover id="volume-popover" open-on-hover delay="200" close-delay="100" side="top" class="${cn(popup.base, popup.volume)}">
|
||||
<media-volume-slider class="${slider.root}" orientation="vertical" thumb-alignment="edge">
|
||||
<media-slider-track class="${slider.track}">
|
||||
<media-slider-fill class="${cn(slider.fill.base, slider.fill.fill)}"></media-slider-fill>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="${slider.thumb.base}"></media-slider-thumb>
|
||||
</media-volume-slider>
|
||||
</media-popover>
|
||||
|
||||
<!--<button type="button" class="${cn(button.base, button.icon)}" aria-label="Captions">
|
||||
${renderIcon('captions-off', { class: icon })}
|
||||
${renderIcon('captions-on', { class: icon })}
|
||||
</button>-->
|
||||
|
||||
<media-pip-button class="${cn(button.base, button.icon)}">
|
||||
${renderIcon('pip', { class: icon })}
|
||||
</media-pip-button>
|
||||
|
||||
<media-fullscreen-button class="${cn(button.base, button.icon, iconState.fullscreen.button)}">
|
||||
${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })}
|
||||
${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })}
|
||||
</media-fullscreen-button>
|
||||
</span>
|
||||
</media-controls>
|
||||
|
||||
<div class="${overlay}"></div>
|
||||
|
||||
<slot name="media"></slot>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
export class MinimalVideoSkinTailwindElement extends SkinMixin(ReactiveElement) {
|
||||
static readonly tagName = 'video-minimal-skin-tailwind';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalVideoSkinTailwindElement.tagName, MinimalVideoSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[MinimalVideoSkinTailwindElement.tagName]: MinimalVideoSkinTailwindElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
import '../media/container';
|
||||
import '../ui/buffering-indicator';
|
||||
import '../ui/controls';
|
||||
import '../ui/fullscreen-button';
|
||||
import '../ui/mute-button';
|
||||
import '../ui/pip-button';
|
||||
import '../ui/play-button';
|
||||
import '../ui/playback-rate-button';
|
||||
import '../ui/popover';
|
||||
import '../ui/seek-button';
|
||||
import '../ui/time';
|
||||
import '../ui/time-slider';
|
||||
import '../ui/volume-slider';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-minimal-skin">
|
||||
<media-buffering-indicator class="media-buffering-indicator">
|
||||
${renderIcon('spinner', { class: 'media-icon' })}
|
||||
</media-buffering-indicator>
|
||||
|
||||
<div class="media-error" role="alertdialog" aria-labelledby="media-error-title" aria-describedby="media-error-description">
|
||||
<div class="media-error__dialog">
|
||||
<div class="media-error__content">
|
||||
<p id="media-error-title" class="media-error__title">Something went wrong.</p>
|
||||
<p id="media-error-description" class="media-error__description">An error occurred while trying to play the video. Please try again.</p>
|
||||
</div>
|
||||
<div class="media-error__actions">
|
||||
<button type="button" class="media-button">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<media-controls class="media-controls">
|
||||
<span class="media-button-group">
|
||||
<media-play-button class="media-button media-button--icon media-button--play">
|
||||
${renderIcon('restart', { class: 'media-icon media-icon--restart' })}
|
||||
${renderIcon('play', { class: 'media-icon media-icon--play' })}
|
||||
${renderIcon('pause', { class: 'media-icon media-icon--pause' })}
|
||||
</media-play-button>
|
||||
|
||||
<media-seek-button seconds="${-SEEK_TIME}" class="media-button media-button--icon media-button--seek">
|
||||
<span class="media-icon__container">
|
||||
${renderIcon('seek', { class: 'media-icon media-icon--flipped' })}
|
||||
<span class="media-icon__label">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-seek-button seconds="${SEEK_TIME}" class="media-button media-button--icon media-button--seek">
|
||||
<span class="media-icon__container">
|
||||
${renderIcon('seek', { class: 'media-icon' })}
|
||||
<span class="media-icon__label">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
</span>
|
||||
|
||||
<span class="media-time-controls">
|
||||
<media-time-group class="media-time">
|
||||
<media-time type="current" class="media-time__value media-time__value--current"></media-time>
|
||||
<media-time-separator class="media-time__separator"></media-time-separator>
|
||||
<media-time type="duration" class="media-time__value media-time__value--duration"></media-time>
|
||||
</media-time-group>
|
||||
|
||||
<media-time-slider class="media-slider">
|
||||
<media-slider-track class="media-slider__track">
|
||||
<media-slider-fill class="media-slider__fill"></media-slider-fill>
|
||||
<media-slider-buffer class="media-slider__buffer"></media-slider-buffer>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="media-slider__thumb"></media-slider-thumb>
|
||||
</media-time-slider>
|
||||
</span>
|
||||
|
||||
<span class="media-button-group">
|
||||
<media-playback-rate-button class="media-button media-button--icon media-button--playback-rate">
|
||||
</media-playback-rate-button>
|
||||
|
||||
<media-mute-button commandfor="volume-popover" class="media-button media-button--icon media-button--mute">
|
||||
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
|
||||
${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })}
|
||||
${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })}
|
||||
</media-mute-button>
|
||||
|
||||
<media-popover id="volume-popover" open-on-hover delay="200" close-delay="100" side="top" class="media-surface media-popup media-popup--volume media-popup-animation">
|
||||
<media-volume-slider class="media-slider" orientation="vertical" thumb-alignment="edge">
|
||||
<media-slider-track class="media-slider__track">
|
||||
<media-slider-fill class="media-slider__fill"></media-slider-fill>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="media-slider__thumb media-slider__thumb--persistent"></media-slider-thumb>
|
||||
</media-volume-slider>
|
||||
</media-popover>
|
||||
|
||||
<!-- <button type="button" class="media-button media-button--icon media-button--captions" aria-label="Captions">
|
||||
${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })}
|
||||
${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })}
|
||||
</button> -->
|
||||
|
||||
<media-pip-button class="media-button media-button--icon">
|
||||
${renderIcon('pip', { class: 'media-icon' })}
|
||||
</media-pip-button>
|
||||
|
||||
<media-fullscreen-button class="media-button media-button--icon media-button--fullscreen">
|
||||
${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })}
|
||||
${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })}
|
||||
</media-fullscreen-button>
|
||||
</span>
|
||||
</media-controls>
|
||||
|
||||
<div class="media-overlay"></div>
|
||||
|
||||
<slot name="media"></slot>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
export class MinimalVideoSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static readonly tagName = 'video-minimal-skin';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(MinimalVideoSkinElement.tagName, MinimalVideoSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[MinimalVideoSkinElement.tagName]: MinimalVideoSkinElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "@videojs/skins/video/default.css";
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import {
|
||||
bufferingIndicator,
|
||||
button,
|
||||
controls,
|
||||
error,
|
||||
icon,
|
||||
iconContainer,
|
||||
iconFlipped,
|
||||
iconState,
|
||||
overlay,
|
||||
playbackRate,
|
||||
popup,
|
||||
root,
|
||||
seek,
|
||||
slider,
|
||||
time,
|
||||
} from '@videojs/skins/video/default.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
import '../media/container';
|
||||
import '../ui/buffering-indicator';
|
||||
import '../ui/controls';
|
||||
import '../ui/fullscreen-button';
|
||||
import '../ui/mute-button';
|
||||
import '../ui/pip-button';
|
||||
import '../ui/play-button';
|
||||
import '../ui/playback-rate-button';
|
||||
import '../ui/popover';
|
||||
import '../ui/seek-button';
|
||||
import '../ui/time';
|
||||
import '../ui/time-slider';
|
||||
import '../ui/volume-slider';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root}">
|
||||
<media-buffering-indicator class="${bufferingIndicator.root}">
|
||||
<div class="${bufferingIndicator.container}">
|
||||
${renderIcon('spinner')}
|
||||
</div>
|
||||
</media-buffering-indicator>
|
||||
|
||||
<div class="${error.root}" role="alertdialog" aria-labelledby="media-error-title" aria-describedby="media-error-description">
|
||||
<div class="${error.dialog}">
|
||||
<div class="${error.content}">
|
||||
<p id="media-error-title" class="${error.title}">Something went wrong.</p>
|
||||
<p id="media-error-description" class="${error.description}">An error occurred while trying to play the video. Please try again.</p>
|
||||
</div>
|
||||
<div class="${error.actions}">
|
||||
<button type="button" class="${cn(button.base, button.default)}">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<media-controls data-controls="" class="${controls}">
|
||||
<media-play-button class="${cn(button.base, button.icon, iconState.play.button)}">
|
||||
${renderIcon('restart', { class: cn(icon, iconState.play.restart) })}
|
||||
${renderIcon('play', { class: cn(icon, iconState.play.play) })}
|
||||
${renderIcon('pause', { class: cn(icon, iconState.play.pause) })}
|
||||
</media-play-button>
|
||||
|
||||
<media-seek-button seconds="${-SEEK_TIME}" class="${cn(button.base, button.icon, seek.button)}">
|
||||
<span class="${iconContainer}">
|
||||
${renderIcon('seek', { class: cn(icon, iconFlipped) })}
|
||||
<span class="${cn(seek.label, seek.labelBackward)}">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-seek-button seconds="${SEEK_TIME}" class="${cn(button.base, button.icon, seek.button)}">
|
||||
<span class="${iconContainer}">
|
||||
${renderIcon('seek', { class: icon })}
|
||||
<span class="${cn(seek.label, seek.labelForward)}">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-time-group class="${time.group}">
|
||||
<media-time type="current" class="${time.current}"></media-time>
|
||||
<media-time-slider class="${slider.root}">
|
||||
<media-slider-track class="${slider.track}">
|
||||
<media-slider-fill class="${cn(slider.fill.base, slider.fill.fill)}"></media-slider-fill>
|
||||
<media-slider-buffer class="${cn(slider.fill.base, slider.fill.buffer)}"></media-slider-buffer>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="${cn(slider.thumb.base, slider.thumb.interactive)}"></media-slider-thumb>
|
||||
</media-time-slider>
|
||||
<media-time type="duration" class="${time.duration}"></media-time>
|
||||
</media-time-group>
|
||||
|
||||
<media-playback-rate-button class="${cn(button.base, button.icon, playbackRate.button)}">
|
||||
</media-playback-rate-button>
|
||||
|
||||
<media-mute-button commandfor="volume-popover" class="${cn(button.base, button.icon, iconState.mute.button)}">
|
||||
${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })}
|
||||
${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })}
|
||||
${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })}
|
||||
</media-mute-button>
|
||||
|
||||
<media-popover id="volume-popover" open-on-hover delay="200" close-delay="100" side="top" class="${cn(popup.base, popup.volume)}">
|
||||
<media-volume-slider class="${slider.root}" orientation="vertical" thumb-alignment="edge">
|
||||
<media-slider-track class="${slider.track}">
|
||||
<media-slider-fill class="${cn(slider.fill.base, slider.fill.fill)}"></media-slider-fill>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="${cn(slider.thumb.base, slider.thumb.persistent)}"></media-slider-thumb>
|
||||
</media-volume-slider>
|
||||
</media-popover>
|
||||
|
||||
<!--<button type="button" class="${cn(button.base, button.icon)}" aria-label="Captions">
|
||||
${renderIcon('captions-off', { class: icon })}
|
||||
${renderIcon('captions-on', { class: icon })}
|
||||
</button>-->
|
||||
|
||||
<media-pip-button class="${cn(button.base, button.icon)}">
|
||||
${renderIcon('pip', { class: icon })}
|
||||
</media-pip-button>
|
||||
|
||||
<media-fullscreen-button class="${cn(button.base, button.icon, iconState.fullscreen.button)}">
|
||||
${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })}
|
||||
${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })}
|
||||
</media-fullscreen-button>
|
||||
</media-controls>
|
||||
|
||||
<div class="${overlay}"></div>
|
||||
|
||||
<slot name="media"></slot>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
export class VideoSkinTailwindElement extends SkinMixin(ReactiveElement) {
|
||||
static readonly tagName = 'video-skin-tailwind';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(VideoSkinTailwindElement.tagName, VideoSkinTailwindElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[VideoSkinTailwindElement.tagName]: VideoSkinTailwindElement;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,127 @@
|
||||
// TODO: Implement VideoSkinElement and then register it here
|
||||
import { ReactiveElement } from '@videojs/element';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import { SkinMixin } from '../skin-mixin';
|
||||
|
||||
// Side-effect imports: register all custom elements used in the template.
|
||||
import '../media/container';
|
||||
import '../ui/buffering-indicator';
|
||||
import '../ui/controls';
|
||||
import '../ui/fullscreen-button';
|
||||
import '../ui/mute-button';
|
||||
import '../ui/pip-button';
|
||||
import '../ui/play-button';
|
||||
import '../ui/playback-rate-button';
|
||||
import '../ui/popover';
|
||||
import '../ui/seek-button';
|
||||
import '../ui/time';
|
||||
import '../ui/time-slider';
|
||||
import '../ui/volume-slider';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-default-skin">
|
||||
<media-buffering-indicator class="media-buffering-indicator">
|
||||
<div class="media-surface">
|
||||
${renderIcon('spinner', { class: 'media-icon' })}
|
||||
</div>
|
||||
</media-buffering-indicator>
|
||||
|
||||
<!--<div class="media-error" role="alertdialog" aria-labelledby="media-error-title" aria-describedby="media-error-description">
|
||||
<div class="media-error__dialog media-surface">
|
||||
<div class="media-error__content">
|
||||
<p id="media-error-title" class="media-error__title">Something went wrong.</p>
|
||||
<p id="media-error-description" class="media-error__description">An error occurred while trying to play the video. Please try again.</p>
|
||||
</div>
|
||||
<div class="media-error__actions">
|
||||
<button type="button" class="media-button">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>-->
|
||||
|
||||
<media-controls class="media-surface media-controls">
|
||||
<media-play-button class="media-button media-button--icon media-button--play">
|
||||
${renderIcon('restart', { class: 'media-icon media-icon--restart' })}
|
||||
${renderIcon('play', { class: 'media-icon media-icon--play' })}
|
||||
${renderIcon('pause', { class: 'media-icon media-icon--pause' })}
|
||||
</media-play-button>
|
||||
|
||||
<media-seek-button seconds="${-SEEK_TIME}" class="media-button media-button--icon media-button--seek">
|
||||
<span class="media-icon__container">
|
||||
${renderIcon('seek', { class: 'media-icon media-icon--flipped' })}
|
||||
<span class="media-icon__label">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-seek-button seconds="${SEEK_TIME}" class="media-button media-button--icon media-button--seek">
|
||||
<span class="media-icon__container">
|
||||
${renderIcon('seek', { class: 'media-icon' })}
|
||||
<span class="media-icon__label">${SEEK_TIME}</span>
|
||||
</span>
|
||||
</media-seek-button>
|
||||
|
||||
<media-time-group class="media-time">
|
||||
<media-time type="current" class="media-time__value"></media-time>
|
||||
<media-time-slider class="media-slider">
|
||||
<media-slider-track class="media-slider__track">
|
||||
<media-slider-fill class="media-slider__fill"></media-slider-fill>
|
||||
<media-slider-buffer class="media-slider__buffer"></media-slider-buffer>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="media-slider__thumb"></media-slider-thumb>
|
||||
</media-time-slider>
|
||||
<media-time type="duration" class="media-time__value"></media-time>
|
||||
</media-time-group>
|
||||
|
||||
<media-playback-rate-button class="media-button media-button--icon media-button--playback-rate">
|
||||
</media-playback-rate-button>
|
||||
|
||||
<media-mute-button commandfor="volume-popover" class="media-button media-button--icon media-button--mute">
|
||||
${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })}
|
||||
${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })}
|
||||
${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })}
|
||||
</media-mute-button>
|
||||
|
||||
<media-popover id="volume-popover" open-on-hover delay="200" close-delay="100" side="top" class="media-surface media-popup media-popup--volume media-popup-animation">
|
||||
<media-volume-slider class="media-slider" orientation="vertical" thumb-alignment="edge">
|
||||
<media-slider-track class="media-slider__track">
|
||||
<media-slider-fill class="media-slider__fill"></media-slider-fill>
|
||||
</media-slider-track>
|
||||
<media-slider-thumb class="media-slider__thumb media-slider__thumb--persistent"></media-slider-thumb>
|
||||
</media-volume-slider>
|
||||
</media-popover>
|
||||
|
||||
<!--<button type="button" class="media-button media-button--icon media-button--captions" aria-label="Captions">
|
||||
${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })}
|
||||
${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })}
|
||||
</button>-->
|
||||
|
||||
<media-pip-button class="media-button media-button--icon">
|
||||
${renderIcon('pip', { class: 'media-icon' })}
|
||||
</media-pip-button>
|
||||
|
||||
<media-fullscreen-button class="media-button media-button--icon media-button--fullscreen">
|
||||
${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })}
|
||||
${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })}
|
||||
</media-fullscreen-button>
|
||||
</media-controls>
|
||||
|
||||
<div class="media-overlay"></div>
|
||||
|
||||
<slot name="media"></slot>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
export class VideoSkinElement extends SkinMixin(ReactiveElement) {
|
||||
static readonly tagName = 'video-skin';
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
}
|
||||
|
||||
customElements.define(VideoSkinElement.tagName, VideoSkinElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[VideoSkinElement.tagName]: VideoSkinElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { audioFeatures } from '@videojs/core/dom';
|
||||
export { MinimalAudioSkinElement } from '../define/audio/minimal-skin';
|
||||
export { AudioSkinElement } from '../define/audio/skin';
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { videoFeatures } from '@videojs/core/dom';
|
||||
export { MinimalVideoSkinElement } from '../define/video/minimal-skin';
|
||||
export { MinimalVideoSkinTailwindElement } from '../define/video/minimal-skin.tailwind';
|
||||
export { VideoSkinElement } from '../define/video/skin';
|
||||
export { VideoSkinTailwindElement } from '../define/video/skin.tailwind';
|
||||
|
||||
@@ -19,15 +19,27 @@ export function createContainerMixin<Store extends PlayerStore>(context: PlayerC
|
||||
class PlayerContainerElement extends BaseClass implements PlayerConsumer<Store>, MediaContainer {
|
||||
#detach = noop;
|
||||
#observer: MutationObserver | null = null;
|
||||
#contextStore: Store | null = null;
|
||||
|
||||
#consumer = new ContextConsumer(this, {
|
||||
context,
|
||||
callback: () => this.#attachMedia(),
|
||||
subscribe: true,
|
||||
});
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
// Created in the constructor body (after all field initializers) so
|
||||
// that #contextStore's private slot exists if the callback fires
|
||||
// synchronously — which happens when the element is already connected.
|
||||
// The host's controller list keeps the consumer alive; no field needed.
|
||||
new ContextConsumer(this, {
|
||||
context,
|
||||
callback: (value) => {
|
||||
this.#contextStore = value ?? null;
|
||||
this.#attachMedia();
|
||||
},
|
||||
subscribe: true,
|
||||
});
|
||||
}
|
||||
|
||||
get store(): Store | null {
|
||||
return this.#consumer.value ?? null;
|
||||
return this.#contextStore;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
@@ -50,8 +62,9 @@ export function createContainerMixin<Store extends PlayerStore>(context: PlayerC
|
||||
}
|
||||
|
||||
#attachMedia() {
|
||||
// Store will be overridden and set by provider mixin if consumer is empty.
|
||||
const store = this.#consumer.value ?? this.store;
|
||||
// Prefer the cached context value; fall back to `this.store` which
|
||||
// ProviderMixin overrides when both mixins are applied to one element.
|
||||
const store = this.#contextStore ?? this.store;
|
||||
if (!store) return;
|
||||
|
||||
const media = this.querySelector<HTMLMediaElement>('video, audio');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { globSync } from 'node:fs';
|
||||
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { UserConfig } from 'tsdown';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
@@ -6,6 +8,8 @@ type BuildMode = 'dev' | 'default';
|
||||
|
||||
const buildModes: BuildMode[] = ['dev', 'default'];
|
||||
|
||||
const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src');
|
||||
|
||||
const defineEntries = Object.fromEntries(
|
||||
globSync('src/define/**/*.ts')
|
||||
.filter((file) => !file.includes('.test.'))
|
||||
@@ -34,6 +38,13 @@ const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
clean: true,
|
||||
hash: false,
|
||||
unbundle: true,
|
||||
treeshake: {
|
||||
// The sideEffects field in package.json uses dist paths, but the build
|
||||
// runs against source. Ensure define/* modules (which register custom
|
||||
// elements as a side effect) are never tree-shaken from skin bundles.
|
||||
moduleSideEffects: [{ test: /\/define\//, sideEffects: true }],
|
||||
},
|
||||
noExternal: [/^@videojs\/icons/, /^@videojs\/skins/],
|
||||
alias: {
|
||||
'@': new URL('./src', import.meta.url).pathname,
|
||||
},
|
||||
@@ -42,21 +53,36 @@ const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
__DEV__: mode === 'dev' ? 'true' : 'false',
|
||||
},
|
||||
dts: mode === 'dev',
|
||||
copy: [
|
||||
{
|
||||
from: 'src/**/*.css',
|
||||
to: `dist/${mode}`,
|
||||
flatten: false,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: 'watch-css',
|
||||
name: 'copy-css',
|
||||
buildStart() {
|
||||
const cssFiles = globSync('src/**/*.css');
|
||||
for (const file of cssFiles) {
|
||||
for (const file of globSync('src/**/*.css')) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
for (const file of globSync(join(skinsDir, '**/*.css'))) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
},
|
||||
writeBundle() {
|
||||
for (const file of globSync('src/**/*.css')) {
|
||||
let content = readFileSync(file, 'utf-8');
|
||||
|
||||
// Resolve @import from @videojs/skins by inlining the CSS (including nested relative imports)
|
||||
content = content.replace(/@import\s+['"]@videojs\/skins\/([^'"]+)['"]\s*;/g, (_, importPath) => {
|
||||
const skinsFile = resolve(skinsDir, importPath);
|
||||
let skinsContent = readFileSync(skinsFile, 'utf-8');
|
||||
// Resolve relative @import within the skins CSS
|
||||
skinsContent = skinsContent.replace(/@import\s+['"]\.\/([^'"]+)['"]\s*;/g, (__, relPath) =>
|
||||
readFileSync(resolve(dirname(skinsFile), relPath), 'utf-8')
|
||||
);
|
||||
return skinsContent;
|
||||
});
|
||||
|
||||
const outFile = join(`dist/${mode}`, file.replace(/^src\//, ''));
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
writeFileSync(outFile, content);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"url": "https://github.com/videojs/v10",
|
||||
"directory": "packages/icons"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"sideEffects": [
|
||||
"./dist/element/**/*.js"
|
||||
],
|
||||
"exports": {
|
||||
"./react": {
|
||||
"types": "./dist/react/default/index.d.ts",
|
||||
@@ -27,6 +29,18 @@
|
||||
"./html/*": {
|
||||
"types": "./dist/html/*/index.d.ts",
|
||||
"default": "./dist/html/*/index.js"
|
||||
},
|
||||
"./render": {
|
||||
"types": "./dist/render/default/index.d.ts",
|
||||
"default": "./dist/render/default/index.js"
|
||||
},
|
||||
"./render/*": {
|
||||
"types": "./dist/render/*/index.d.ts",
|
||||
"default": "./dist/render/*/index.js"
|
||||
},
|
||||
"./element": {
|
||||
"types": "./dist/element/index.d.ts",
|
||||
"default": "./dist/element/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -15,7 +15,6 @@ const ASSETS_DIR = join(ROOT, 'src/assets');
|
||||
const DIST_DIR = join(ROOT, 'dist');
|
||||
|
||||
const FRAMEWORKS = ['react', 'html'] as const;
|
||||
type Framework = (typeof FRAMEWORKS)[number];
|
||||
|
||||
const SVGO_CONFIG: Config = {
|
||||
multipass: true,
|
||||
@@ -87,17 +86,133 @@ function buildHtmlExport(svgContent: string, varName: string): string {
|
||||
return `export const ${varName} = \`${optimizeSvg(svgContent)}\`;\n`;
|
||||
}
|
||||
|
||||
function buildIndexExports(icons: { name: string; varName: string }[], framework: Framework): string {
|
||||
function buildRenderModule(icons: { name: string; content: string }[]): string {
|
||||
const entries = icons.map(({ name, content }) => ` "${name}": \`${optimizeSvg(content)}\``).join(',\n');
|
||||
return [
|
||||
`const icons = {\n${entries},\n};`,
|
||||
``,
|
||||
`export function renderIcon(name, attrs) {`,
|
||||
` const svg = icons[name];`,
|
||||
` if (!svg) return '';`,
|
||||
` if (!attrs) return svg;`,
|
||||
` const attrStr = Object.entries(attrs)`,
|
||||
` .map(([k, v]) => \` \${k}="\${v}"\`)`,
|
||||
` .join('');`,
|
||||
` return svg.replace('<svg', \`<svg\${attrStr}\`);`,
|
||||
`}`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildRenderTypes(iconNames: string[]): string {
|
||||
const union = iconNames.map((name) => `'${name}'`).join(' | ');
|
||||
return [
|
||||
`export type IconName = ${union};`,
|
||||
``,
|
||||
`export declare function renderIcon(`,
|
||||
` name: IconName,`,
|
||||
` attrs?: Record<string, string>,`,
|
||||
`): string;`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildIconMap(icons: { name: string; content: string }[]): string {
|
||||
const entries = icons.map(({ name, content }) => ` "${name}": \`${optimizeSvg(content)}\``).join(',\n');
|
||||
return `export const icons = {\n${entries},\n};\n`;
|
||||
}
|
||||
|
||||
function buildElementIndex(sets: string[]): string {
|
||||
const varName = (set: string) => `${camelCase(set)}Icons`;
|
||||
const imports = sets.map((set) => `import { icons as ${varName(set)} } from './${set}/icons.js';`).join('\n');
|
||||
const registers = sets.map((set) => `MediaIconElement.register('${set}', ${varName(set)});`).join('\n');
|
||||
|
||||
return [
|
||||
`import { MediaIconElement } from './base.js';`,
|
||||
imports,
|
||||
``,
|
||||
`if (!customElements.get('media-icon')) {`,
|
||||
` customElements.define('media-icon', MediaIconElement);`,
|
||||
`}`,
|
||||
``,
|
||||
registers,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildElementBase(): string {
|
||||
return [
|
||||
`export class MediaIconElement extends HTMLElement {`,
|
||||
` static #families = new Map();`,
|
||||
``,
|
||||
` static register(family, icons) {`,
|
||||
` const map = MediaIconElement.#families.get(family) ?? new Map();`,
|
||||
` for (const [name, svg] of Object.entries(icons)) {`,
|
||||
` map.set(name, svg);`,
|
||||
` }`,
|
||||
` MediaIconElement.#families.set(family, map);`,
|
||||
` }`,
|
||||
``,
|
||||
` static get observedAttributes() {`,
|
||||
` return ['name', 'family'];`,
|
||||
` }`,
|
||||
``,
|
||||
` attributeChangedCallback() {`,
|
||||
` this.#render();`,
|
||||
` }`,
|
||||
``,
|
||||
` connectedCallback() {`,
|
||||
` this.#render();`,
|
||||
` }`,
|
||||
``,
|
||||
` #render() {`,
|
||||
` const name = this.getAttribute('name');`,
|
||||
` if (!name) return;`,
|
||||
``,
|
||||
` const family = this.getAttribute('family') || 'default';`,
|
||||
` const icons = MediaIconElement.#families.get(family);`,
|
||||
` const svg = icons?.get(name);`,
|
||||
` if (!svg) return;`,
|
||||
``,
|
||||
` this.innerHTML = svg;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildElementBaseTypes(): string {
|
||||
return [
|
||||
`export type IconMap = Record<string, string>;`,
|
||||
``,
|
||||
`export declare class MediaIconElement extends HTMLElement {`,
|
||||
` static register(family: string, icons: IconMap): void;`,
|
||||
` connectedCallback(): void;`,
|
||||
` attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;`,
|
||||
`}`,
|
||||
``,
|
||||
`declare global {`,
|
||||
` interface HTMLElementTagNameMap {`,
|
||||
` 'media-icon': MediaIconElement;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildIndexExports(icons: { name: string; varName: string }[], framework: 'react' | 'html'): string {
|
||||
return icons
|
||||
.map(({ name, varName }) =>
|
||||
framework === 'react'
|
||||
? `export { default as ${pascalCase(varName)}Icon } from './${name}.js';`
|
||||
: `export { ${camelCase(varName)}Icon } from './${name}.js';`
|
||||
)
|
||||
.map(({ name, varName }) => {
|
||||
if (framework === 'react') {
|
||||
return `export { default as ${pascalCase(varName)}Icon } from './${name}.js';`;
|
||||
}
|
||||
|
||||
return `export { ${camelCase(varName)}Icon } from './${name}.js';`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildIndexTypes(icons: { name: string; varName: string }[], framework: Framework): string {
|
||||
function buildIndexTypes(icons: { name: string; varName: string }[], framework: 'react' | 'html'): string {
|
||||
const types = icons.map(({ varName }) =>
|
||||
framework === 'react'
|
||||
? `export declare const ${pascalCase(varName)}Icon: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;`
|
||||
@@ -106,6 +221,17 @@ function buildIndexTypes(icons: { name: string; varName: string }[], framework:
|
||||
return `/// <reference types="react" />\n${types.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function ensureElementBase(): void {
|
||||
const baseDir = join(DIST_DIR, 'element');
|
||||
ensureDir(baseDir);
|
||||
|
||||
const basePath = join(baseDir, 'base.js');
|
||||
if (!existsSync(basePath)) {
|
||||
writeFileSync(basePath, buildElementBase());
|
||||
writeFileSync(join(baseDir, 'base.d.ts'), buildElementBaseTypes());
|
||||
}
|
||||
}
|
||||
|
||||
async function buildIconSet(setName: string): Promise<void> {
|
||||
const svgFiles = getSvgFiles(setName);
|
||||
console.log(`Building set: ${setName} (${svgFiles.length} icons)`);
|
||||
@@ -116,6 +242,7 @@ async function buildIconSet(setName: string): Promise<void> {
|
||||
content: readFileSync(join(ASSETS_DIR, setName, file), 'utf8'),
|
||||
}));
|
||||
|
||||
// Build react and html per-icon modules
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const outDir = join(DIST_DIR, framework, setName);
|
||||
ensureDir(outDir);
|
||||
@@ -142,6 +269,20 @@ async function buildIconSet(setName: string): Promise<void> {
|
||||
writeFileSync(join(outDir, 'index.js'), buildIndexExports(icons, framework));
|
||||
writeFileSync(join(outDir, 'index.d.ts'), buildIndexTypes(icons, framework));
|
||||
}
|
||||
|
||||
// Build render module
|
||||
const renderDir = join(DIST_DIR, 'render', setName);
|
||||
ensureDir(renderDir);
|
||||
writeFileSync(join(renderDir, 'index.js'), buildRenderModule(icons));
|
||||
writeFileSync(join(renderDir, 'index.d.ts'), buildRenderTypes(icons.map((i) => i.name)));
|
||||
|
||||
// Build element: icon map per family (no per-set index)
|
||||
ensureElementBase();
|
||||
const elementDir = join(DIST_DIR, 'element', setName);
|
||||
ensureDir(elementDir);
|
||||
|
||||
writeFileSync(join(elementDir, 'icons.js'), buildIconMap(icons));
|
||||
writeFileSync(join(elementDir, 'icons.d.ts'), `export declare const icons: Record<string, string>;\n`);
|
||||
}
|
||||
|
||||
async function build(): Promise<void> {
|
||||
@@ -151,6 +292,11 @@ async function build(): Promise<void> {
|
||||
for (const set of sets) {
|
||||
await buildIconSet(set);
|
||||
}
|
||||
|
||||
// Build unified element index that registers all families
|
||||
const elementDir = join(DIST_DIR, 'element');
|
||||
writeFileSync(join(elementDir, 'index.js'), buildElementIndex(sets));
|
||||
writeFileSync(join(elementDir, 'index.d.ts'), `export {};\n`);
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, ms: number): () => void {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*", "dist/**/*"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@videojs/core": "workspace:*",
|
||||
"@videojs/skins": "workspace:*",
|
||||
"@videojs/store": "workspace:*",
|
||||
"@videojs/utils": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
.media-skin {
|
||||
color: var(--media-color, red);
|
||||
}
|
||||
@import "@videojs/skins/audio/default.css";
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
.media-background-skin {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-background-skin > video,
|
||||
.media-background-skin > .media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: inherit;
|
||||
}
|
||||
|
||||
.media-background-skin > :where(img, picture) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: inherit;
|
||||
}
|
||||
@import "@videojs/skins/background/default.css";
|
||||
|
||||
@@ -1,707 +1 @@
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin *,
|
||||
.media-minimal-skin *::before,
|
||||
.media-minimal-skin *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.media-minimal-skin img,
|
||||
.media-minimal-skin video,
|
||||
.media-minimal-skin svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-minimal-skin button {
|
||||
font: inherit;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.media-minimal-skin {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Root Container
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 0.75rem);
|
||||
background: oklch(0 0 0);
|
||||
font-family:
|
||||
Inter Variable,
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
|
||||
/* Border ring */
|
||||
.media-minimal-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.media-minimal-skin::after {
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-minimal-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Media Element
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Poster Image
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Overlay / Scrim
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklch(0 0 0 / 0.7), oklch(0 0 0 / 0.5) 7.5rem, oklch(0 0 0 / 0));
|
||||
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
|
||||
opacity: 0;
|
||||
transition-property: opacity, backdrop-filter;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay,
|
||||
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
|
||||
opacity: 1;
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
|
||||
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buffering Indicator
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Dialog
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-error {
|
||||
display: none;
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-error__dialog {
|
||||
display: none;
|
||||
transition-property: display, opacity, transform;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 100ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-timing-function: linear(
|
||||
0,
|
||||
0.034 1.5%,
|
||||
0.763 9.7%,
|
||||
1.066 13.9%,
|
||||
1.198 19.9%,
|
||||
1.184 21.8%,
|
||||
0.963 37.5%,
|
||||
0.997 50.9%,
|
||||
1
|
||||
);
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] .media-error__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 16rem;
|
||||
padding: 1rem;
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.875rem;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.5);
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
color: oklch(1 0 0);
|
||||
|
||||
@starting-style {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-error__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
.media-minimal-skin .media-error__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.media-minimal-skin .media-error__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.media-minimal-skin .media-error__actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Controls
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 10;
|
||||
container: media-controls / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 0.375rem 0.375rem 0.375rem;
|
||||
color: oklch(1 0 0);
|
||||
will-change: transform, filter, opacity;
|
||||
transition-property: transform, filter, opacity;
|
||||
transition-duration: 75ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
filter: blur(8px);
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 500ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
scale: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-controls {
|
||||
gap: 0.875rem;
|
||||
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Time Controls & Display
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-time-controls {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.media-minimal-skin .media-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.media-minimal-skin .media-time__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.2);
|
||||
}
|
||||
.media-minimal-skin .media-time__value--current,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
display: none;
|
||||
}
|
||||
@container media-controls (width > 28rem) {
|
||||
.media-minimal-skin .media-time-controls {
|
||||
flex-direction: row;
|
||||
}
|
||||
.media-minimal-skin .media-time__value--duration,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
color: oklch(1 0 0 / 0.5);
|
||||
}
|
||||
.media-minimal-skin .media-time__value--current,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Button Groups
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-button-group {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
/* Base button */
|
||||
.media-minimal-skin .media-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: oklch(1 0 0);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
color: oklch(0 0 0);
|
||||
font-weight: 500;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.media-minimal-skin .media-button:focus-visible {
|
||||
outline-color: oklch(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-button[disabled] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icon button variant */
|
||||
.media-minimal-skin .media-button--icon {
|
||||
display: grid;
|
||||
width: 2.375rem;
|
||||
padding: 0;
|
||||
aspect-ratio: 1;
|
||||
background: transparent;
|
||||
color: oklch(1 0 0);
|
||||
}
|
||||
.media-minimal-skin .media-button--icon:hover,
|
||||
.media-minimal-skin .media-button--icon:focus-visible,
|
||||
.media-minimal-skin .media-button--icon[aria-expanded="true"] {
|
||||
color: oklch(1 0 0 / 0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-minimal-skin .media-button--icon .media-icon {
|
||||
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* Seek button variant — hidden at small sizes */
|
||||
@container media-controls (width < 28rem) {
|
||||
.media-minimal-skin .media-button--seek {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-minimal-skin .media-icon {
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: display, opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-icon--hidden {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.media-minimal-skin .media-icon--flipped {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
/* Seek icon label positioning */
|
||||
.media-minimal-skin .media-icon--seek ~ .media-icon__label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-minimal-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
|
||||
right: unset;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation */
|
||||
.media-minimal-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation */
|
||||
.media-minimal-skin .media-slider[data-orientation="vertical"] {
|
||||
width: 1.25rem;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.media-minimal-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
|
||||
user-select: none;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.1875rem;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.1875rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.media-minimal-skin .media-slider__thumb {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background-color: oklch(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklch(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklch(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
scale: 0.7;
|
||||
transform-origin: center;
|
||||
transition-property: opacity, scale, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider:focus-within .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
/* Shared track fills */
|
||||
.media-minimal-skin .media-slider__buffer,
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-minimal-skin .media-slider__buffer {
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-buffer);
|
||||
transition-property: width;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"] {
|
||||
height: var(--media-slider-buffer);
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
/* Fill */
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
.media-minimal-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Popups & Animations
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-popup-animation {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
filter: blur(0px);
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-starting-style],
|
||||
.media-minimal-skin .media-popup-animation[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-popup {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-minimal-skin .media-popup--volume {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tooltips
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px oklch(0 0 0 / 0.1),
|
||||
0 2px 4px -2px oklch(0 0 0 / 0.1);
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklch(0 0 0 / 0.7);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklch(0 0 0 / 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Captions
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-captions {
|
||||
position: absolute;
|
||||
inset: auto 1rem 1.5rem 1rem;
|
||||
z-index: 20;
|
||||
font-size: 1rem;
|
||||
text-wrap: balance;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-captions__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 42ch;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.media-minimal-skin .media-captions__text {
|
||||
display: block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
color: oklch(1 0 0);
|
||||
text-shadow:
|
||||
0 0 1px oklch(0 0 0 / 0.7),
|
||||
0 0 8px oklch(0 0 0 / 0.7);
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-minimal-skin .media-captions__text {
|
||||
background: oklch(0 0 0 / 0.7);
|
||||
text-shadow: none;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-captions__text > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Responsive caption sizing */
|
||||
@container media-root (width > 20rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Caption shifting styles (custom and native) */
|
||||
.media-minimal-skin {
|
||||
--media-caption-track-delay: 600ms;
|
||||
--media-caption-track-y: -0.5rem;
|
||||
}
|
||||
.media-minimal-skin:has(.media-controls[data-visible]) {
|
||||
--media-caption-track-delay: 25ms;
|
||||
--media-caption-track-y: -3rem;
|
||||
}
|
||||
.media-minimal-skin .media-captions,
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
/* NOTE: The delay must account for the controls delay/duration */
|
||||
transition: transform 150ms ease-out;
|
||||
transition-delay: var(--media-caption-track-delay);
|
||||
}
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
transform: translateY(var(--media-caption-track-y)) scale(0.98);
|
||||
z-index: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* When controls are visible, shift captions up to avoid overlap */
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
|
||||
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-captions,
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
}
|
||||
@import "@videojs/skins/video/minimal.css";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
CaptionsOffIcon,
|
||||
CaptionsOnIcon,
|
||||
@@ -14,11 +13,29 @@ import {
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react/minimal';
|
||||
import { playbackRate } from '@videojs/skins/video/default.tailwind';
|
||||
import {
|
||||
bufferingIndicator,
|
||||
button,
|
||||
buttonGroup,
|
||||
controls,
|
||||
error,
|
||||
icon,
|
||||
iconContainer,
|
||||
iconFlipped,
|
||||
iconState,
|
||||
overlay,
|
||||
popup,
|
||||
root,
|
||||
seek,
|
||||
slider,
|
||||
time,
|
||||
} from '@videojs/skins/video/minimal.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
|
||||
import { CaptionsButton } from '@/ui/captions-button';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { ErrorDialog } from '@/ui/error-dialog';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
@@ -35,16 +52,6 @@ import type { MinimalVideoSkinProps } from './minimal-skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
/* ------------------------------------ Reused fragments ------------------------------------- */
|
||||
|
||||
const icon = cn(
|
||||
'[grid-area:1/1] size-4.5',
|
||||
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
const iconHidden = 'hidden opacity-0';
|
||||
|
||||
/* --------------------------------------- Components ---------------------------------------- */
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
|
||||
@@ -55,112 +62,21 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
// Shared
|
||||
'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 w-[2.375rem] aspect-square bg-transparent rounded-md',
|
||||
'text-white',
|
||||
'hover:text-white/80 hover:no-underline',
|
||||
'focus-visible:text-white/80',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
'aria-expanded:text-white/80'
|
||||
)
|
||||
: cn(
|
||||
'flex py-2 px-4 bg-white rounded-lg',
|
||||
'text-black font-medium',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2'
|
||||
),
|
||||
className
|
||||
)}
|
||||
className={cn(button.base, variant === 'icon' ? button.icon : button.default, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { [iconHidden]: !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { [iconHidden]: ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { [iconHidden]: paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { [iconHidden]: !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
|
||||
const { active } = state;
|
||||
return (
|
||||
<>
|
||||
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
|
||||
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-[4.5rem]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
|
||||
});
|
||||
|
||||
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-0.75',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-0.75 data-[orientation=vertical]:h-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
|
||||
});
|
||||
|
||||
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
|
||||
@@ -170,28 +86,7 @@ const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: '
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute rounded-[inherit] pointer-events-none',
|
||||
{
|
||||
'bg-white': type === 'fill',
|
||||
'bg-white/20 duration-250 ease-out': type === 'buffer',
|
||||
},
|
||||
// Horizontal
|
||||
`data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0`,
|
||||
{
|
||||
'data-[orientation=horizontal]:w-(--media-slider-fill)': type === 'fill',
|
||||
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)':
|
||||
type === 'buffer',
|
||||
},
|
||||
// Vertical
|
||||
`data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0`,
|
||||
{
|
||||
'data-[orientation=vertical]:h-(--media-slider-fill)': type === 'fill',
|
||||
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)':
|
||||
type === 'buffer',
|
||||
},
|
||||
className
|
||||
)}
|
||||
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -204,27 +99,7 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
|
||||
// Visibility
|
||||
persistent
|
||||
? undefined
|
||||
: cn(
|
||||
'opacity-0 scale-70 origin-center',
|
||||
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
|
||||
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
|
||||
),
|
||||
className
|
||||
)}
|
||||
className={cn(slider.thumb.base, persistent ? undefined : slider.thumb.interactive, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -236,87 +111,28 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={cn(
|
||||
// Layout & containment
|
||||
'relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-[var(--media-border-radius,0.75rem)] bg-black',
|
||||
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Resets
|
||||
'**:box-border **:m-0',
|
||||
'[&_button]:font-[inherit]',
|
||||
'motion-safe:[interpolate-size:allow-keywords]',
|
||||
// Outer border ring (::after only)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/15',
|
||||
'dark:after:ring-white/15',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-[250ms]',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Caption track CSS variables (consumed by native track container below)
|
||||
'[--media-caption-track-delay:600ms]',
|
||||
'[--media-caption-track-y:-0.5rem]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
|
||||
// Native caption track container
|
||||
'[&_video::-webkit-media-text-track-container]:transition-transform',
|
||||
'[&_video::-webkit-media-text-track-container]:duration-150',
|
||||
'[&_video::-webkit-media-text-track-container]:ease-out',
|
||||
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
|
||||
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
|
||||
'[&_video::-webkit-media-text-track-container]:scale-98',
|
||||
'[&_video::-webkit-media-text-track-container]:z-1',
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Container className={cn(root, className)} {...rest}>
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div
|
||||
{...props}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 text-white"
|
||||
>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
render={(props) => (
|
||||
<div {...props} className={bufferingIndicator}>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ErrorDialog
|
||||
aria-labelledby="media-error-title"
|
||||
aria-describedby="media-error-description"
|
||||
render={(props, { onDismiss }) => (
|
||||
<div
|
||||
{...props}
|
||||
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden flex-col gap-3 max-w-64 p-4 text-white text-sm pointer-events-auto',
|
||||
'group-data-[visible]/error:flex',
|
||||
'text-shadow-2xs text-shadow-black/50',
|
||||
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
|
||||
'starting:opacity-0 starting:scale-50',
|
||||
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1.5">
|
||||
<p id="media-error-title" className="font-semibold leading-tight">
|
||||
<div {...props} className={error.root}>
|
||||
<div className={error.dialog}>
|
||||
<div className={error.content}>
|
||||
<p id="media-error-title" className={error.title}>
|
||||
Something went wrong.
|
||||
</p>
|
||||
<p id="media-error-description">An error occurred while trying to play the video. Please try again.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 *:flex-1">
|
||||
<div className={error.actions}>
|
||||
<Button onClick={onDismiss}>OK</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,35 +142,15 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
|
||||
<Controls.Root
|
||||
data-controls="" // Used as a hook for Tailwind has-[] styles
|
||||
className={cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-0 inset-x-0',
|
||||
'pt-8 px-1.5 pb-1.5 flex items-center gap-2',
|
||||
'text-white z-10',
|
||||
// Transitions
|
||||
'will-change-[translate,filter,opacity]',
|
||||
'transition-[translate,filter,opacity] ease-out',
|
||||
'delay-0 duration-75',
|
||||
// Hidden state
|
||||
'not-data-[visible]:opacity-0 not-data-[visible]:translate-y-full',
|
||||
'not-data-[visible]:blur-[8px] not-data-[visible]:pointer-events-none',
|
||||
'not-data-[visible]:delay-500 not-data-[visible]:duration-500',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-[visible]:duration-100',
|
||||
'motion-reduce:not-data-[visible]:translate-y-0',
|
||||
'motion-reduce:not-data-[visible]:blur-none motion-reduce:not-data-[visible]:scale-100',
|
||||
// Wider container
|
||||
'@sm/media-root:pt-10 @sm/media-root:px-3 @sm/media-root:pb-3',
|
||||
'@sm/media-root:gap-3.5'
|
||||
)}
|
||||
className={controls}
|
||||
>
|
||||
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
|
||||
<span className={buttonGroup}>
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<PlayButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.play.button}>
|
||||
<RestartIcon className={cn(icon, iconState.play.restart)} />
|
||||
<PlayIcon className={cn(icon, iconState.play.play)} />
|
||||
<PauseIcon className={cn(icon, iconState.play.pause)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -362,12 +158,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
|
||||
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">
|
||||
{SEEK_TIME}
|
||||
</span>
|
||||
<Button variant="icon" {...props} className={seek.button}>
|
||||
<span className={iconContainer}>
|
||||
<SeekIcon className={cn(icon, iconFlipped)} />
|
||||
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
@@ -376,29 +170,21 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<Button variant="icon" {...props} className={seek.button}>
|
||||
<span className={iconContainer}>
|
||||
<SeekIcon className={icon} />
|
||||
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">
|
||||
{SEEK_TIME}
|
||||
</span>
|
||||
<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className={cn('flex flex-row-reverse items-center flex-1 gap-3', '@md/media-controls:flex-row')}>
|
||||
<Time.Group className="flex items-center gap-1">
|
||||
<Time.Value
|
||||
type="current"
|
||||
className={cn('hidden tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:inline')}
|
||||
/>
|
||||
<Time.Separator className={cn('hidden', '@md/media-controls:inline @md/media-controls:text-white/50')} />
|
||||
<Time.Value
|
||||
type="duration"
|
||||
className={cn('tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:text-white/50')}
|
||||
/>
|
||||
<span className={time.controls}>
|
||||
<Time.Group className={time.group}>
|
||||
<Time.Value type="current" className={time.current} />
|
||||
<Time.Separator className={time.separator} />
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
</Time.Group>
|
||||
|
||||
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
|
||||
@@ -410,41 +196,26 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
</TimeSlider.Root>
|
||||
</span>
|
||||
|
||||
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
|
||||
<span className={buttonGroup}>
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props} className="p-0">
|
||||
<span className="w-[4ch] tabular-nums">{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />}
|
||||
/>
|
||||
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.mute.button}>
|
||||
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
|
||||
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
|
||||
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popover.Popup
|
||||
className={cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 bg-transparent [--media-popover-side-offset:0.5rem]',
|
||||
// Volume popup
|
||||
'p-1',
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
|
||||
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
|
||||
'data-[instant]:duration-0'
|
||||
)}
|
||||
>
|
||||
<Popover.Popup className={cn(popup.base, popup.volume)}>
|
||||
<VolumeSlider.Root
|
||||
orientation="vertical"
|
||||
thumbAlignment="edge"
|
||||
@@ -459,9 +230,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
</Popover.Root>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<CaptionsButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.captions.button}>
|
||||
<CaptionsOffIcon className={cn(icon, iconState.captions.off)} />
|
||||
<CaptionsOnIcon className={cn(icon, iconState.captions.on)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -475,66 +247,25 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<FullscreenButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.fullscreen.button}>
|
||||
<FullscreenEnterIcon className={cn(icon, iconState.fullscreen.enter)} />
|
||||
<FullscreenExitIcon className={cn(icon, iconState.fullscreen.exit)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Controls.Root>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-[visible]/controls:-translate-y-10 peer-data-[visible]/controls:delay-25',
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[42ch] mx-auto text-center flex flex-col items-center">
|
||||
<span className={cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline',
|
||||
)}>
|
||||
{/* <div className={captions.root}>
|
||||
<div className={captions.container}>
|
||||
<span className={captions.cue}>
|
||||
An example cue
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Default: hidden
|
||||
'opacity-0',
|
||||
'bg-gradient-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
|
||||
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
|
||||
// Transitions
|
||||
'transition-[opacity,backdrop-filter] ease-out',
|
||||
'duration-500 delay-500',
|
||||
// Shown when controls visible
|
||||
'peer-data-[visible]/controls:opacity-100',
|
||||
'peer-data-[visible]/controls:duration-150',
|
||||
'peer-data-[visible]/controls:delay-0',
|
||||
// Shown when error visible (+ blur)
|
||||
'peer-data-[visible]/error:opacity-100',
|
||||
'peer-data-[visible]/error:duration-150',
|
||||
'peer-data-[visible]/error:delay-0',
|
||||
'peer-data-[visible]/error:backdrop-blur-[8px]',
|
||||
// Reduced motion
|
||||
'motion-reduce:duration-100'
|
||||
)}
|
||||
/>
|
||||
<div className={overlay} />
|
||||
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
CaptionsOffIcon,
|
||||
CaptionsOnIcon,
|
||||
@@ -18,7 +17,7 @@ import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
|
||||
import { CaptionsButton } from '@/ui/captions-button';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { ErrorDialog } from '@/ui/error-dialog';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
@@ -41,61 +40,17 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
|
||||
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { 'media-icon--hidden': !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { 'media-icon--hidden': ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { 'media-icon--hidden': paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { 'media-icon--hidden': !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
|
||||
const { active } = state;
|
||||
return (
|
||||
<>
|
||||
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
|
||||
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { 'media-icon--hidden': !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { 'media-icon--hidden': fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container className={cn('media-minimal-skin', className)} {...rest}>
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
render={(props) => (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ErrorDialog
|
||||
@@ -123,9 +78,11 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
<Controls.Root className="media-controls">
|
||||
<span className="media-button-group">
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<PlayButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--play">
|
||||
<RestartIcon className="media-icon media-icon--restart" />
|
||||
<PlayIcon className="media-icon media-icon--play" />
|
||||
<PauseIcon className="media-icon media-icon--pause" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -173,20 +130,18 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
|
||||
<span className="media-button-group">
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon media-button--playback-rate">
|
||||
<span>{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
|
||||
/>
|
||||
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--mute">
|
||||
<VolumeOffIcon className="media-icon media-icon--volume-off" />
|
||||
<VolumeLowIcon className="media-icon media-icon--volume-low" />
|
||||
<VolumeHighIcon className="media-icon media-icon--volume-high" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -203,9 +158,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
</Popover.Root>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<CaptionsButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--captions">
|
||||
<CaptionsOffIcon className="media-icon media-icon--captions-off" />
|
||||
<CaptionsOnIcon className="media-icon media-icon--captions-on" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -219,9 +175,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<FullscreenButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--fullscreen">
|
||||
<FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
|
||||
<FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,708 +1 @@
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin *,
|
||||
.media-default-skin *::before,
|
||||
.media-default-skin *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.media-default-skin img,
|
||||
.media-default-skin video,
|
||||
.media-default-skin svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-default-skin button {
|
||||
font: inherit;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.media-default-skin {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Root Container
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 2rem);
|
||||
background: oklch(0 0 0);
|
||||
font-family:
|
||||
Inter Variable,
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
|
||||
/* Inner border rings */
|
||||
.media-default-skin::before,
|
||||
.media-default-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin::before {
|
||||
inset: 1px;
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
|
||||
}
|
||||
.media-default-skin::after {
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-default-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Media Element
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin > video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Poster Image
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Overlay / Scrim
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklch(0 0 0 / 0.5), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0));
|
||||
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
|
||||
opacity: 0;
|
||||
transition-property: opacity, backdrop-filter;
|
||||
transition-duration: 300ms;
|
||||
transition-delay: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-overlay,
|
||||
.media-default-skin .media-error[data-visible] ~ .media-overlay {
|
||||
opacity: 1;
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] ~ .media-overlay {
|
||||
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buffering Indicator
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-buffering-indicator .media-surface {
|
||||
padding: 0.25rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Dialog
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-error {
|
||||
display: none;
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-error__dialog {
|
||||
display: none;
|
||||
transition-property: display, opacity, transform;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 100ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-timing-function: linear(
|
||||
0,
|
||||
0.034 1.5%,
|
||||
0.763 9.7%,
|
||||
1.066 13.9%,
|
||||
1.198 19.9%,
|
||||
1.184 21.8%,
|
||||
0.963 37.5%,
|
||||
0.997 50.9%,
|
||||
1
|
||||
);
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] .media-error__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 18rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 1.75rem;
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.875rem;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-error__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.375rem;
|
||||
}
|
||||
.media-default-skin .media-error__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.media-default-skin .media-error__description {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.media-default-skin .media-error__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-error__actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Surface (shared glass effect for tooltips, popovers, controls)
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 0 0 1px oklch(0 0 0 / 0.15),
|
||||
oklch(0 0 0 / 0.15) 0px 1px 3px 0px,
|
||||
oklch(0 0 0 / 0.15) 0px 1px 2px -1px;
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(0 0 0 / 0.7);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(0 0 0),
|
||||
0 0 0 1px oklch(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(0 0 0 / 0.9);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(0 0 0),
|
||||
0 0 0 1px oklch(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Controls
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
inset-inline: 0.75rem;
|
||||
z-index: 10;
|
||||
container: media-controls / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
padding: 0.175rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
color: oklch(1 0 0);
|
||||
will-change: scale, transform, filter, opacity;
|
||||
transition-property: scale, transform, filter, opacity;
|
||||
transition-duration: 100ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
opacity: 0;
|
||||
scale: 0.9;
|
||||
filter: blur(8px);
|
||||
transition-duration: 300ms;
|
||||
transition-delay: 500ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
scale: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-default-skin .media-controls {
|
||||
gap: 0.125rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Time Display
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-time {
|
||||
container: media-time / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-time__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
|
||||
}
|
||||
.media-default-skin .media-time .media-time__value:first-child {
|
||||
display: none;
|
||||
}
|
||||
@container media-time (width > 18rem) {
|
||||
.media-default-skin .media-time .media-time__value:first-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
/* Base button */
|
||||
.media-default-skin .media-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: oklch(1 0 0);
|
||||
border: none;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
color: oklch(0 0 0);
|
||||
font-weight: 500;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.media-default-skin .media-button:focus-visible {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-default-skin .media-button[disabled] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icon button variant */
|
||||
.media-default-skin .media-button--icon {
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
.media-default-skin .media-button--icon:hover,
|
||||
.media-default-skin .media-button--icon:focus-visible,
|
||||
.media-default-skin .media-button--icon[aria-expanded="true"] {
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
color: oklch(1 0 0);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-default-skin .media-button--icon .media-icon {
|
||||
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* Seek button variant — hidden at small sizes */
|
||||
@container media-controls (width < 28rem) {
|
||||
.media-default-skin .media-button--seek {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-default-skin .media-icon {
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: display, opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-default-skin .media-icon--hidden {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.media-default-skin .media-icon--flipped {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
/* Seek icon label positioning */
|
||||
.media-default-skin .media-icon--seek ~ .media-icon__label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-default-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
|
||||
right: unset;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation */
|
||||
.media-default-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation */
|
||||
.media-default-skin .media-slider[data-orientation="vertical"] {
|
||||
width: 1.25rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.media-default-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
|
||||
user-select: none;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.25rem;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.media-default-skin .media-slider__thumb {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background-color: oklch(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklch(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklch(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
transition-property: opacity, height, width, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-default-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-default-skin .media-slider:active .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.media-default-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb:focus-visible,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Shared track fills */
|
||||
.media-default-skin .media-slider__buffer,
|
||||
.media-default-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-default-skin .media-slider__buffer {
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-buffer);
|
||||
transition-property: width;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"] {
|
||||
height: var(--media-slider-buffer);
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
/* Fill */
|
||||
.media-default-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
.media-default-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Popups & Animations
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-popup-animation {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
filter: blur(0px);
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-starting-style],
|
||||
.media-default-skin .media-popup-animation[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-default-skin .media-popup {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-popup--volume {
|
||||
padding: 0.625rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tooltips
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-default-skin .media-tooltip-popup {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Captions
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-captions {
|
||||
position: absolute;
|
||||
inset: auto 1rem 1.5rem 1rem;
|
||||
z-index: 20;
|
||||
font-size: 1rem;
|
||||
text-wrap: balance;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-captions__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 42ch;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.media-default-skin .media-captions__text {
|
||||
display: block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
color: oklch(1 0 0);
|
||||
text-shadow:
|
||||
0 0 1px oklch(0 0 0 / 0.7),
|
||||
0 0 8px oklch(0 0 0 / 0.7);
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-default-skin .media-captions__text {
|
||||
background: oklch(0 0 0 / 0.7);
|
||||
text-shadow: none;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-captions__text > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Responsive caption sizing */
|
||||
@container media-root (width > 20rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Caption shifting styles (custom and native) */
|
||||
.media-default-skin {
|
||||
--media-caption-track-delay: 600ms;
|
||||
--media-caption-track-y: -0.5rem;
|
||||
}
|
||||
.media-default-skin:has(.media-controls[data-visible]) {
|
||||
--media-caption-track-delay: 25ms;
|
||||
--media-caption-track-y: -3.5rem;
|
||||
}
|
||||
.media-default-skin .media-captions,
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
/* NOTE: The delay must account for the controls delay/duration */
|
||||
transition: transform 150ms ease-out;
|
||||
transition-delay: var(--media-caption-track-delay);
|
||||
}
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
transform: translateY(var(--media-caption-track-y)) scale(0.98);
|
||||
z-index: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* When controls are visible, shift captions up to avoid overlap */
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-captions {
|
||||
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-captions,
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
}
|
||||
@import "@videojs/skins/video/default.css";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
CaptionsOffIcon,
|
||||
CaptionsOnIcon,
|
||||
@@ -14,11 +13,28 @@ import {
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@videojs/icons/react';
|
||||
import {
|
||||
bufferingIndicator,
|
||||
button,
|
||||
controls,
|
||||
error,
|
||||
icon,
|
||||
iconContainer,
|
||||
iconFlipped,
|
||||
iconState,
|
||||
overlay,
|
||||
playbackRate,
|
||||
popup,
|
||||
root,
|
||||
seek,
|
||||
slider,
|
||||
time,
|
||||
} from '@videojs/skins/video/default.tailwind';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
|
||||
import { CaptionsButton } from '@/ui/captions-button';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { ErrorDialog } from '@/ui/error-dialog';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
@@ -35,29 +51,6 @@ import type { VideoSkinProps } from './skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
|
||||
/* ------------------------------------ Reused fragments ------------------------------------- */
|
||||
|
||||
const surface = cn(
|
||||
'bg-white/10',
|
||||
'backdrop-blur-3xl backdrop-brightness-90 backdrop-saturate-150',
|
||||
// Border and shadow
|
||||
'ring ring-white/5 ring-inset shadow-sm shadow-black/15',
|
||||
// Border to enhance contrast on lighter videos
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
|
||||
// Reduced transparency for users with preference
|
||||
'[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
|
||||
// High contrast mode
|
||||
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
|
||||
);
|
||||
|
||||
const icon = cn(
|
||||
'[grid-area:1/1] size-4.5 shrink-0',
|
||||
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
const iconHidden = 'hidden opacity-0';
|
||||
|
||||
/* --------------------------------------- Components ---------------------------------------- */
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
|
||||
@@ -68,113 +61,21 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { varian
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
// Shared
|
||||
'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 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',
|
||||
'focus-visible:bg-white/10 focus-visible:text-white',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
|
||||
'aria-expanded:bg-white/10 aria-expanded:text-white'
|
||||
)
|
||||
: cn(
|
||||
'flex py-2 px-4 bg-white rounded-full',
|
||||
'text-black font-medium',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2'
|
||||
),
|
||||
className
|
||||
)}
|
||||
className={cn(button.base, variant === 'icon' ? button.icon : button.default, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { [iconHidden]: !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { [iconHidden]: ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { [iconHidden]: paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { [iconHidden]: !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { [iconHidden]: muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
|
||||
const { active } = state;
|
||||
return (
|
||||
<>
|
||||
<CaptionsOffIcon {...rest} className={cn(className, { [iconHidden]: active })} />
|
||||
<CaptionsOnIcon {...rest} className={cn(className, { [iconHidden]: !active })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { [iconHidden]: !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { [iconHidden]: fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
|
||||
});
|
||||
|
||||
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
|
||||
});
|
||||
|
||||
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
|
||||
@@ -184,28 +85,7 @@ const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: '
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute rounded-[inherit] pointer-events-none',
|
||||
{
|
||||
'bg-white': type === 'fill',
|
||||
'bg-white/20 duration-250 ease-out': type === 'buffer',
|
||||
},
|
||||
// Horizontal
|
||||
`data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0`,
|
||||
{
|
||||
'data-[orientation=horizontal]:w-(--media-slider-fill)': type === 'fill',
|
||||
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)':
|
||||
type === 'buffer',
|
||||
},
|
||||
// Vertical
|
||||
`data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0`,
|
||||
{
|
||||
'data-[orientation=vertical]:h-(--media-slider-fill)': type === 'fill',
|
||||
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)':
|
||||
type === 'buffer',
|
||||
},
|
||||
className
|
||||
)}
|
||||
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -218,27 +98,7 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
|
||||
// Visibility
|
||||
persistent
|
||||
? 'size-3'
|
||||
: cn(
|
||||
'size-2.5',
|
||||
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
|
||||
'group-active/slider:size-3'
|
||||
),
|
||||
className
|
||||
)}
|
||||
className={cn(slider.thumb.base, persistent ? slider.thumb.persistent : slider.thumb.interactive, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -250,93 +110,32 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={cn(
|
||||
// Layout & containment
|
||||
'relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-[var(--media-border-radius,2rem)] bg-black',
|
||||
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Resets
|
||||
'**:box-border **:m-0',
|
||||
'[&_button]:font-[inherit]',
|
||||
'motion-safe:[interpolate-size:allow-keywords]',
|
||||
// Inner highlight ring (::before)
|
||||
'before:absolute before:pointer-events-none before:rounded-[inherit] before:z-10',
|
||||
'before:inset-px before:ring-1 before:ring-inset before:ring-white/15',
|
||||
// Outer border ring (::after)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/10',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-[250ms]',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Caption track CSS variables (consumed by native track container below)
|
||||
'[--media-caption-track-delay:600ms]',
|
||||
'[--media-caption-track-y:-0.5rem]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
|
||||
// Native caption track container
|
||||
'[&_video::-webkit-media-text-track-container]:transition-transform',
|
||||
'[&_video::-webkit-media-text-track-container]:duration-150',
|
||||
'[&_video::-webkit-media-text-track-container]:ease-out',
|
||||
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
|
||||
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
|
||||
'[&_video::-webkit-media-text-track-container]:scale-98',
|
||||
'[&_video::-webkit-media-text-track-container]:z-1',
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Container className={cn(root, className)} {...rest}>
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div
|
||||
{...props}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10 text-white"
|
||||
>
|
||||
<div className={cn('p-1 rounded-full', surface)}>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
render={(props) => (
|
||||
<div {...props} className={bufferingIndicator.root}>
|
||||
<div className={bufferingIndicator.container}>
|
||||
<SpinnerIcon className={icon} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ErrorDialog
|
||||
aria-labelledby="media-error-title"
|
||||
aria-describedby="media-error-description"
|
||||
render={(props, { onDismiss }) => (
|
||||
<div
|
||||
{...props}
|
||||
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white text-sm pointer-events-auto',
|
||||
'group-data-[visible]/error:flex',
|
||||
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
|
||||
'starting:opacity-0 starting:scale-50',
|
||||
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]',
|
||||
surface
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-2 pt-2 pb-1.5">
|
||||
<p id="media-error-title" className="font-semibold leading-tight">
|
||||
<div {...props} className={error.root}>
|
||||
<div className={error.dialog}>
|
||||
<div className={error.content}>
|
||||
<p id="media-error-title" className={error.title}>
|
||||
Something went wrong.
|
||||
</p>
|
||||
<p id="media-error-description" className="opacity-70">
|
||||
<p id="media-error-description" className={error.description}>
|
||||
An error occurred while trying to play the video. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 *:flex-1">
|
||||
<div className={error.actions}>
|
||||
<Button onClick={onDismiss}>OK</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,35 +145,14 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
|
||||
<Controls.Root
|
||||
data-controls="" // Used as a hook for Tailwind has-[] styles
|
||||
className={cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Surface
|
||||
surface,
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-3 inset-x-3',
|
||||
'p-[0.175rem] flex items-center gap-[0.075rem]',
|
||||
'text-white rounded-full z-10',
|
||||
// Transitions
|
||||
'will-change-[scale,transform,filter,opacity]',
|
||||
'transition-[scale,transform,filter,opacity] ease-out',
|
||||
'delay-0 duration-100 origin-bottom',
|
||||
// Hidden state
|
||||
'not-data-[visible]:pointer-events-none not-data-[visible]:blur-[8px]',
|
||||
'not-data-[visible]:scale-90 not-data-[visible]:opacity-0',
|
||||
'not-data-[visible]:delay-500 not-data-[visible]:duration-300',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-[visible]:duration-100',
|
||||
'motion-reduce:not-data-[visible]:blur-none',
|
||||
'motion-reduce:not-data-[visible]:scale-100',
|
||||
// Wider container
|
||||
'@2xl/media-root:p-1 @2xl/media-root:gap-0.5'
|
||||
)}
|
||||
className={controls}
|
||||
>
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<PlayButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.play.button}>
|
||||
<RestartIcon className={cn(icon, iconState.play.restart)} />
|
||||
<PlayIcon className={cn(icon, iconState.play.play)} />
|
||||
<PauseIcon className={cn(icon, iconState.play.pause)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -382,10 +160,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
<SeekButton
|
||||
seconds={-SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
|
||||
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">{SEEK_TIME}</span>
|
||||
<Button variant="icon" {...props} className={seek.button}>
|
||||
<span className={iconContainer}>
|
||||
<SeekIcon className={cn(icon, iconFlipped)} />
|
||||
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
@@ -394,20 +172,17 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
<SeekButton
|
||||
seconds={SEEK_TIME}
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
|
||||
<span className="relative">
|
||||
<Button variant="icon" {...props} className={seek.button}>
|
||||
<span className={iconContainer}>
|
||||
<SeekIcon className={icon} />
|
||||
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">{SEEK_TIME}</span>
|
||||
<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Time.Group className="@container/media-time flex items-center flex-1 gap-3 px-2">
|
||||
<Time.Value
|
||||
type="current"
|
||||
className="hidden @2xs/media-time:block text-shadow-2xs text-shadow-black/25 tabular-nums"
|
||||
/>
|
||||
<Time.Group className={time.group}>
|
||||
<Time.Value type="current" className={time.current} />
|
||||
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
|
||||
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
|
||||
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
|
||||
@@ -415,45 +190,26 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
</TimeSlider.Track>
|
||||
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
</Time.Group>
|
||||
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<span className="w-[4ch] tabular-nums">{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PlaybackRateButton render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />} />
|
||||
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.mute.button}>
|
||||
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
|
||||
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
|
||||
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popover.Popup
|
||||
className={cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 [--media-popover-side-offset:0.5rem]',
|
||||
// Volume popup
|
||||
'py-2.5 px-1 rounded-full',
|
||||
// Surface
|
||||
surface,
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
|
||||
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
|
||||
'data-[instant]:duration-0'
|
||||
)}
|
||||
>
|
||||
<Popover.Popup className={cn(popup.base, popup.volume)}>
|
||||
<VolumeSlider.Root
|
||||
orientation="vertical"
|
||||
thumbAlignment="edge"
|
||||
@@ -468,9 +224,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
</Popover.Root>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<CaptionsButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.captions.button}>
|
||||
<CaptionsOffIcon className={cn(icon, iconState.captions.off)} />
|
||||
<CaptionsOnIcon className={cn(icon, iconState.captions.on)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -484,65 +241,24 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<FullscreenButtonIcon state={state} className={icon} />
|
||||
render={(props) => (
|
||||
<Button variant="icon" {...props} className={iconState.fullscreen.button}>
|
||||
<FullscreenEnterIcon className={cn(icon, iconState.fullscreen.enter)} />
|
||||
<FullscreenExitIcon className={cn(icon, iconState.fullscreen.exit)} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Controls.Root>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-[visible]/controls:-translate-y-12 peer-data-[visible]/controls:delay-25',
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[42ch] mx-auto text-center flex flex-col items-center">
|
||||
<span className={cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline',
|
||||
)}>
|
||||
{/* <div className={captions.root}>
|
||||
<div className={captions.container}>
|
||||
<span className={captions.cue}>
|
||||
An example cue
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Default: hidden
|
||||
'opacity-0',
|
||||
'bg-gradient-to-t from-black/50 via-black/30 to-transparent',
|
||||
'backdrop-blur-[0px] backdrop-saturate-120 backdrop-brightness-90',
|
||||
// Transitions
|
||||
'transition-[opacity,backdrop-filter] ease-out',
|
||||
'duration-300 delay-500',
|
||||
// Shown when controls visible
|
||||
'peer-data-[visible]/controls:opacity-100',
|
||||
'peer-data-[visible]/controls:duration-150',
|
||||
'peer-data-[visible]/controls:delay-0',
|
||||
// Shown when error visible (+ blur)
|
||||
'peer-data-[visible]/error:opacity-100',
|
||||
'peer-data-[visible]/error:duration-150',
|
||||
'peer-data-[visible]/error:delay-0',
|
||||
'peer-data-[visible]/error:backdrop-blur-[8px]',
|
||||
// Reduced motion
|
||||
'motion-reduce:duration-100'
|
||||
)}
|
||||
/>
|
||||
<div className={overlay} />
|
||||
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
|
||||
import {
|
||||
CaptionsOffIcon,
|
||||
CaptionsOnIcon,
|
||||
@@ -18,7 +17,7 @@ import { cn } from '@videojs/utils/style';
|
||||
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
|
||||
import { Container } from '@/player/context';
|
||||
import { BufferingIndicator } from '@/ui/buffering-indicator';
|
||||
import { CaptionsButton, type CaptionsButtonState } from '@/ui/captions-button';
|
||||
import { CaptionsButton } from '@/ui/captions-button';
|
||||
import { Controls } from '@/ui/controls';
|
||||
import { ErrorDialog } from '@/ui/error-dialog';
|
||||
import { FullscreenButton } from '@/ui/fullscreen-button';
|
||||
@@ -41,63 +40,19 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
|
||||
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
|
||||
});
|
||||
|
||||
function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
|
||||
const { ended, paused } = state;
|
||||
return (
|
||||
<>
|
||||
<RestartIcon {...rest} className={cn(className, { 'media-icon--hidden': !ended })} />
|
||||
<PlayIcon {...rest} className={cn(className, { 'media-icon--hidden': ended || !paused })} />
|
||||
<PauseIcon {...rest} className={cn(className, { 'media-icon--hidden': paused })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
|
||||
const { muted, volumeLevel } = state;
|
||||
return (
|
||||
<>
|
||||
<VolumeOffIcon {...rest} className={cn(className, { 'media-icon--hidden': !muted })} />
|
||||
<VolumeLowIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel !== 'low' })} />
|
||||
<VolumeHighIcon {...rest} className={cn(className, { 'media-icon--hidden': muted || volumeLevel === 'low' })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsButtonIcon({ state, className, ...rest }: { state: CaptionsButtonState } & ComponentProps<'svg'>) {
|
||||
const { active } = state;
|
||||
return (
|
||||
<>
|
||||
<CaptionsOffIcon {...rest} className={cn(className, { 'media-icon--hidden': active })} />
|
||||
<CaptionsOnIcon {...rest} className={cn(className, { 'media-icon--hidden': !active })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
|
||||
const { fullscreen } = state;
|
||||
return (
|
||||
<>
|
||||
<FullscreenExitIcon {...rest} className={cn(className, { 'media-icon--hidden': !fullscreen })} />
|
||||
<FullscreenEnterIcon {...rest} className={cn(className, { 'media-icon--hidden': fullscreen })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Container className={cn('media-default-skin', className)} {...rest}>
|
||||
<BufferingIndicator
|
||||
render={(props, state) =>
|
||||
state.visible ? (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<div className="media-surface">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
render={(props) => (
|
||||
<div {...props} className="media-buffering-indicator">
|
||||
<div className="media-surface">
|
||||
<SpinnerIcon className="media-icon" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ErrorDialog
|
||||
@@ -124,9 +79,11 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
|
||||
<Controls.Root className="media-surface media-controls">
|
||||
<PlayButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<PlayButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--play">
|
||||
<RestartIcon className="media-icon media-icon--restart" />
|
||||
<PlayIcon className="media-icon media-icon--play" />
|
||||
<PauseIcon className="media-icon media-icon--pause" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -168,20 +125,18 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
</Time.Group>
|
||||
|
||||
<PlaybackRateButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon media-button--playback-rate">
|
||||
<span>{state.rate}×</span>
|
||||
</Button>
|
||||
)}
|
||||
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
|
||||
/>
|
||||
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--mute">
|
||||
<VolumeOffIcon className="media-icon media-icon--volume-off" />
|
||||
<VolumeLowIcon className="media-icon media-icon--volume-low" />
|
||||
<VolumeHighIcon className="media-icon media-icon--volume-high" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -198,9 +153,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
</Popover.Root>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<CaptionsButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--captions">
|
||||
<CaptionsOffIcon className="media-icon media-icon--captions-off" />
|
||||
<CaptionsOnIcon className="media-icon media-icon--captions-on" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -214,9 +170,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
/>
|
||||
|
||||
<FullscreenButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<FullscreenButtonIcon state={state} className="media-icon" />
|
||||
render={(props) => (
|
||||
<Button {...props} className="media-button--icon media-button--fullscreen">
|
||||
<FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
|
||||
<FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { globSync } from 'node:fs';
|
||||
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { UserConfig } from 'tsdown';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
@@ -6,6 +8,8 @@ type BuildMode = 'dev' | 'default';
|
||||
|
||||
const buildModes: BuildMode[] = ['dev', 'default'];
|
||||
|
||||
const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src');
|
||||
|
||||
const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
entry: 'src/**/index.{ts,tsx}',
|
||||
platform: 'browser',
|
||||
@@ -14,6 +18,7 @@ const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
clean: true,
|
||||
hash: false,
|
||||
unbundle: true,
|
||||
noExternal: [/^@videojs\/skins/],
|
||||
alias: {
|
||||
'@': new URL('./src', import.meta.url).pathname,
|
||||
},
|
||||
@@ -22,21 +27,36 @@ const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
__DEV__: mode === 'dev' ? 'true' : 'false',
|
||||
},
|
||||
dts: mode === 'dev',
|
||||
copy: [
|
||||
{
|
||||
from: 'src/**/*.css',
|
||||
to: `dist/${mode}`,
|
||||
flatten: false,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: 'watch-css',
|
||||
name: 'copy-css',
|
||||
buildStart() {
|
||||
const cssFiles = globSync('src/**/*.css');
|
||||
for (const file of cssFiles) {
|
||||
for (const file of globSync('src/**/*.css')) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
for (const file of globSync(join(skinsDir, '**/*.css'))) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
},
|
||||
writeBundle() {
|
||||
for (const file of globSync('src/**/*.css')) {
|
||||
let content = readFileSync(file, 'utf-8');
|
||||
|
||||
// Resolve @import from @videojs/skins by inlining the CSS (including nested relative imports)
|
||||
content = content.replace(/@import\s+['"]@videojs\/skins\/([^'"]+)['"]\s*;/g, (_, importPath) => {
|
||||
const skinsFile = resolve(skinsDir, importPath);
|
||||
let skinsContent = readFileSync(skinsFile, 'utf-8');
|
||||
// Resolve relative @import within the skins CSS
|
||||
skinsContent = skinsContent.replace(/@import\s+['"]\.\/([^'"]+)['"]\s*;/g, (__, relPath) =>
|
||||
readFileSync(resolve(dirname(skinsFile), relPath), 'utf-8')
|
||||
);
|
||||
return skinsContent;
|
||||
});
|
||||
|
||||
const outFile = join(`dist/${mode}`, file.replace(/^src\//, ''));
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
writeFileSync(outFile, content);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,8 +7,9 @@ Vite-based playground for testing and developing Video.js 10 integrations. Each
|
||||
| Name | Description |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| `core` | Framework-agnostic core API |
|
||||
| `html` | Web player with HTML custom elements |
|
||||
| `html` | HTML player with skin switching |
|
||||
| `html-background` | Full-screen background video (HTML) |
|
||||
| `html-tailwind` | HTML player styled with Tailwind CSS |
|
||||
| `react` | React player with skin switching |
|
||||
| `react-tailwind` | React player styled with Tailwind CSS |
|
||||
| `react-background` | Full-screen background video (React) |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@videojs/html": "workspace:*",
|
||||
"@videojs/icons": "workspace:*",
|
||||
"@videojs/react": "workspace:*",
|
||||
"@videojs/skins": "workspace:*",
|
||||
"@videojs/store": "workspace:*",
|
||||
"@videojs/utils": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<li><a href="/core/" class="text-blue-600 underline hover:text-blue-800">Core</a></li>
|
||||
<li><a href="/html/" class="text-blue-600 underline hover:text-blue-800">HTML</a></li>
|
||||
<li><a href="/html-background/" class="text-blue-600 underline hover:text-blue-800">HTML Background</a></li>
|
||||
<li><a href="/html-tailwind/" class="text-blue-600 underline hover:text-blue-800">HTML & Tailwind</a></li>
|
||||
<li><a href="/react/" class="text-blue-600 underline hover:text-blue-800">React</a></li>
|
||||
<li><a href="/react-background/" class="text-blue-600 underline hover:text-blue-800">React Background</a></li>
|
||||
<li><a href="/react-tailwind/" class="text-blue-600 underline hover:text-blue-800">React & Tailwind</a></li>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sandbox — HTML & Tailwind</title>
|
||||
<link rel="stylesheet" href="../styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col justify-center items-center gap-4 my-4">
|
||||
<select id="skin-select" aria-label="Skin"></select>
|
||||
<div id="player" class="w-full aspect-video max-w-4xl mx-auto"></div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
// HTML & Tailwind sandbox — Web player with Tailwind skins
|
||||
// http://localhost:5173/html-tailwind/
|
||||
|
||||
import '@videojs/html/video/player';
|
||||
import '@videojs/html/video/skin.tailwind';
|
||||
import '@videojs/html/video/minimal-skin.tailwind';
|
||||
import { SKINS } from '../constants';
|
||||
import type { Skin } from '../types';
|
||||
|
||||
const html = String.raw;
|
||||
|
||||
const VIDEO_SRC = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4';
|
||||
|
||||
const skinTags: Record<Skin, string> = {
|
||||
default: 'video-skin-tailwind',
|
||||
minimal: 'video-minimal-skin-tailwind',
|
||||
};
|
||||
|
||||
function render(skin: Skin) {
|
||||
const tag = skinTags[skin];
|
||||
|
||||
document.getElementById('player')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag}>
|
||||
<video slot="media" src="${VIDEO_SRC}"></video>
|
||||
</${tag}>
|
||||
</video-player>
|
||||
`;
|
||||
}
|
||||
|
||||
// Skin switcher
|
||||
const select = document.getElementById('skin-select') as HTMLSelectElement;
|
||||
for (const skin of SKINS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = skin;
|
||||
option.textContent = skin;
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener('change', () => render(select.value as Skin));
|
||||
|
||||
// Initial render
|
||||
render('default');
|
||||
@@ -7,7 +7,10 @@
|
||||
<link rel="stylesheet" href="../styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div class="flex flex-col justify-center items-center gap-4 my-4">
|
||||
<select id="skin-select" aria-label="Skin"></select>
|
||||
<div id="player" class="w-full aspect-video max-w-4xl mx-auto"></div>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
// HTML sandbox — Web player (DOM/Browser)
|
||||
// HTML sandbox — Web player with skin switching
|
||||
// http://localhost:5173/html/
|
||||
|
||||
import '@videojs/html/video/player';
|
||||
import '@videojs/html/video/skin';
|
||||
import '@videojs/html/video/minimal-skin';
|
||||
import { SKINS } from '../constants';
|
||||
import type { Skin } from '../types';
|
||||
|
||||
const html = String.raw;
|
||||
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<media-container>
|
||||
<video src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4"></video>
|
||||
</media-container>
|
||||
</video-player>
|
||||
`;
|
||||
const VIDEO_SRC = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4';
|
||||
|
||||
const skinTags: Record<Skin, string> = {
|
||||
default: 'video-skin',
|
||||
minimal: 'video-minimal-skin',
|
||||
};
|
||||
|
||||
const stylesheets: Record<Skin, string> = {
|
||||
default: new URL('@videojs/html/video/skin.css', import.meta.url).href,
|
||||
minimal: new URL('@videojs/html/video/minimal-skin.css', import.meta.url).href,
|
||||
};
|
||||
|
||||
let currentLink: HTMLLinkElement | null = null;
|
||||
|
||||
function loadStylesheet(url: string) {
|
||||
currentLink?.remove();
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
currentLink = link;
|
||||
}
|
||||
|
||||
function render(skin: Skin) {
|
||||
const tag = skinTags[skin];
|
||||
|
||||
loadStylesheet(stylesheets[skin]);
|
||||
|
||||
document.getElementById('player')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag}>
|
||||
<video slot="media" src="${VIDEO_SRC}"></video>
|
||||
</${tag}>
|
||||
</video-player>
|
||||
`;
|
||||
}
|
||||
|
||||
// Skin switcher
|
||||
const select = document.getElementById('skin-select') as HTMLSelectElement;
|
||||
for (const skin of SKINS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = skin;
|
||||
option.textContent = skin;
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener('change', () => render(select.value as Skin));
|
||||
|
||||
// Initial render
|
||||
render('default');
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<li><a href="/core/" class="text-blue-600 underline hover:text-blue-800">Core</a></li>
|
||||
<li><a href="/html/" class="text-blue-600 underline hover:text-blue-800">HTML</a></li>
|
||||
<li><a href="/html-background/" class="text-blue-600 underline hover:text-blue-800">HTML Background</a></li>
|
||||
<li><a href="/html-tailwind/" class="text-blue-600 underline hover:text-blue-800">HTML & Tailwind</a></li>
|
||||
<li><a href="/react/" class="text-blue-600 underline hover:text-blue-800">React</a></li>
|
||||
<li><a href="/react-background/" class="text-blue-600 underline hover:text-blue-800">React Background</a></li>
|
||||
<li><a href="/react-tailwind/" class="text-blue-600 underline hover:text-blue-800">React & Tailwind</a></li>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@source "../templates";
|
||||
/* NOTE: This is non-standard since you'd usually eject the Tailwind skin into your own project and not import it from node_modules. */
|
||||
@source "../node_modules/@videojs/skins";
|
||||
@source "../node_modules/@videojs/html";
|
||||
@source "../node_modules/@videojs/react";
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
core: resolve(__dirname, 'src/core/index.html'),
|
||||
html: resolve(__dirname, 'src/html/index.html'),
|
||||
'html-background': resolve(__dirname, 'src/html-background/index.html'),
|
||||
'html-tailwind': resolve(__dirname, 'src/html-tailwind/index.html'),
|
||||
react: resolve(__dirname, 'src/react/index.html'),
|
||||
'react-background': resolve(__dirname, 'src/react-background/index.html'),
|
||||
'react-tailwind': resolve(__dirname, 'src/react-tailwind/index.html'),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@videojs/skins",
|
||||
"type": "module",
|
||||
"version": "10.0.0-alpha.4",
|
||||
"private": true,
|
||||
"description": "Shared skin CSS and Tailwind classname tokens for Video.js",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/videojs/v10",
|
||||
"directory": "packages/skins"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./*.css": {
|
||||
"style": "./dist/default/*.css",
|
||||
"default": "./dist/default/*.css"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/dev/*.d.ts",
|
||||
"development": "./dist/dev/*.js",
|
||||
"default": "./dist/default/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch ./src --no-clean",
|
||||
"dev": "pnpm run build:watch",
|
||||
"clean": "rm -rf dist types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@videojs/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.20.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"keywords": [
|
||||
"media",
|
||||
"skins",
|
||||
"css",
|
||||
"tailwind",
|
||||
"videojs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.media-skin {
|
||||
color: var(--media-color, red);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.media-background-skin {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-background-skin > video,
|
||||
.media-background-skin > .media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: inherit;
|
||||
}
|
||||
|
||||
.media-background-skin > :where(img, picture) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: inherit;
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
@import "./icon-state.css";
|
||||
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin *,
|
||||
.media-default-skin *::before,
|
||||
.media-default-skin *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.media-default-skin img,
|
||||
.media-default-skin video,
|
||||
.media-default-skin svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-default-skin button {
|
||||
font: inherit;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.media-default-skin {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Root Container
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: block;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 2rem);
|
||||
background: oklch(0 0 0);
|
||||
font-family:
|
||||
Inter Variable,
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
|
||||
/* Inner border rings */
|
||||
.media-default-skin::before,
|
||||
.media-default-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin::before {
|
||||
inset: 1px;
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
|
||||
}
|
||||
.media-default-skin::after {
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-default-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Media Element
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin > video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Poster Image
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Overlay / Scrim
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklch(0 0 0 / 0.5), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0));
|
||||
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
|
||||
opacity: 0;
|
||||
transition-property: opacity, backdrop-filter;
|
||||
transition-duration: 300ms;
|
||||
transition-delay: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-overlay,
|
||||
.media-default-skin .media-error[data-visible] ~ .media-overlay {
|
||||
opacity: 1;
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] ~ .media-overlay {
|
||||
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buffering Indicator
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-buffering-indicator[data-visible] {
|
||||
display: flex;
|
||||
}
|
||||
.media-default-skin .media-buffering-indicator .media-surface {
|
||||
padding: 0.25rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Dialog
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-error {
|
||||
display: none;
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-error__dialog {
|
||||
display: none;
|
||||
transition-property: display, opacity, transform;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 100ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-timing-function: linear(
|
||||
0,
|
||||
0.034 1.5%,
|
||||
0.763 9.7%,
|
||||
1.066 13.9%,
|
||||
1.198 19.9%,
|
||||
1.184 21.8%,
|
||||
0.963 37.5%,
|
||||
0.997 50.9%,
|
||||
1
|
||||
);
|
||||
}
|
||||
.media-default-skin .media-error[data-visible] .media-error__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 18rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 1.75rem;
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.875rem;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-error__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.375rem;
|
||||
}
|
||||
.media-default-skin .media-error__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.media-default-skin .media-error__description {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.media-default-skin .media-error__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-error__actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Surface (shared glass effect for tooltips, popovers, controls)
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 0 0 1px oklch(0 0 0 / 0.15),
|
||||
oklch(0 0 0 / 0.15) 0px 1px 3px 0px,
|
||||
oklch(0 0 0 / 0.15) 0px 1px 2px -1px;
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(0 0 0 / 0.7);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(0 0 0),
|
||||
0 0 0 1px oklch(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-default-skin .media-surface {
|
||||
background-color: oklch(0 0 0 / 0.9);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(0 0 0),
|
||||
0 0 0 1px oklch(1 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Controls
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
inset-inline: 0.75rem;
|
||||
z-index: 10;
|
||||
container: media-controls / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
padding: 0.175rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
color: oklch(1 0 0);
|
||||
will-change: scale, transform, filter, opacity;
|
||||
transition-property: scale, transform, filter, opacity;
|
||||
transition-duration: 100ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
opacity: 0;
|
||||
scale: 0.9;
|
||||
filter: blur(8px);
|
||||
transition-duration: 300ms;
|
||||
transition-delay: 500ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-controls:not([data-visible]) {
|
||||
scale: 1;
|
||||
filter: blur(0);
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-default-skin .media-controls {
|
||||
gap: 0.125rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Time Display
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-time {
|
||||
container: media-time / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-time__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
|
||||
}
|
||||
.media-default-skin .media-time .media-time__value:first-child {
|
||||
display: none;
|
||||
}
|
||||
@container media-time (width > 18rem) {
|
||||
.media-default-skin .media-time .media-time__value:first-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
/* Base button */
|
||||
.media-default-skin .media-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: oklch(1 0 0);
|
||||
border: none;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
color: oklch(0 0 0);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.media-default-skin .media-button:focus-visible {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-default-skin .media-button[disabled] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icon button variant */
|
||||
.media-default-skin .media-button--icon {
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
.media-default-skin .media-button--icon:hover,
|
||||
.media-default-skin .media-button--icon:focus-visible,
|
||||
.media-default-skin .media-button--icon[aria-expanded="true"] {
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
color: oklch(1 0 0);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-default-skin .media-button--icon .media-icon {
|
||||
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* Seek button */
|
||||
.media-default-skin .media-button--seek .media-icon__label {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-default-skin .media-button--seek:has(.media-icon--flipped) .media-icon__label {
|
||||
right: unset;
|
||||
left: -1px;
|
||||
}
|
||||
@container media-controls (width < 28rem) {
|
||||
.media-default-skin .media-button--seek {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback rate button */
|
||||
.media-default-skin .media-button--playback-rate {
|
||||
padding: 0;
|
||||
}
|
||||
.media-default-skin .media-button--playback-rate::after {
|
||||
content: attr(data-rate) "\00D7";
|
||||
width: 4ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Icons
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-default-skin .media-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: display, opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-default-skin .media-icon--flipped {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation */
|
||||
.media-default-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation */
|
||||
.media-default-skin .media-slider[data-orientation="vertical"] {
|
||||
width: 1.25rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.media-default-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
|
||||
user-select: none;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.25rem;
|
||||
}
|
||||
.media-default-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.media-default-skin .media-slider__thumb {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background-color: oklch(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklch(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklch(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
transition-property: opacity, height, width, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-default-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-default-skin .media-slider:active .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.media-default-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb:focus-visible,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Shared track fills */
|
||||
.media-default-skin .media-slider__buffer,
|
||||
.media-default-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-default-skin .media-slider__buffer {
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-buffer);
|
||||
transition-property: width;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"] {
|
||||
height: var(--media-slider-buffer);
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
/* Fill */
|
||||
.media-default-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
.media-default-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Popups & Animations
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-popup-animation {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
filter: blur(0px);
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-starting-style],
|
||||
.media-default-skin .media-popup-animation[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-default-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-default-skin .media-popup {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-popup--volume {
|
||||
padding: 0.625rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tooltips
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-default-skin .media-tooltip-popup {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Captions
|
||||
========================================================================== */
|
||||
|
||||
.media-default-skin .media-captions {
|
||||
position: absolute;
|
||||
inset: auto 1rem 1.5rem 1rem;
|
||||
z-index: 20;
|
||||
font-size: 1rem;
|
||||
text-wrap: balance;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-captions__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 42ch;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.media-default-skin .media-captions__text {
|
||||
display: block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
color: oklch(1 0 0);
|
||||
text-shadow:
|
||||
0 0 1px oklch(0 0 0 / 0.7),
|
||||
0 0 8px oklch(0 0 0 / 0.7);
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-default-skin .media-captions__text {
|
||||
background: oklch(0 0 0 / 0.7);
|
||||
text-shadow: none;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
}
|
||||
.media-default-skin .media-captions__text > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Responsive caption sizing */
|
||||
@container media-root (width > 20rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-default-skin .media-captions {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Caption shifting styles (custom and native) */
|
||||
.media-default-skin {
|
||||
--media-caption-track-delay: 600ms;
|
||||
--media-caption-track-y: -0.5rem;
|
||||
}
|
||||
.media-default-skin:has(.media-controls[data-visible]) {
|
||||
--media-caption-track-delay: 25ms;
|
||||
--media-caption-track-y: -3.5rem;
|
||||
}
|
||||
.media-default-skin .media-captions,
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
/* NOTE: The delay must account for the controls delay/duration */
|
||||
transition: transform 150ms ease-out;
|
||||
transition-delay: var(--media-caption-track-delay);
|
||||
}
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
transform: translateY(var(--media-caption-track-y)) scale(0.98);
|
||||
z-index: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* When controls are visible, shift captions up to avoid overlap */
|
||||
.media-default-skin .media-controls[data-visible] ~ .media-captions {
|
||||
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-default-skin .media-captions,
|
||||
.media-default-skin video::-webkit-media-text-track-container {
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { cn } from '@videojs/utils/style';
|
||||
|
||||
/* ==========================================================================
|
||||
Atomic Fragments
|
||||
========================================================================== */
|
||||
|
||||
const surface = cn(
|
||||
'bg-white/10',
|
||||
'backdrop-blur-3xl backdrop-brightness-90 backdrop-saturate-150',
|
||||
// Border and shadow
|
||||
'ring ring-white/5 ring-inset shadow-sm shadow-black/15',
|
||||
// Border to enhance contrast on lighter videos
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
|
||||
// Reduced transparency for users with preference
|
||||
'[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
|
||||
// High contrast mode
|
||||
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
|
||||
);
|
||||
|
||||
export const icon = cn(
|
||||
'block [grid-area:1/1] size-4.5 shrink-0',
|
||||
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
export const iconHidden = 'hidden opacity-0';
|
||||
export const iconFlipped = '[scale:-1_1]';
|
||||
export const iconContainer = 'relative';
|
||||
|
||||
/* ==========================================================================
|
||||
Button
|
||||
========================================================================== */
|
||||
|
||||
export const button = {
|
||||
base: cn(
|
||||
'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center',
|
||||
'font-medium',
|
||||
'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',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2'
|
||||
),
|
||||
icon: cn(
|
||||
'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',
|
||||
'focus-visible:bg-white/10 focus-visible:text-white',
|
||||
'aria-expanded:bg-white/10 aria-expanded:text-white'
|
||||
),
|
||||
default: cn('flex py-2 px-4 bg-white rounded-full', 'text-black'),
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
export const slider = {
|
||||
root: cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-20'
|
||||
),
|
||||
track: cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full'
|
||||
),
|
||||
fill: {
|
||||
base: 'absolute rounded-[inherit] pointer-events-none',
|
||||
fill: cn(
|
||||
'bg-white',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
|
||||
'data-[orientation=horizontal]:w-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
|
||||
'data-[orientation=vertical]:h-(--media-slider-fill)'
|
||||
),
|
||||
buffer: cn(
|
||||
'bg-white/20 duration-250 ease-out',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
|
||||
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
|
||||
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)'
|
||||
),
|
||||
},
|
||||
thumb: {
|
||||
base: cn(
|
||||
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]'
|
||||
),
|
||||
persistent: 'size-3',
|
||||
interactive: cn(
|
||||
'size-2.5',
|
||||
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
|
||||
'group-active/slider:size-3'
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Component-Level Compositions
|
||||
========================================================================== */
|
||||
|
||||
export const root = cn(
|
||||
// Layout & containment
|
||||
'block relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-(--media-border-radius,2rem) bg-black',
|
||||
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Resets
|
||||
'**:box-border **:m-0',
|
||||
'[&_button]:font-[inherit]',
|
||||
'motion-safe:[interpolate-size:allow-keywords]',
|
||||
// Inner highlight ring (::before)
|
||||
'before:absolute before:pointer-events-none before:rounded-[inherit] before:z-10',
|
||||
'before:inset-px before:ring-1 before:ring-inset before:ring-white/15',
|
||||
// Outer border ring (::after)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/10',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full [&>video]:rounded-[inherit]',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full [&>img]:rounded-[inherit]',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-250',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Caption track CSS variables
|
||||
'[--media-caption-track-delay:600ms]',
|
||||
'[--media-caption-track-y:-0.5rem]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
|
||||
// Native caption track container
|
||||
'[&_video::-webkit-media-text-track-container]:transition-transform',
|
||||
'[&_video::-webkit-media-text-track-container]:duration-150',
|
||||
'[&_video::-webkit-media-text-track-container]:ease-out',
|
||||
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
|
||||
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
|
||||
'[&_video::-webkit-media-text-track-container]:scale-98',
|
||||
'[&_video::-webkit-media-text-track-container]:z-1',
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none'
|
||||
);
|
||||
|
||||
export const controls = cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Surface
|
||||
surface,
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-3 inset-x-3',
|
||||
'p-[0.175rem] flex items-center gap-[0.075rem]',
|
||||
'text-white rounded-full z-10',
|
||||
// Transitions
|
||||
'will-change-[scale,transform,filter,opacity]',
|
||||
'transition-[scale,transform,filter,opacity] ease-out',
|
||||
'delay-0 duration-100 origin-bottom',
|
||||
// Hidden state
|
||||
'not-data-visible:pointer-events-none not-data-visible:blur-sm',
|
||||
'not-data-visible:scale-90 not-data-visible:opacity-0',
|
||||
'not-data-visible:delay-500 not-data-visible:duration-300',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-visible:duration-100',
|
||||
'motion-reduce:not-data-visible:blur-none',
|
||||
'motion-reduce:not-data-visible:scale-100',
|
||||
// Wider container
|
||||
'@2xl/media-root:p-1 @2xl/media-root:gap-0.5'
|
||||
);
|
||||
|
||||
export const overlay = cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Default: hidden
|
||||
'opacity-0',
|
||||
'bg-linear-to-t from-black/50 via-black/30 to-transparent',
|
||||
'backdrop-blur-none backdrop-saturate-120 backdrop-brightness-90',
|
||||
// Transitions
|
||||
'transition-[opacity,backdrop-filter] ease-out',
|
||||
'duration-300 delay-500',
|
||||
// Shown when controls visible
|
||||
'peer-data-visible/controls:opacity-100',
|
||||
'peer-data-visible/controls:duration-150',
|
||||
'peer-data-visible/controls:delay-0',
|
||||
// Shown when error visible (+ blur)
|
||||
'peer-data-visible/error:opacity-100',
|
||||
'peer-data-visible/error:duration-150',
|
||||
'peer-data-visible/error:delay-0',
|
||||
'peer-data-visible/error:backdrop-blur-sm',
|
||||
// Reduced motion
|
||||
'motion-reduce:duration-100'
|
||||
);
|
||||
|
||||
export const bufferingIndicator = {
|
||||
root: 'absolute inset-0 hidden items-center justify-center pointer-events-none z-10 text-white data-visible:flex',
|
||||
container: cn('p-1 rounded-full', surface),
|
||||
};
|
||||
|
||||
export const error = {
|
||||
root: 'peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex',
|
||||
dialog: cn(
|
||||
'hidden flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white text-sm pointer-events-auto',
|
||||
'group-data-visible/error:flex',
|
||||
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
|
||||
'starting:opacity-0 starting:scale-50',
|
||||
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]',
|
||||
surface
|
||||
),
|
||||
content: 'flex flex-col gap-2 px-2 pt-2 pb-1.5',
|
||||
title: 'font-semibold leading-tight',
|
||||
description: 'opacity-70',
|
||||
actions: 'flex gap-2 *:flex-1',
|
||||
};
|
||||
|
||||
export const time = {
|
||||
group: '@container/media-time flex items-center flex-1 gap-3 px-2',
|
||||
current: 'hidden @2xs/media-time:block text-shadow-2xs text-shadow-black/25 tabular-nums',
|
||||
duration: 'text-shadow-2xs text-shadow-black/25 tabular-nums',
|
||||
};
|
||||
|
||||
export const popup = {
|
||||
base: cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 [--media-popover-side-offset:0.5rem]',
|
||||
// Surface
|
||||
surface,
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-starting-style:opacity-0 data-starting-style:scale-0 data-starting-style:blur-sm',
|
||||
'data-ending-style:opacity-0 data-ending-style:scale-0 data-ending-style:blur-sm',
|
||||
'data-instant:duration-0'
|
||||
),
|
||||
volume: 'py-2.5 px-1 rounded-full',
|
||||
};
|
||||
|
||||
export const seek = {
|
||||
button: '@max-md/media-controls:hidden',
|
||||
label: 'text-[0.75em] font-[480] tabular-nums',
|
||||
labelForward: 'absolute -right-px -bottom-0.75',
|
||||
labelBackward: 'absolute -left-px -bottom-0.75',
|
||||
};
|
||||
|
||||
export const playbackRate = {
|
||||
button: `after:content-[attr(data-rate)_'×'] after:w-[4ch] after:tabular-nums`,
|
||||
};
|
||||
|
||||
export const iconState = {
|
||||
play: {
|
||||
button: 'group',
|
||||
restart: 'hidden opacity-0 group-data-ended:block group-data-ended:opacity-100',
|
||||
play: 'hidden opacity-0 group-not-data-ended:group-data-paused:block group-not-data-ended:group-data-paused:opacity-100',
|
||||
pause:
|
||||
'hidden opacity-0 group-not-data-paused:group-not-data-ended:block group-not-data-paused:group-not-data-ended:opacity-100',
|
||||
},
|
||||
mute: {
|
||||
button: 'group',
|
||||
volumeOff: 'hidden opacity-0 group-data-muted:block group-data-muted:opacity-100',
|
||||
volumeLow:
|
||||
'hidden opacity-0 group-not-data-muted:group-data-[volume-level=low]:block group-not-data-muted:group-data-[volume-level=low]:opacity-100',
|
||||
volumeHigh:
|
||||
'hidden opacity-0 group-not-data-muted:group-not-data-[volume-level=low]:block group-not-data-muted:group-not-data-[volume-level=low]:opacity-100',
|
||||
},
|
||||
fullscreen: {
|
||||
button: 'group',
|
||||
enter: 'hidden opacity-0 group-not-data-fullscreen:block group-not-data-fullscreen:opacity-100',
|
||||
exit: 'hidden opacity-0 group-data-fullscreen:block group-data-fullscreen:opacity-100',
|
||||
},
|
||||
captions: {
|
||||
button: 'group',
|
||||
off: 'hidden opacity-0 group-not-data-active:block group-not-data-active:opacity-100',
|
||||
on: 'hidden opacity-0 group-data-active:block group-data-active:opacity-100',
|
||||
},
|
||||
};
|
||||
|
||||
export const captions = {
|
||||
root: cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-visible/controls:-translate-y-12 peer-data-visible/controls:delay-25'
|
||||
),
|
||||
container: 'max-w-[42ch] mx-auto text-center flex flex-col items-center',
|
||||
cue: cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline'
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/* ==========================================================================
|
||||
Icon State Visibility
|
||||
|
||||
Data-attribute-driven visibility rules for multi-state icon buttons.
|
||||
Uses :is() with both element selectors (for HTML custom element wrappers)
|
||||
and class selectors (for React rendered SVG elements).
|
||||
========================================================================== */
|
||||
|
||||
/* --- All icons hidden by default --- */
|
||||
|
||||
.media-button--play .media-icon--restart,
|
||||
.media-button--play .media-icon--play,
|
||||
.media-button--play .media-icon--pause,
|
||||
.media-button--mute .media-icon--volume-off,
|
||||
.media-button--mute .media-icon--volume-low,
|
||||
.media-button--mute .media-icon--volume-high,
|
||||
.media-button--fullscreen .media-icon--fullscreen-enter,
|
||||
.media-button--fullscreen .media-icon--fullscreen-exit,
|
||||
.media-button--captions .media-icon--captions-off,
|
||||
.media-button--captions .media-icon--captions-on {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* --- Active icon per state --- */
|
||||
|
||||
/* Play: ended → restart */
|
||||
.media-button--play[data-ended] .media-icon--restart,
|
||||
/* Play: paused (not ended) → play */
|
||||
.media-button--play:not([data-ended])[data-paused] .media-icon--play,
|
||||
/* Play: playing (not paused, not ended) → pause */
|
||||
.media-button--play:not([data-paused]):not([data-ended]) .media-icon--pause,
|
||||
/* Mute: muted → volume off */
|
||||
.media-button--mute[data-muted] .media-icon--volume-off,
|
||||
/* Mute: volume low (not muted) → volume low */
|
||||
.media-button--mute:not([data-muted])[data-volume-level="low"] .media-icon--volume-low,
|
||||
/* Mute: volume high (not muted, not low) → volume high */
|
||||
.media-button--mute:not([data-muted]):not([data-volume-level="low"]) .media-icon--volume-high,
|
||||
/* Fullscreen: not fullscreen → enter */
|
||||
.media-button--fullscreen:not([data-fullscreen]) .media-icon--fullscreen-enter,
|
||||
/* Fullscreen: fullscreen → exit */
|
||||
.media-button--fullscreen[data-fullscreen] .media-icon--fullscreen-exit,
|
||||
/* Captions: not active → captions off */
|
||||
.media-button--captions:not([data-active]) .media-icon--captions-off,
|
||||
/* Captions: active → captions on */
|
||||
.media-button--captions[data-active] .media-icon--captions-on {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
@import "./icon-state.css";
|
||||
|
||||
/* ==========================================================================
|
||||
Reset
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin *,
|
||||
.media-minimal-skin *::before,
|
||||
.media-minimal-skin *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.media-minimal-skin img,
|
||||
.media-minimal-skin video,
|
||||
.media-minimal-skin svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-minimal-skin button {
|
||||
font: inherit;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.media-minimal-skin {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Root Container
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: block;
|
||||
container: media-root / inline-size;
|
||||
overflow: clip;
|
||||
border-radius: var(--media-border-radius, 0.75rem);
|
||||
background: oklch(0 0 0);
|
||||
font-family:
|
||||
Inter Variable,
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
}
|
||||
|
||||
/* Border ring */
|
||||
.media-minimal-skin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.media-minimal-skin::after {
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.media-minimal-skin:fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Media Element
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Poster Image
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin > img:not([data-visible]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Overlay / Scrim
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(to top, oklch(0 0 0 / 0.7), oklch(0 0 0 / 0.5) 7.5rem, oklch(0 0 0 / 0));
|
||||
backdrop-filter: blur(0) saturate(1.2) brightness(0.9);
|
||||
opacity: 0;
|
||||
transition-property: opacity, backdrop-filter;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-overlay {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay,
|
||||
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
|
||||
opacity: 1;
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
|
||||
backdrop-filter: blur(8px) saturate(1.2) brightness(0.9);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buffering Indicator
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-buffering-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-buffering-indicator[data-visible] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Dialog
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-error {
|
||||
display: none;
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-error__dialog {
|
||||
display: none;
|
||||
transition-property: display, opacity, transform;
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 100ms;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-timing-function: linear(
|
||||
0,
|
||||
0.034 1.5%,
|
||||
0.763 9.7%,
|
||||
1.066 13.9%,
|
||||
1.198 19.9%,
|
||||
1.184 21.8%,
|
||||
0.963 37.5%,
|
||||
0.997 50.9%,
|
||||
1
|
||||
);
|
||||
}
|
||||
.media-minimal-skin .media-error[data-visible] .media-error__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 16rem;
|
||||
padding: 1rem;
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.875rem;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.5);
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
color: oklch(1 0 0);
|
||||
|
||||
@starting-style {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-error__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
.media-minimal-skin .media-error__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.media-minimal-skin .media-error__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.media-minimal-skin .media-error__actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Controls
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 10;
|
||||
container: media-controls / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 0.375rem 0.375rem 0.375rem;
|
||||
color: oklch(1 0 0);
|
||||
will-change: transform, filter, opacity;
|
||||
transition-property: transform, filter, opacity;
|
||||
transition-duration: 75ms;
|
||||
transition-delay: 0ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
filter: blur(8px);
|
||||
transition-duration: 500ms;
|
||||
transition-delay: 500ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-controls:not([data-visible]) {
|
||||
scale: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-controls {
|
||||
gap: 0.875rem;
|
||||
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Time Controls & Display
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-time-controls {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.media-minimal-skin .media-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.media-minimal-skin .media-time__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 0 oklch(0 0 0 / 0.2);
|
||||
}
|
||||
.media-minimal-skin .media-time__value--current,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
display: none;
|
||||
}
|
||||
@container media-controls (width > 28rem) {
|
||||
.media-minimal-skin .media-time-controls {
|
||||
flex-direction: row;
|
||||
}
|
||||
.media-minimal-skin .media-time__value--duration,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
color: oklch(1 0 0 / 0.5);
|
||||
}
|
||||
.media-minimal-skin .media-time__value--current,
|
||||
.media-minimal-skin .media-time__separator {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Button Groups
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.075rem;
|
||||
}
|
||||
@container media-root (width > 40rem) {
|
||||
.media-minimal-skin .media-button-group {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
/* Base button */
|
||||
.media-minimal-skin .media-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: oklch(1 0 0);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
color: oklch(0 0 0);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition-property: background-color, color, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.media-minimal-skin .media-button:focus-visible {
|
||||
outline-color: oklch(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-button[disabled] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icon button variant */
|
||||
.media-minimal-skin .media-button--icon {
|
||||
display: grid;
|
||||
width: 2.375rem;
|
||||
padding: 0;
|
||||
aspect-ratio: 1;
|
||||
background: transparent;
|
||||
color: oklch(1 0 0);
|
||||
}
|
||||
.media-minimal-skin .media-button--icon:hover,
|
||||
.media-minimal-skin .media-button--icon:focus-visible,
|
||||
.media-minimal-skin .media-button--icon[aria-expanded="true"] {
|
||||
color: oklch(1 0 0 / 0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
.media-minimal-skin .media-button--icon .media-icon {
|
||||
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
|
||||
}
|
||||
|
||||
/* Seek button */
|
||||
.media-minimal-skin .media-button--seek .media-icon__label {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 480;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.media-minimal-skin .media-button--seek:has(.media-icon--flipped) .media-icon__label {
|
||||
right: unset;
|
||||
left: -1px;
|
||||
}
|
||||
@container media-controls (width < 28rem) {
|
||||
.media-minimal-skin .media-button--seek {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback rate button */
|
||||
.media-minimal-skin .media-button--playback-rate {
|
||||
padding: 0;
|
||||
}
|
||||
.media-minimal-skin .media-button--playback-rate::after {
|
||||
content: attr(data-rate) "\00D7";
|
||||
width: 4ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Icons
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-icon__container {
|
||||
position: relative;
|
||||
}
|
||||
.media-minimal-skin .media-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
grid-area: 1 / 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: display, opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-icon--flipped {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
border-radius: calc(infinity * 1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal orientation */
|
||||
.media-minimal-skin .media-slider[data-orientation="horizontal"] {
|
||||
min-width: 5rem;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Vertical orientation */
|
||||
.media-minimal-skin .media-slider[data-orientation="vertical"] {
|
||||
width: 1.25rem;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.media-minimal-skin .media-slider__track {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
|
||||
user-select: none;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] {
|
||||
width: 100%;
|
||||
height: 0.1875rem;
|
||||
}
|
||||
.media-minimal-skin .media-slider__track[data-orientation="vertical"] {
|
||||
width: 0.1875rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.media-minimal-skin .media-slider__thumb {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background-color: oklch(1 0 0);
|
||||
border-radius: calc(infinity * 1px);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.1),
|
||||
0 1px 3px 0 oklch(0 0 0 / 0.15),
|
||||
0 1px 2px -1px oklch(0 0 0 / 0.15);
|
||||
opacity: 0;
|
||||
scale: 0.7;
|
||||
transform-origin: center;
|
||||
transition-property: opacity, scale, outline-offset;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
user-select: none;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider:focus-within .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
/* Shared track fills */
|
||||
.media-minimal-skin .media-slider__buffer,
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-minimal-skin .media-slider__buffer {
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-buffer);
|
||||
transition-property: width;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"] {
|
||||
height: var(--media-slider-buffer);
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
/* Fill */
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
.media-minimal-skin .media-slider__time-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Popups & Animations
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-popup-animation {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform-origin: bottom;
|
||||
filter: blur(0px);
|
||||
transition-property: transform, scale, opacity, filter;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-starting-style],
|
||||
.media-minimal-skin .media-popup-animation[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.media-minimal-skin .media-popup-animation[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-popup {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-minimal-skin .media-popup--volume {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tooltips
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: oklch(1 0 0 / 0.1);
|
||||
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px oklch(0 0 0 / 0.1),
|
||||
0 2px 4px -2px oklch(0 0 0 / 0.1);
|
||||
color: oklch(1 0 0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklch(0 0 0 / 0.7);
|
||||
}
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-minimal-skin .media-tooltip-popup {
|
||||
background-color: oklch(0 0 0 / 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Captions
|
||||
========================================================================== */
|
||||
|
||||
.media-minimal-skin .media-captions {
|
||||
position: absolute;
|
||||
inset: auto 1rem 1.5rem 1rem;
|
||||
z-index: 20;
|
||||
font-size: 1rem;
|
||||
text-wrap: balance;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-captions__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 42ch;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.media-minimal-skin .media-captions__text {
|
||||
display: block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
color: oklch(1 0 0);
|
||||
text-shadow:
|
||||
0 0 1px oklch(0 0 0 / 0.7),
|
||||
0 0 8px oklch(0 0 0 / 0.7);
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
.media-minimal-skin .media-captions__text {
|
||||
background: oklch(0 0 0 / 0.7);
|
||||
text-shadow: none;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
}
|
||||
.media-minimal-skin .media-captions__text > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Responsive caption sizing */
|
||||
@container media-root (width > 20rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 48rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
@container media-root (width > 80rem) {
|
||||
.media-minimal-skin .media-captions {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Caption shifting styles (custom and native) */
|
||||
.media-minimal-skin {
|
||||
--media-caption-track-delay: 600ms;
|
||||
--media-caption-track-y: -0.5rem;
|
||||
}
|
||||
.media-minimal-skin:has(.media-controls[data-visible]) {
|
||||
--media-caption-track-delay: 25ms;
|
||||
--media-caption-track-y: -3rem;
|
||||
}
|
||||
.media-minimal-skin .media-captions,
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
/* NOTE: The delay must account for the controls delay/duration */
|
||||
transition: transform 150ms ease-out;
|
||||
transition-delay: var(--media-caption-track-delay);
|
||||
}
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
transform: translateY(var(--media-caption-track-y)) scale(0.98);
|
||||
z-index: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* When controls are visible, shift captions up to avoid overlap */
|
||||
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
|
||||
transform: translateY(calc(var(--media-caption-track-y) - 0.5rem));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-minimal-skin .media-captions,
|
||||
.media-minimal-skin video::-webkit-media-text-track-container {
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { cn } from '@videojs/utils/style';
|
||||
|
||||
/* ==========================================================================
|
||||
Atomic Fragments
|
||||
========================================================================== */
|
||||
|
||||
export const icon = cn(
|
||||
'block [grid-area:1/1] size-4.5',
|
||||
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
|
||||
'transition-discrete transition-[display,opacity] duration-150 ease-out'
|
||||
);
|
||||
|
||||
export const iconHidden = 'hidden opacity-0';
|
||||
export const iconFlipped = '[scale:-1_1]';
|
||||
export const iconContainer = 'relative';
|
||||
|
||||
/* ==========================================================================
|
||||
Button
|
||||
========================================================================== */
|
||||
|
||||
export const button = {
|
||||
base: cn(
|
||||
'items-center justify-center shrink-0 border-none cursor-pointer select-none text-center',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'font-medium',
|
||||
'transition-[background-color,color,outline-offset] duration-150 ease-out',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale'
|
||||
),
|
||||
icon: cn(
|
||||
'grid w-[2.375rem] aspect-square bg-transparent rounded-md',
|
||||
'text-white',
|
||||
'hover:text-white/80 hover:no-underline',
|
||||
'focus-visible:text-white/80',
|
||||
'aria-expanded:text-white/80'
|
||||
),
|
||||
default: cn('flex py-2 px-4 bg-white rounded-lg', 'text-black'),
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Slider
|
||||
========================================================================== */
|
||||
|
||||
export const slider = {
|
||||
root: cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-[4.5rem]'
|
||||
),
|
||||
track: cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-0.75',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-0.75 data-[orientation=vertical]:h-full'
|
||||
),
|
||||
fill: {
|
||||
base: 'absolute rounded-[inherit] pointer-events-none',
|
||||
fill: cn(
|
||||
'bg-white',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
|
||||
'data-[orientation=horizontal]:w-(--media-slider-fill,0)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
|
||||
'data-[orientation=vertical]:h-(--media-slider-fill,0)'
|
||||
),
|
||||
buffer: cn(
|
||||
'bg-white/20 duration-250 ease-out',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0',
|
||||
'data-[orientation=horizontal]:transition-[width] data-[orientation=horizontal]:w-(--media-slider-buffer,0)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0',
|
||||
'data-[orientation=vertical]:transition-[height] data-[orientation=vertical]:h-(--media-slider-buffer)'
|
||||
),
|
||||
},
|
||||
thumb: {
|
||||
base: cn(
|
||||
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill,0)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill,0))]'
|
||||
),
|
||||
interactive: cn(
|
||||
'opacity-0 scale-70 origin-center',
|
||||
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
|
||||
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Component-Level Compositions
|
||||
========================================================================== */
|
||||
|
||||
export const root = cn(
|
||||
// Layout & containment
|
||||
'block relative isolate overflow-clip @container/media-root',
|
||||
// Appearance
|
||||
'rounded-(--media-border-radius,0.75rem) bg-black',
|
||||
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
|
||||
// Resets
|
||||
'**:box-border **:m-0',
|
||||
'[&_button]:font-[inherit]',
|
||||
'motion-safe:[interpolate-size:allow-keywords]',
|
||||
// Outer border ring (::after only)
|
||||
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
|
||||
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/15',
|
||||
'dark:after:ring-white/15',
|
||||
// Video element
|
||||
'[&>video]:block [&>video]:w-full [&>video]:h-full [&>video]:rounded-[inherit]',
|
||||
// Poster image
|
||||
'[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full [&>img]:rounded-[inherit]',
|
||||
'[&>img]:object-cover [&>img]:pointer-events-none',
|
||||
'[&>img]:transition-opacity [&>img]:duration-250',
|
||||
'[&>img:not([data-visible])]:opacity-0',
|
||||
// Caption track CSS variables
|
||||
'[--media-caption-track-delay:600ms]',
|
||||
'[--media-caption-track-y:-0.5rem]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]',
|
||||
'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]',
|
||||
// Native caption track container
|
||||
'[&_video::-webkit-media-text-track-container]:transition-transform',
|
||||
'[&_video::-webkit-media-text-track-container]:duration-150',
|
||||
'[&_video::-webkit-media-text-track-container]:ease-out',
|
||||
'[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)',
|
||||
'[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)',
|
||||
'[&_video::-webkit-media-text-track-container]:scale-98',
|
||||
'[&_video::-webkit-media-text-track-container]:z-1',
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50',
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:rounded-none'
|
||||
);
|
||||
|
||||
export const controls = cn(
|
||||
// Peer marker for overlay/captions
|
||||
'peer/controls',
|
||||
// Layout
|
||||
'absolute @container/media-controls bottom-0 inset-x-0',
|
||||
'pt-8 px-1.5 pb-1.5 flex items-center gap-2',
|
||||
'text-white z-10',
|
||||
// Transitions
|
||||
'will-change-[translate,filter,opacity]',
|
||||
'transition-[translate,filter,opacity] ease-out',
|
||||
'delay-0 duration-75',
|
||||
// Hidden state
|
||||
'not-data-visible:opacity-0 not-data-visible:translate-y-full',
|
||||
'not-data-visible:blur-sm not-data-visible:pointer-events-none',
|
||||
'not-data-visible:delay-500 not-data-visible:duration-500',
|
||||
// Reduced motion + hidden
|
||||
'motion-reduce:not-data-visible:duration-100',
|
||||
'motion-reduce:not-data-visible:translate-y-0',
|
||||
'motion-reduce:not-data-visible:blur-none motion-reduce:not-data-visible:scale-100',
|
||||
// Wider container
|
||||
'@sm/media-root:pt-10 @sm/media-root:px-3 @sm/media-root:pb-3',
|
||||
'@sm/media-root:gap-3.5'
|
||||
);
|
||||
|
||||
export const overlay = cn(
|
||||
// Layout
|
||||
'absolute inset-0 flex flex-col items-start',
|
||||
'pointer-events-none rounded-[inherit]',
|
||||
// Default: hidden
|
||||
'opacity-0',
|
||||
'bg-linear-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
|
||||
'backdrop-blur-none backdrop-saturate-120 backdrop-brightness-90',
|
||||
// Transitions
|
||||
'transition-[opacity,backdrop-filter] ease-out',
|
||||
'duration-500 delay-500',
|
||||
// Shown when controls visible
|
||||
'peer-data-visible/controls:opacity-100',
|
||||
'peer-data-visible/controls:duration-150',
|
||||
'peer-data-visible/controls:delay-0',
|
||||
// Shown when error visible (+ blur)
|
||||
'peer-data-visible/error:opacity-100',
|
||||
'peer-data-visible/error:duration-150',
|
||||
'peer-data-visible/error:delay-0',
|
||||
'peer-data-visible/error:backdrop-blur-sm',
|
||||
// Reduced motion
|
||||
'motion-reduce:duration-100'
|
||||
);
|
||||
|
||||
export const bufferingIndicator =
|
||||
'absolute inset-0 hidden items-center justify-center pointer-events-none z-10 text-white data-visible:flex';
|
||||
|
||||
export const error = {
|
||||
root: 'peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-visible:flex',
|
||||
dialog: cn(
|
||||
'hidden flex-col gap-3 max-w-64 p-4 text-white text-sm pointer-events-auto',
|
||||
'group-data-visible/error:flex',
|
||||
'text-shadow-2xs text-shadow-black/50',
|
||||
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
|
||||
'starting:opacity-0 starting:scale-50',
|
||||
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]'
|
||||
),
|
||||
content: 'flex flex-col gap-2 py-1.5',
|
||||
title: 'font-semibold leading-tight',
|
||||
actions: 'flex gap-2 *:flex-1',
|
||||
};
|
||||
|
||||
export const buttonGroup = cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5');
|
||||
|
||||
export const time = {
|
||||
group: 'flex items-center gap-1',
|
||||
current: cn('hidden tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:inline'),
|
||||
separator: cn('hidden', '@md/media-controls:inline @md/media-controls:text-white/50'),
|
||||
duration: cn('tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:text-white/50'),
|
||||
controls: cn('flex flex-row-reverse items-center flex-1 gap-3', '@md/media-controls:flex-row'),
|
||||
};
|
||||
|
||||
export const popup = {
|
||||
base: cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 bg-transparent [--media-popover-side-offset:0.5rem]',
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-starting-style:opacity-0 data-starting-style:scale-0 data-starting-style:blur-sm',
|
||||
'data-ending-style:opacity-0 data-ending-style:scale-0 data-ending-style:blur-sm',
|
||||
'data-instant:duration-0'
|
||||
),
|
||||
volume: 'p-1',
|
||||
};
|
||||
|
||||
export const seek = {
|
||||
button: '@max-md/media-controls:hidden',
|
||||
label: 'text-[0.75em] font-[480] tabular-nums',
|
||||
labelForward: 'absolute -right-px -bottom-0.75',
|
||||
labelBackward: 'absolute -left-px -bottom-0.75',
|
||||
};
|
||||
|
||||
export const iconState = {
|
||||
play: {
|
||||
button: 'group',
|
||||
restart: 'hidden opacity-0 group-data-ended:block group-data-ended:opacity-100',
|
||||
play: 'hidden opacity-0 group-not-data-ended:group-data-paused:block group-not-data-ended:group-data-paused:opacity-100',
|
||||
pause:
|
||||
'hidden opacity-0 group-not-data-paused:group-not-data-ended:block group-not-data-paused:group-not-data-ended:opacity-100',
|
||||
},
|
||||
mute: {
|
||||
button: 'group',
|
||||
volumeOff: 'hidden opacity-0 group-data-muted:block group-data-muted:opacity-100',
|
||||
volumeLow:
|
||||
'hidden opacity-0 group-not-data-muted:group-data-[volume-level=low]:block group-not-data-muted:group-data-[volume-level=low]:opacity-100',
|
||||
volumeHigh:
|
||||
'hidden opacity-0 group-not-data-muted:group-not-data-[volume-level=low]:block group-not-data-muted:group-not-data-[volume-level=low]:opacity-100',
|
||||
},
|
||||
fullscreen: {
|
||||
button: 'group',
|
||||
enter: 'hidden opacity-0 group-not-data-fullscreen:block group-not-data-fullscreen:opacity-100',
|
||||
exit: 'hidden opacity-0 group-data-fullscreen:block group-data-fullscreen:opacity-100',
|
||||
},
|
||||
captions: {
|
||||
button: 'group',
|
||||
off: 'hidden opacity-0 group-not-data-active:block group-not-data-active:opacity-100',
|
||||
on: 'hidden opacity-0 group-data-active:block group-data-active:opacity-100',
|
||||
},
|
||||
};
|
||||
|
||||
export const captions = {
|
||||
root: cn(
|
||||
'absolute z-20 pointer-events-none text-balance text-base',
|
||||
'inset-x-4 bottom-6',
|
||||
'transition-transform duration-150 ease-out delay-600',
|
||||
'motion-reduce:duration-50',
|
||||
// Responsive font sizes
|
||||
'@xs/media-root:text-2xl',
|
||||
'@3xl/media-root:text-3xl',
|
||||
'@7xl/media-root:text-4xl',
|
||||
// Shift up when controls visible
|
||||
'peer-data-visible/controls:-translate-y-10 peer-data-visible/controls:delay-25'
|
||||
),
|
||||
container: 'max-w-[42ch] mx-auto text-center flex flex-col items-center',
|
||||
cue: cn(
|
||||
'block py-0.5 px-2 text-white text-center whitespace-pre-wrap leading-1.2',
|
||||
'[text-shadow:0_0_1px_oklab(0_0_0_/_0.7),0_0_8px_oklab(0_0_0_/_0.7)]',
|
||||
'contrast-more:[text-shadow:none] contrast-more:[box-decoration-break:clone] contrast-more:bg-black/70',
|
||||
'*:inline'
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2020"],
|
||||
"declarationDir": "types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { globSync } from 'node:fs';
|
||||
import type { UserConfig } from 'tsdown';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
type BuildMode = 'dev' | 'default';
|
||||
|
||||
const buildModes: BuildMode[] = ['dev', 'default'];
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
globSync('src/**/*.tailwind.ts').map((file) => {
|
||||
const key = file.replace('src/', '').replace('.ts', '');
|
||||
return [key, file];
|
||||
})
|
||||
);
|
||||
|
||||
const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
entry: entries,
|
||||
platform: 'browser',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
hash: false,
|
||||
unbundle: true,
|
||||
outDir: `dist/${mode}`,
|
||||
define: {
|
||||
__DEV__: mode === 'dev' ? 'true' : 'false',
|
||||
},
|
||||
dts: mode === 'dev',
|
||||
copy: [
|
||||
{
|
||||
from: 'src/**/*.css',
|
||||
to: `dist/${mode}`,
|
||||
flatten: false,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: 'watch-css',
|
||||
buildStart() {
|
||||
const cssFiles = globSync('src/**/*.css');
|
||||
for (const file of cssFiles) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default defineConfig(buildModes.map((mode) => createConfig(mode)));
|
||||
Generated
+33
-8
@@ -220,6 +220,12 @@ importers:
|
||||
'@videojs/element':
|
||||
specifier: workspace:*
|
||||
version: link:../element
|
||||
'@videojs/icons':
|
||||
specifier: workspace:*
|
||||
version: link:../icons
|
||||
'@videojs/skins':
|
||||
specifier: workspace:*
|
||||
version: link:../skins
|
||||
'@videojs/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
@@ -279,6 +285,9 @@ importers:
|
||||
'@videojs/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@videojs/skins':
|
||||
specifier: workspace:*
|
||||
version: link:../skins
|
||||
'@videojs/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
@@ -328,6 +337,9 @@ importers:
|
||||
'@videojs/react':
|
||||
specifier: workspace:*
|
||||
version: link:../react
|
||||
'@videojs/skins':
|
||||
specifier: workspace:*
|
||||
version: link:../skins
|
||||
'@videojs/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
@@ -387,6 +399,19 @@ importers:
|
||||
specifier: ^6.0.0
|
||||
version: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/skins:
|
||||
dependencies:
|
||||
'@videojs/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
devDependencies:
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/store:
|
||||
dependencies:
|
||||
'@videojs/utils':
|
||||
@@ -8924,7 +8949,7 @@ snapshots:
|
||||
jwt-decode: 4.0.0
|
||||
lambda-local: 2.2.0
|
||||
read-package-up: 11.0.0
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
source-map-support: 0.5.21
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
@@ -9055,8 +9080,8 @@ snapshots:
|
||||
|
||||
'@netlify/zip-it-and-ship-it@14.3.1(rollup@4.53.3)':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@netlify/binary-info': 1.0.0
|
||||
'@netlify/serverless-functions-api': 2.8.3
|
||||
'@vercel/nft': 0.29.4(rollup@4.53.3)
|
||||
@@ -9436,7 +9461,7 @@ snapshots:
|
||||
'@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.28.0': {}
|
||||
|
||||
@@ -10556,7 +10581,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -10616,7 +10641,7 @@ snapshots:
|
||||
|
||||
'@vue/compiler-core@3.5.27':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/parser': 7.29.0
|
||||
'@vue/shared': 3.5.27
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
@@ -10629,7 +10654,7 @@ snapshots:
|
||||
|
||||
'@vue/compiler-sfc@3.5.27':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/parser': 7.29.0
|
||||
'@vue/compiler-core': 3.5.27
|
||||
'@vue/compiler-dom': 3.5.27
|
||||
'@vue/compiler-ssr': 3.5.27
|
||||
@@ -13365,7 +13390,7 @@ snapshots:
|
||||
|
||||
node-source-walk@7.0.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/parser': 7.29.0
|
||||
|
||||
node-stream-zip@1.15.0: {}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
{ "path": "packages/core" },
|
||||
{ "path": "packages/core/src/dom" },
|
||||
|
||||
{ "path": "packages/skins" },
|
||||
|
||||
{ "path": "packages/html" },
|
||||
{ "path": "packages/react" }
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user