mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(html): context-based media discovery, remove slot="media" (#997)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: decided
|
||||
date: 2026-03-17
|
||||
---
|
||||
|
||||
# Context-Based Media Discovery
|
||||
|
||||
## Decision
|
||||
|
||||
Media elements are discovered via context, not named slots. Skins use a default `<slot>` instead of `<slot name="media">`. Users no longer need `slot="media"` on their `<video>` or `<audio>` elements.
|
||||
|
||||
The provider's `mediaAttachContext` is the primary discovery mechanism. Custom elements that consume context register themselves directly. Plain `<video>`/`<audio>` elements that can't consume context are found via the provider's fallback `querySelector('video, audio')`.
|
||||
|
||||
The `<slot name="media">` inside `CustomMediaElement` is a separate concern — it's for overriding the internal native element and stays unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
Skins previously used `<slot name="media">` inside `<media-container>` for two purposes:
|
||||
|
||||
1. **DOM projection** — visually placing the media element inside the container's layout.
|
||||
2. **Media discovery** — the container's `slotchange` listener and `MutationObserver` watched the slot to detect when a media element appeared.
|
||||
|
||||
With [provider-attach](provider-attach.md), the provider now owns media discovery and `store.attach()`. The container no longer watches for media elements. Discovery purpose (2) is gone. Only DOM projection (1) remains — and a default slot serves that purpose without requiring users to mark their elements with `slot="media"`.
|
||||
|
||||
The named slot created user-facing friction:
|
||||
|
||||
- Forgetting `slot="media"` meant the player silently didn't attach — no error, just a broken player.
|
||||
- The slot pattern confused users who expected shadow DOM semantics under the hood.
|
||||
- Frameworks that hijack slot assignment could conflict with the sentinel usage.
|
||||
- React already used context-based registration, making the HTML behavior inconsistent.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep named slots alongside context** — Skins keep `<slot name="media">`, users keep writing `slot="media"`. Rejected because the named slot no longer serves a discovery purpose and creates unnecessary friction.
|
||||
|
||||
- **Remove all slots from skins** — Skins render media via a different mechanism (e.g., `<template>` insertion). Rejected because default slots still provide clean DOM projection without any user-facing attributes.
|
||||
|
||||
## Rationale
|
||||
|
||||
**One less thing to remember.** Users drop a `<video>` inside a skin and it works. No attribute ceremony.
|
||||
|
||||
**Consistent with React.** React uses `<Video>` with a callback ref — no slot concept. HTML now matches: elements register via context, the provider discovers them.
|
||||
|
||||
**Default slot is invisible.** A `<slot>` (default) projects all light DOM children. The media element, controls, and other children all project naturally. No named targeting needed.
|
||||
|
||||
**Fallback covers plain elements.** The provider's `querySelector('video, audio')` microtask fallback handles native elements that can't consume context. This path is simple and predictable.
|
||||
|
||||
## Previous Behavior
|
||||
|
||||
Skins defined `<slot name="media"></slot>` inside `<media-container>`. Users wrote:
|
||||
|
||||
```html
|
||||
<video-player>
|
||||
<video-skin>
|
||||
<video slot="media" src="video.mp4"></video>
|
||||
</video-skin>
|
||||
</video-player>
|
||||
```
|
||||
|
||||
The container discovered media via `querySelector`, `MutationObserver`, duck-type checks (`localName.endsWith('-video')`), and `slotchange` listeners. Forgetting `slot="media"` meant the video rendered but never attached to the store.
|
||||
@@ -1 +1,7 @@
|
||||
export { type Context, ContextConsumer, ContextProvider, createContext } from '@lit/context';
|
||||
export {
|
||||
type Context,
|
||||
ContextConsumer,
|
||||
ContextEvent,
|
||||
ContextProvider,
|
||||
createContext,
|
||||
} from '@lit/context';
|
||||
|
||||
@@ -37,7 +37,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root}">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<div class="${controls}">
|
||||
<media-tooltip-group>
|
||||
|
||||
@@ -21,7 +21,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-minimal-skin media-minimal-skin--audio">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<div class="media-controls">
|
||||
<media-tooltip-group>
|
||||
|
||||
@@ -36,7 +36,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root}">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<div class="${controls}">
|
||||
<media-tooltip-group>
|
||||
|
||||
@@ -21,7 +21,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-default-skin media-default-skin--audio">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<div class="media-surface media-controls">
|
||||
<media-tooltip-group>
|
||||
|
||||
@@ -11,7 +11,7 @@ background-video-skin {
|
||||
object-fit: var(--media-object-fit);
|
||||
}
|
||||
|
||||
background-video-skin > [slot="media"] {
|
||||
background-video-skin > background-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { safeDefine } from '../safe-define';
|
||||
function getTemplateHTML(_attrs: Record<string, string>) {
|
||||
return /*html*/ `
|
||||
<media-container>
|
||||
<slot name="media" slot="media"></slot>
|
||||
<slot></slot>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root(true)}">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<media-poster class="${poster(true)}">
|
||||
<slot name="poster"></slot>
|
||||
|
||||
@@ -26,7 +26,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-minimal-skin media-minimal-skin--video">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<media-poster>
|
||||
<slot name="poster"></slot>
|
||||
|
||||
@@ -46,7 +46,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="${root(true)}">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<media-poster class="${poster(true)}">
|
||||
<slot name="poster"></slot>
|
||||
|
||||
@@ -27,7 +27,7 @@ const SEEK_TIME = 10;
|
||||
function getTemplateHTML() {
|
||||
return /*html*/ `
|
||||
<media-container class="media-default-skin media-default-skin--video">
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
|
||||
<media-poster>
|
||||
<slot name="poster"></slot>
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from './player/context';
|
||||
export * from './player/create-player';
|
||||
export * from './player/player-controller';
|
||||
export * from './store/container-mixin';
|
||||
export * from './store/media-attach-mixin';
|
||||
export * from './store/provider-mixin';
|
||||
export * from './store/types';
|
||||
// UI Components
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Media } from '@videojs/core/dom';
|
||||
import { namedNodeMapToObject } from '@videojs/utils/dom';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
function getTemplateHTML(attrs: Record<string, string>) {
|
||||
return /*html*/ `
|
||||
@@ -22,7 +24,7 @@ function getTemplateHTML(attrs: Record<string, string>) {
|
||||
}
|
||||
|
||||
// Don't extend CustomMediaMixin to save some bytes, background videos don't need the full Media API.
|
||||
export class BackgroundVideo extends HTMLElement {
|
||||
export class BackgroundVideo extends MediaAttachMixin(HTMLElement) {
|
||||
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
|
||||
static getTemplateHTML = getTemplateHTML;
|
||||
static get observedAttributes() {
|
||||
@@ -54,6 +56,11 @@ export class BackgroundVideo extends HTMLElement {
|
||||
this.target!.muted = !this.hasAttribute('nomuted');
|
||||
}
|
||||
|
||||
// Register the inner <video> (not `this`) with the provider.
|
||||
getMediaTarget(): Media | null {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void {
|
||||
if (attrName === 'src' && oldValue !== newValue) {
|
||||
this.target!.src = newValue ?? '';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DashCustomMedia } from '@videojs/core/dom/media/dash';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class DashVideo extends DashCustomMedia {
|
||||
export class DashVideo extends MediaAttachMixin(DashCustomMedia) {
|
||||
static getTemplateHTML(attrs: Record<string, string>): string {
|
||||
const { src, ...rest } = attrs;
|
||||
// biome-ignore lint/complexity/noThisInStatic: intentional use of super
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HlsCustomMedia } from '@videojs/core/dom/media/hls';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class HlsVideo extends HlsCustomMedia {
|
||||
export class HlsVideo extends MediaAttachMixin(HlsCustomMedia) {
|
||||
static getTemplateHTML(attrs: Record<string, string>): string {
|
||||
const { src, ...rest } = attrs;
|
||||
// biome-ignore lint/complexity/noThisInStatic: intentional use of super
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SimpleHlsCustomMedia } from '@videojs/core/dom/media/simple-hls';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class SimpleHlsVideo extends SimpleHlsCustomMedia {
|
||||
export class SimpleHlsVideo extends MediaAttachMixin(SimpleHlsCustomMedia) {
|
||||
static getTemplateHTML(attrs: Record<string, string>): string {
|
||||
const { src, ...rest } = attrs;
|
||||
// biome-ignore lint/complexity/noThisInStatic: intentional use of super
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Media } from '@videojs/core/dom';
|
||||
import { ContextEvent } from '@videojs/element/context';
|
||||
import type { CustomElement } from '@videojs/utils/dom';
|
||||
import type { AnyConstructor, Constructor } from '@videojs/utils/types';
|
||||
import { type MediaAttachContext, mediaAttachContext } from '../player/context';
|
||||
|
||||
export type MediaAttachMixin = <Class extends AnyConstructor<HTMLElement>>(BaseClass: Class) => Class;
|
||||
|
||||
/**
|
||||
* Create a mixin that consumes `mediaAttachContext` and registers the
|
||||
* element as the media with the provider.
|
||||
*
|
||||
* Uses the raw context-request protocol so it works with any
|
||||
* `HTMLElement` subclass — no `ReactiveControllerHost` required.
|
||||
*
|
||||
* @param context - The media attach context to consume.
|
||||
*/
|
||||
export function createMediaAttachMixin(context: MediaAttachContext): MediaAttachMixin {
|
||||
return <Class extends AnyConstructor<HTMLElement>>(BaseClass: Class) => {
|
||||
class MediaAttachElement extends (BaseClass as unknown as Constructor<CustomElement>) {
|
||||
#setMedia: ((media: Media | null) => void) | null = null;
|
||||
#unsubscribe: (() => void) | null = null;
|
||||
|
||||
getMediaTarget(): Media | null {
|
||||
return this as unknown as Media;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
|
||||
this.dispatchEvent(
|
||||
new ContextEvent(
|
||||
context,
|
||||
this,
|
||||
(value, unsubscribe) => {
|
||||
if (unsubscribe) this.#unsubscribe = unsubscribe;
|
||||
this.#setMedia = value ?? null;
|
||||
if (this.isConnected) {
|
||||
this.#setMedia?.(this.getMediaTarget());
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
this.#setMedia?.(null);
|
||||
this.#unsubscribe?.();
|
||||
this.#unsubscribe = null;
|
||||
this.#setMedia = null;
|
||||
}
|
||||
}
|
||||
|
||||
return MediaAttachElement as unknown as Class;
|
||||
};
|
||||
}
|
||||
|
||||
export const MediaAttachMixin = createMediaAttachMixin(mediaAttachContext);
|
||||
@@ -18,7 +18,7 @@ async function render() {
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<audio-player>
|
||||
<${tag}>
|
||||
<audio slot="media" src="${SOURCES[state.source].url}"></audio>
|
||||
<audio src="${SOURCES[state.source].url}"></audio>
|
||||
</${tag}>
|
||||
</audio-player>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const html = String.raw;
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<background-video-player>
|
||||
<background-video-skin>
|
||||
<background-video slot="media" src="${BACKGROUND_VIDEO_SRC}"></background-video>
|
||||
<background-video src="${BACKGROUND_VIDEO_SRC}"></background-video>
|
||||
</background-video-skin>
|
||||
</background-video-player>
|
||||
`;
|
||||
|
||||
@@ -22,7 +22,7 @@ async function render() {
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
|
||||
<dash-video slot="media" src="${SOURCES[state.source].url}" playsinline>
|
||||
<dash-video src="${SOURCES[state.source].url}" playsinline>
|
||||
${renderStoryboard(storyboard)}
|
||||
</dash-video>
|
||||
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function render() {
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
|
||||
<hls-video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
<hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
${renderStoryboard(storyboard)}
|
||||
</hls-video>
|
||||
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function render() {
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
|
||||
<simple-hls-video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
<simple-hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
${renderStoryboard(storyboard)}
|
||||
</simple-hls-video>
|
||||
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function render() {
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
|
||||
<video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
<video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
${renderStoryboard(storyboard)}
|
||||
</video>
|
||||
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
|
||||
|
||||
Reference in New Issue
Block a user