diff --git a/.vscode/settings.json b/.vscode/settings.json
index f5b3f676..00e180a4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,7 +16,7 @@
"biome.requireConfiguration": true,
"editor.defaultFormatter": "biomejs.biome",
- "typescript.tsdk": "node_modules/typescript/lib",
+ "js/ts.tsdk.path": "node_modules/typescript/lib",
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
diff --git a/build/plugins/copy-css-plugin.mjs b/build/plugins/copy-css-plugin.mjs
new file mode 100644
index 00000000..2b39bd24
--- /dev/null
+++ b/build/plugins/copy-css-plugin.mjs
@@ -0,0 +1,31 @@
+import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { resolveImports } from './resolve-css-imports.mjs';
+
+/**
+ * @param {{ skinsDir: string; outDir: string }} options
+ */
+export function copyCssPlugin(options) {
+ const { skinsDir, outDir } = options;
+
+ return {
+ name: 'copy-css',
+ buildStart() {
+ 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')) {
+ const content = readFileSync(file, 'utf-8');
+ const resolved = resolveImports(content, dirname(file), skinsDir);
+ const outFile = join(outDir, file.replace(/^src\//, ''));
+ mkdirSync(dirname(outFile), { recursive: true });
+ writeFileSync(outFile, resolved);
+ }
+ },
+ };
+}
diff --git a/build/plugins/inline-css-plugin.mjs b/build/plugins/inline-css-plugin.mjs
new file mode 100644
index 00000000..c5e6fbb4
--- /dev/null
+++ b/build/plugins/inline-css-plugin.mjs
@@ -0,0 +1,45 @@
+import { readFileSync } from 'node:fs';
+import { dirname, relative, resolve } from 'node:path';
+import { resolveImports } from './resolve-css-imports.mjs';
+
+const INLINE_PREFIX = 'inline-css:';
+
+const INLINE_SUFFIX = '?inline';
+
+/**
+ * Rolldown/tsdown plugin that inlines `.css?inline` imports as JavaScript
+ * modules exporting the resolved CSS string. Mirrors the Vite `?inline`
+ * convention so the same source works in both Vite dev and tsdown builds.
+ *
+ * @param {{ skinsDir: string; rootDir?: string }} options
+ */
+export function inlineCssPlugin(options) {
+ const { skinsDir, rootDir = process.cwd() } = options;
+
+ return {
+ name: 'inline-css',
+
+ resolveId(source, importer) {
+ if (!source.endsWith(INLINE_SUFFIX)) return null;
+
+ const cssPath = source.slice(0, -INLINE_SUFFIX.length);
+ const abs = resolve(dirname(importer), cssPath);
+ const rel = relative(rootDir, abs);
+
+ // Use .js extension so rolldown doesn't apply its CSS loader.
+ return { id: `${INLINE_PREFIX}${rel.replace(/\.css$/, '.js')}`, moduleSideEffects: false };
+ },
+
+ load(id) {
+ if (!id.startsWith(INLINE_PREFIX)) return null;
+
+ // Map back to the .css file path.
+ const rel = id.slice(INLINE_PREFIX.length).replace(/\.js$/, '.css');
+ const file = resolve(rootDir, rel);
+ const raw = readFileSync(file, 'utf-8');
+ const resolved = resolveImports(raw, dirname(file), skinsDir);
+
+ return { code: `export default ${JSON.stringify(resolved)};`, moduleSideEffects: false };
+ },
+ };
+}
diff --git a/build/plugins/resolve-css-imports.mjs b/build/plugins/resolve-css-imports.mjs
new file mode 100644
index 00000000..23394b9c
--- /dev/null
+++ b/build/plugins/resolve-css-imports.mjs
@@ -0,0 +1,27 @@
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+/**
+ * Resolves `@import` directives in CSS content, inlining referenced files.
+ *
+ * @param {string} content - The CSS content to resolve.
+ * @param {string} baseDir - The directory to resolve relative imports from.
+ * @param {string} skinsDir - The directory to resolve `@videojs/skins` imports from.
+ * @returns {string} The resolved CSS content.
+ */
+export function resolveImports(content, baseDir, skinsDir) {
+ return content.replace(/@import\s+['"]([^'"]+)['"]\s*;/g, (_, importPath) => {
+ let file;
+
+ if (importPath.startsWith('@videojs/skins/')) {
+ file = resolve(skinsDir, importPath.replace('@videojs/skins/', ''));
+ } else if (importPath.startsWith('.')) {
+ file = resolve(baseDir, importPath);
+ } else {
+ return _;
+ }
+
+ const nested = readFileSync(file, 'utf-8');
+ return resolveImports(nested, dirname(file), skinsDir);
+ });
+}
diff --git a/scripts/link-aliases.mjs b/build/scripts/link-aliases.mjs
similarity index 100%
rename from scripts/link-aliases.mjs
rename to build/scripts/link-aliases.mjs
diff --git a/package.json b/package.json
index 8dcfdb70..af20625e 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
},
"scripts": {
"prepare": "simple-git-hooks",
- "postinstall": "node scripts/link-aliases.mjs",
+ "postinstall": "node build/scripts/link-aliases.mjs",
"build:packages": "turbo run build --filter='./packages/*'",
"build:site": "turbo run build --filter=site",
"build": "turbo run build",
@@ -29,7 +29,7 @@
"lint": "biome check .",
"lint:fix": "biome check . --write",
"lint:fix:file": "biome check --write",
- "link:aliases": "node scripts/link-aliases.mjs",
+ "link:aliases": "node build/scripts/link-aliases.mjs",
"size": "node .github/scripts/bundle-size.js | node .github/scripts/bundle-size-report.js",
"test": "turbo run test",
"format:astro": "prettier --write 'site/src/**/*.astro'",
diff --git a/packages/html/src/define/audio/minimal-skin.css b/packages/html/src/define/audio/minimal-skin.css
index 76c1d426..f7064f30 100644
--- a/packages/html/src/define/audio/minimal-skin.css
+++ b/packages/html/src/define/audio/minimal-skin.css
@@ -1,11 +1 @@
-.media-minimal-skin {
- color: var(--media-color, red);
-}
-
-audio-player {
- display: contents;
-}
-
-audio-minimal-skin {
- display: contents;
-}
+@import "@videojs/skins/minimal/css/audio.css";
diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts
new file mode 100644
index 00000000..d4d76b06
--- /dev/null
+++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts
@@ -0,0 +1,113 @@
+import { ReactiveElement } from '@videojs/element';
+import { renderIcon } from '@videojs/icons/render/minimal';
+import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind';
+import {
+ button,
+ buttonGroup,
+ controls,
+ icon,
+ iconContainer,
+ iconFlipped,
+ iconState,
+ popup,
+ root,
+ seek,
+ slider,
+ time,
+} from '@videojs/skins/minimal/tailwind/audio.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/mute-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('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) })}
+
+
+
+
+
+
+ `;
+}
+
+export class MinimalAudioSkinTailwindElement extends SkinMixin(ReactiveElement) {
+ static readonly tagName = 'audio-minimal-skin-tailwind';
+ static getTemplateHTML = getTemplateHTML;
+}
+
+customElements.define(MinimalAudioSkinTailwindElement.tagName, MinimalAudioSkinTailwindElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [MinimalAudioSkinTailwindElement.tagName]: MinimalAudioSkinTailwindElement;
+ }
+}
diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts
index a720a6c9..9ab891d0 100644
--- a/packages/html/src/define/audio/minimal-skin.ts
+++ b/packages/html/src/define/audio/minimal-skin.ts
@@ -1,20 +1,93 @@
import { ReactiveElement } from '@videojs/element';
+import { renderIcon } from '@videojs/icons/render/minimal';
+import { createStyles, SkinMixin } from '../skin-mixin';
+import styles from './minimal-skin.css?inline';
+
+// Side-effect imports: register all custom elements used in the template.
+import '../media/container';
+import '../ui/mute-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*/ `
`;
+ return /*html*/ `
+
+
+
+
+
+
+ ${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--seek media-icon--flipped' })}
+ ${SEEK_TIME}
+
+
+
+
+
+ ${renderIcon('seek', { class: 'media-icon media-icon--seek' })}
+ ${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' })}
+
+
+
+
+
+
+ `;
}
-export class MinimalAudioSkinElement extends ReactiveElement {
+export class MinimalAudioSkinElement extends SkinMixin(ReactiveElement) {
static readonly tagName = 'audio-minimal-skin';
+ static styles = createStyles(styles);
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);
diff --git a/packages/html/src/define/audio/skin.css b/packages/html/src/define/audio/skin.css
index 83042de2..d02eea76 100644
--- a/packages/html/src/define/audio/skin.css
+++ b/packages/html/src/define/audio/skin.css
@@ -1,9 +1 @@
-@import "@videojs/skins/audio/default.css";
-
-audio-player {
- display: contents;
-}
-
-audio-skin {
- display: contents;
-}
+@import "@videojs/skins/default/css/audio.css";
diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts
new file mode 100644
index 00000000..ab517861
--- /dev/null
+++ b/packages/html/src/define/audio/skin.tailwind.ts
@@ -0,0 +1,104 @@
+import { ReactiveElement } from '@videojs/element';
+import { renderIcon } from '@videojs/icons/render';
+import {
+ button,
+ controls,
+ icon,
+ iconContainer,
+ iconFlipped,
+ iconState,
+ playbackRate,
+ popup,
+ root,
+ seek,
+ slider,
+ time,
+} from '@videojs/skins/default/tailwind/audio.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/mute-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('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) })}
+
+
+
+
+
+ `;
+}
+
+export class AudioSkinTailwindElement extends SkinMixin(ReactiveElement) {
+ static readonly tagName = 'audio-skin-tailwind';
+ static getTemplateHTML = getTemplateHTML;
+}
+
+customElements.define(AudioSkinTailwindElement.tagName, AudioSkinTailwindElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [AudioSkinTailwindElement.tagName]: AudioSkinTailwindElement;
+ }
+}
diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts
index 17feef2d..7e86fab3 100644
--- a/packages/html/src/define/audio/skin.ts
+++ b/packages/html/src/define/audio/skin.ts
@@ -1,20 +1,85 @@
import { ReactiveElement } from '@videojs/element';
+import { renderIcon } from '@videojs/icons/render';
+import { createStyles, SkinMixin } from '../skin-mixin';
+import styles from './skin.css?inline';
+
+// Side-effect imports: register all custom elements used in the template.
+import '../media/container';
+import '../ui/mute-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*/ ``;
+ return /*html*/ `
+
+
+
+
+
+ ${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--seek media-icon--flipped' })}
+ ${SEEK_TIME}
+
+
+
+
+
+ ${renderIcon('seek', { class: 'media-icon media-icon--seek' })}
+ ${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' })}
+
+
+
+
+
+ `;
}
-export class AudioSkinElement extends ReactiveElement {
+export class AudioSkinElement extends SkinMixin(ReactiveElement) {
static readonly tagName = 'audio-skin';
+ static styles = createStyles(styles);
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);
diff --git a/packages/html/src/define/skin-mixin.ts b/packages/html/src/define/skin-mixin.ts
index 662ed068..46835daa 100644
--- a/packages/html/src/define/skin-mixin.ts
+++ b/packages/html/src/define/skin-mixin.ts
@@ -2,45 +2,81 @@ 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.
+ * Light DOM stylesheet that bridges CSS custom properties set by skins
+ * to native WebKit text track pseudo-elements on the slotted `