diff --git a/packages/html/package.json b/packages/html/package.json index 0ab06d0c..905bc83a 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -89,6 +89,8 @@ "dependencies": { "@videojs/core": "workspace:*", "@videojs/element": "workspace:*", + "@videojs/icons": "workspace:*", + "@videojs/skins": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" }, diff --git a/packages/html/src/define/audio/minimal-skin.css b/packages/html/src/define/audio/minimal-skin.css new file mode 100644 index 00000000..76c1d426 --- /dev/null +++ b/packages/html/src/define/audio/minimal-skin.css @@ -0,0 +1,11 @@ +.media-minimal-skin { + color: var(--media-color, red); +} + +audio-player { + display: contents; +} + +audio-minimal-skin { + display: contents; +} diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts new file mode 100644 index 00000000..a720a6c9 --- /dev/null +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -0,0 +1,26 @@ +import { ReactiveElement } from '@videojs/element'; + +function getTemplateHTML() { + return /*html*/ `
`; +} + +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; + } +} diff --git a/packages/html/src/define/audio/skin.css b/packages/html/src/define/audio/skin.css new file mode 100644 index 00000000..83042de2 --- /dev/null +++ b/packages/html/src/define/audio/skin.css @@ -0,0 +1,9 @@ +@import "@videojs/skins/audio/default.css"; + +audio-player { + display: contents; +} + +audio-skin { + display: contents; +} diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 2367e2e4..17feef2d 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -1 +1,26 @@ -// TODO: Implement AudioSkinElement and then register it here +import { ReactiveElement } from '@videojs/element'; + +function getTemplateHTML() { + return /*html*/ `
`; +} + +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; + } +} diff --git a/packages/html/src/define/skin-mixin.ts b/packages/html/src/define/skin-mixin.ts new file mode 100644 index 00000000..662ed068 --- /dev/null +++ b/packages/html/src/define/skin-mixin.ts @@ -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 `` placeholders + * by replacing them with the actual media element children. + */ +export function SkinMixin>(BaseClass: Base): Base { + class SkinElement extends (BaseClass as Constructor) { + 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(':scope > [slot="media"]')]; + + for (const el of media) slot.before(el); + slot.remove(); + } + } + + return SkinElement as unknown as Base; +} diff --git a/packages/html/src/define/video/minimal-skin.css b/packages/html/src/define/video/minimal-skin.css new file mode 100644 index 00000000..39e7ac9a --- /dev/null +++ b/packages/html/src/define/video/minimal-skin.css @@ -0,0 +1 @@ +@import "@videojs/skins/video/minimal.css"; diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts new file mode 100644 index 00000000..62ba9759 --- /dev/null +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -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*/ ` + + + ${renderIcon('spinner')} + + +
+
+
+

Something went wrong.

+

An error occurred while trying to play the video. Please try again.

+
+
+ +
+
+
+ + + + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + + ${renderIcon('seek', { class: cn(icon, iconFlipped) })} + ${SEEK_TIME} + + + + + + ${renderIcon('seek', { class: icon })} + ${SEEK_TIME} + + + + + + + + + + + + + + + + + + + + + + + + + + ${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) })} + + + + + + + + + + + + + + + ${renderIcon('pip', { class: icon })} + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} + ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} + + + + +
+ + +
+ `; +} + +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; + } +} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts new file mode 100644 index 00000000..ce60b0d2 --- /dev/null +++ b/packages/html/src/define/video/minimal-skin.ts @@ -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*/ ` + + + ${renderIcon('spinner', { class: 'media-icon' })} + + +
+
+
+

Something went wrong.

+

An error occurred while trying to play the video. Please try again.

+
+
+ +
+
+
+ + + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + ${renderIcon('seek', { class: 'media-icon media-icon--flipped' })} + ${SEEK_TIME} + + + + + + ${renderIcon('seek', { class: 'media-icon' })} + ${SEEK_TIME} + + + + + + + + + + + + + + + + + + + + + + + + + + ${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' })} + + + + + + + + + + + + + + + ${renderIcon('pip', { class: 'media-icon' })} + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} + ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} + + + + +
+ + +
+ `; +} + +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; + } +} diff --git a/packages/html/src/define/video/skin.css b/packages/html/src/define/video/skin.css new file mode 100644 index 00000000..e8180491 --- /dev/null +++ b/packages/html/src/define/video/skin.css @@ -0,0 +1 @@ +@import "@videojs/skins/video/default.css"; diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts new file mode 100644 index 00000000..d5e9184f --- /dev/null +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -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*/ ` + + +
+ ${renderIcon('spinner')} +
+
+ +
+
+
+

Something went wrong.

+

An error occurred while trying to play the video. Please try again.

+
+
+ +
+
+
+ + + + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + + ${renderIcon('seek', { class: cn(icon, iconFlipped) })} + ${SEEK_TIME} + + + + + + ${renderIcon('seek', { class: icon })} + ${SEEK_TIME} + + + + + + + + + + + + + + + + + + + + ${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) })} + + + + + + + + + + + + + + + ${renderIcon('pip', { class: icon })} + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} + ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} + + + +
+ + +
+ `; +} + +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; + } +} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 55b74be0..b78419dc 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -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*/ ` + + +
+ ${renderIcon('spinner', { class: 'media-icon' })} +
+
+ + + + + + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + + ${renderIcon('seek', { class: 'media-icon media-icon--flipped' })} + ${SEEK_TIME} + + + + + + ${renderIcon('seek', { class: 'media-icon' })} + ${SEEK_TIME} + + + + + + + + + + + + + + + + + + + + ${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' })} + + + + + + + + + + + + + + + ${renderIcon('pip', { class: 'media-icon' })} + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} + ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} + + + +
+ + +
+ `; +} + +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; + } +} diff --git a/packages/html/src/presets/audio.ts b/packages/html/src/presets/audio.ts index edcf0663..2d8d2159 100644 --- a/packages/html/src/presets/audio.ts +++ b/packages/html/src/presets/audio.ts @@ -1 +1,3 @@ export { audioFeatures } from '@videojs/core/dom'; +export { MinimalAudioSkinElement } from '../define/audio/minimal-skin'; +export { AudioSkinElement } from '../define/audio/skin'; diff --git a/packages/html/src/presets/video.ts b/packages/html/src/presets/video.ts index 06871740..befde046 100644 --- a/packages/html/src/presets/video.ts +++ b/packages/html/src/presets/video.ts @@ -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'; diff --git a/packages/html/src/store/container-mixin.ts b/packages/html/src/store/container-mixin.ts index b12c5ee4..f26eeff6 100644 --- a/packages/html/src/store/container-mixin.ts +++ b/packages/html/src/store/container-mixin.ts @@ -19,15 +19,27 @@ export function createContainerMixin(context: PlayerC class PlayerContainerElement extends BaseClass implements PlayerConsumer, 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(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('video, audio'); diff --git a/packages/html/tsdown.config.ts b/packages/html/tsdown.config.ts index c8e15a50..9897847b 100644 --- a/packages/html/tsdown.config.ts +++ b/packages/html/tsdown.config.ts @@ -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); + } }, }, ], diff --git a/packages/icons/package.json b/packages/icons/package.json index 68c7f103..8fb70df7 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -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": [ diff --git a/packages/icons/scripts/build.ts b/packages/icons/scripts/build.ts index f46fca80..634cd025 100644 --- a/packages/icons/scripts/build.ts +++ b/packages/icons/scripts/build.ts @@ -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(' `'${name}'`).join(' | '); + return [ + `export type IconName = ${union};`, + ``, + `export declare function renderIcon(`, + ` name: IconName,`, + ` attrs?: Record,`, + `): 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;`, + ``, + `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.RefAttributes>;` @@ -106,6 +221,17 @@ function buildIndexTypes(icons: { name: string; varName: string }[], framework: return `/// \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 { const svgFiles = getSvgFiles(setName); console.log(`Building set: ${setName} (${svgFiles.length} icons)`); @@ -116,6 +242,7 @@ async function buildIconSet(setName: string): Promise { 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 { 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;\n`); } async function build(): Promise { @@ -151,6 +292,11 @@ async function build(): Promise { 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 { diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json index 5283a614..b7b8f079 100644 --- a/packages/icons/tsconfig.json +++ b/packages/icons/tsconfig.json @@ -6,5 +6,5 @@ "declaration": true, "jsx": "react-jsx" }, - "include": ["src/**/*", "dist/**/*"] + "include": ["src/**/*"] } diff --git a/packages/react/package.json b/packages/react/package.json index 235c5991..40074752 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@videojs/core": "workspace:*", + "@videojs/skins": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" }, diff --git a/packages/react/src/presets/audio/skin.css b/packages/react/src/presets/audio/skin.css index 9c9762b2..910461f6 100644 --- a/packages/react/src/presets/audio/skin.css +++ b/packages/react/src/presets/audio/skin.css @@ -1,3 +1 @@ -.media-skin { - color: var(--media-color, red); -} +@import "@videojs/skins/audio/default.css"; diff --git a/packages/react/src/presets/background/skin.css b/packages/react/src/presets/background/skin.css index 743d2571..88fe7243 100644 --- a/packages/react/src/presets/background/skin.css +++ b/packages/react/src/presets/background/skin.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"; diff --git a/packages/react/src/presets/video/minimal-skin.css b/packages/react/src/presets/video/minimal-skin.css index a87c794e..39e7ac9a 100644 --- a/packages/react/src/presets/video/minimal-skin.css +++ b/packages/react/src/presets/video/minimal-skin.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"; diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index cace967f..3f75b415 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -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 & { variant?: 'icon' }>(function Button( @@ -55,112 +62,21 @@ const Button = forwardRef & { varian @@ -326,35 +142,15 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod - {/*
-
- + {/*
+
+ An example cue
*/} - @@ -346,35 +145,14 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { - {/*
-
- + {/*
+
+ An example cue
*/} -