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 `