import { type CompilerPlugin, defineConfig, transform } from '@videojs/compiler'; import { tailwind } from '@videojs/compiler/tailwind'; import { kebabCase } from '@videojs/utils/string'; const SOURCE_CORE = '@videojs/core/components'; const SOURCE_ICONS = '@videojs/icons/components'; const SOURCE_TAILWIND_CSS = '../skins/src/default/video.tailwind.css'; const SOURCE_ANIMATIONS_CSS = '../skins/src/shared/animations.css'; const SOURCE_REGISTERED_PROPERTIES_CSS = '../skins/src/shared/registered-properties.css'; const SOURCE_SHARED_VARIABLES_CSS = '../skins/src/shared/variables.css'; const SOURCE_DEFAULT_VIDEO_CSS = '../skins/src/default/video.css'; const INPUT = { 'default-video': '../skins/src/default/video.skin.tsx', }; const GENERATED_DIR = 'src/presets/video/__generated__'; const GENERATED_BANNER = '// Generated by @videojs/compiler. Do not edit.\n'; export default defineConfig({ input: INPUT, output: { dir: GENERATED_DIR, entryFileNames: '[name].css.tsx', banner: GENERATED_BANNER, }, plugins: [ tailwind({ mode: 'extract', input: SOURCE_TAILWIND_CSS, output: 'default-video.css', emit: { mode: 'split', base: [ SOURCE_ANIMATIONS_CSS, SOURCE_REGISTERED_PROPERTIES_CSS, SOURCE_SHARED_VARIABLES_CSS, SOURCE_DEFAULT_VIDEO_CSS, ], }, vars: { properties: { mode: 'inline' }, }, resolve: { element(context) { const className = resolveMediaClassName(context.tag, context.tokenName ?? context.defaultName); return { className, chunk: resolveMediaChunk(className), }; }, classList({ className, classes }) { return [...classes, ...sharedClassNames(className)]; }, }, }), react(), ], }); function react(): CompilerPlugin { return transform((code) => { // Types const BaseVideoSkinProps = code.import('./src/presets/types', 'BaseVideoSkinProps', { type: true }); const ReactNode = code.import('react', 'ReactNode', { type: true }); // Utils const cn = code.import('@videojs/utils/style', 'cn'); const isString = code.import('@videojs/utils/predicate', 'isString'); const isRenderProp = code.import('@/utils/use-render', 'isRenderProp'); // Components const Poster = code.import('@/ui/poster', 'Poster'); return [ code.imports({ [SOURCE_CORE]: resolveComponentImport, [SOURCE_ICONS]: '@/icons', }), code .interface(/Props$/) .property('children') .setType(() => code.type.union(code.type.named(ReactNode), code.type.undefined())), code.interface('DefaultVideoSkinProps').extends(BaseVideoSkinProps), code.jsx.element(/^(Tooltip|Popover)\.Trigger$/).childToProp('render'), code.jsx.element('Controls.Root').addProp('data-controls', ''), code.jsx.element('Container').spreadProps('rest'), code.jsx.element('Text').replace('span'), code.jsx.element('Poster').replace(() => code.jsx.if( 'poster', code.jsx.create(Poster, { src: code.value.when('poster', isString), render: code.value.when('poster', isRenderProp), }) ) ), code.jsx .props('className') .where(code.value.isArray()) .replace(({ value }) => code.value.call(cn, code.value.arrayItems(value))), code.function('DefaultVideoSkin').addProps(['poster', { name: 'rest', spread: true }]), ]; }); } function resolveComponentImport(name: string): { source: string; name: string } { return { source: name === 'Container' ? '@/player/context' : `@/ui/${resolveComponentImportPath(name)}`, name, }; } function resolveComponentImportPath(name: string): string { const id = name.replace(/^AirPlay/, 'Airplay').replace(/^PiP/, 'Pip'); return kebabCase(id).replace(/^-/, ''); } function resolveMediaClassName(tag: string, defaultName: string): string { const tokenName = normalizeTokenName(defaultName); if (tag === 'Container') return 'media-root'; if (/Button$/.test(tag)) return 'media-button'; if (tag === 'ErrorDialog.Close') return 'media-error-close'; return getPrefixedDefaultName(tokenName); } function normalizeTokenName(name: string): string { if (name.startsWith('icons-')) { const iconName = stripRootSuffix(name.slice('icons-'.length)); return iconName === 'root' ? 'icon' : iconName; } return stripRootSuffix(name); } function stripRootSuffix(name: string): string { return name.endsWith('-root') ? name.slice(0, -'-root'.length) : name; } function resolveMediaChunk(className: string): string { if (matchesPrefix(className, 'media-tooltip', 'media-popover', 'media-volume-popover')) return 'popup'; if (matchesPrefix(className, 'media-error')) return 'dialog'; if (matchesPrefix(className, 'media-buffering')) return 'loading'; if (matchesPrefix(className, 'media-controls')) return 'controls'; if (matchesPrefix(className, 'media-time')) return 'time'; if (matchesPrefix(className, 'media-slider', 'media-spinner')) return 'slider'; if ( matchesPrefix( className, 'media-indicator', 'media-volume-indicator', 'media-status-indicator', 'media-seek-indicator' ) ) { return 'feedback'; } if (matchesPrefix(className, 'media-button', 'media-seek-label')) return 'button'; if (className.endsWith('-icon') && className !== 'media-spinner') return 'button'; return 'base'; } function sharedClassNames(className: string): string[] { if (matchesPrefix(className, 'media-controls-group-start', 'media-controls-group-end')) { return ['media-controls-group']; } if (className === 'media-slider-thumb-persistent') return ['media-slider-thumb']; if (matchesPrefix(className, 'media-status-indicator-top', 'media-status-indicator-center')) { return ['media-status-indicator']; } if (matchesPrefix(className, 'media-seek-label-backward', 'media-seek-label-forward')) return ['media-seek-label']; if (className === 'media-error-close') return ['media-button']; if (className === 'media-volume-popover') return ['media-popover']; return []; } function getPrefixedDefaultName(name: string): string { return name.startsWith('media-') ? name : `media-${name}`; } function matchesPrefix(className: string, ...prefixes: string[]): boolean { return prefixes.some((prefix) => className === prefix || className.startsWith(`${prefix}-`)); }