feat(packages): add title component - #1997
Conversation
Add `<media-title>` and `<Title />`, which display the content title the metadata feature resolves. `TitleCore` reads metadata, controls, and playback state and exposes `hasTitle` and `visible` as data attributes, so skins react to `[data-visible]` rather than reaching across the DOM for playback state. The title shows when a title exists, controls are visible, and playback is paused. Controls and playback are optional: a player composed of `metadataFeature` alone has no show-and-hide choreography to join, so the title stays visible for as long as one resolves. The component owns its text content. Authors set the title through `content-title` / `contentTitle` or `setContentTitle`, keeping the resolution order defined in the resolved-feature-state record as the only way to supply one. Refs #1123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118ZxrT7hBHhZ57GdgCTvUy
Document the component, the two data attributes skins style against, and how a title reaches the player. Include HTML and React demos that key their transition off `data-visible` so the styling contract is exercised before a skin depends on it. Refs #1123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118ZxrT7hBHhZ57GdgCTvUy
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for vjs10-site ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📦 Bundle Size Report🎨 @videojs/html
Small changes (9, ≤ 300 B)
Presets (7)
Media (13)
Players (5)
Skins (30)
UI Components (40)
⚛️ @videojs/react
Small changes (20, ≤ 300 B)
Presets (7)
Media (12)
Skins (27)
UI Components (34)
🧩 @videojs/core — 1 small size change
Entries (76)
🏷️ @videojs/element — no changesEntries (2)
📦 @videojs/store — no changesEntries (3)
🔧 @videojs/utils — no changesEntries (13)
📦 @videojs/jsx — no changesEntries (3)
📦 @videojs/media
Small changes (4, ≤ 300 B)
Entries (15)
📦 @videojs/spf — no changesEntries (7)
ℹ️ How to interpretEach entry is independently bundled, minified, and brotli-compressed. Initial size includes its static import graph; lazy dynamic chunks are reported separately. Entries are not additive because their dependency graphs overlap. Preset rows represent realistic combined bundles. Changes of 300 B or less across initial, lazy, and total size are collapsed, not discarded. Run |
decepulis
left a comment
There was a problem hiding this comment.
Just some comments for claude
| import type { MediaControlsState, MediaMetadataState, MediaPlaybackState } from '@videojs/media'; | ||
|
|
||
| /** | ||
| * Media state the title reads. Controls and playback are optional so a player |
There was a problem hiding this comment.
Why are these optional? Would this code be more simple they weren't? Is there precedent for this in the codebase?
There was a problem hiding this comment.
Good catch — one of the two was buying nothing, and the whole type was wider than it needed to be. Fixed in 1c48b8d.
Precedent. No core in the repo takes a Partial<FeatureState>; the only Partial<> uses are label overrides. The two shapes that do exist:
LiveButtonCorenarrows withPick:Pick<MediaTimeState, 'currentTime' | 'seek'> & Pick<MediaBufferState, 'seekable'> & MediaLiveState.TimeSliderCorerequiresMediaTimeState & MediaBufferState, andTimeSliderElementsupplies{ buffered: [], seekable: [] }when the buffer feature is absent — the fallback lives in the adapter, not the core.
Playback: made required. Every preset includes playbackFeature, so Partial there was dead weight. The adapters now bail and warn like Poster does.
Controls: still optional, but for a concrete reason. audioFeatures and liveAudioFeatures leave out controlsFeature — audio chrome never auto-hides, so there is no visibility to follow. controlsVisible ?? true is what "this player has no hiding controls" means, and the JSDoc now says exactly that instead of hand-waving about features in general.
Net effect on the type:
export type TitleMediaState = Pick<MediaMetadataState, 'contentTitle'> &
Pick<MediaPlaybackState, 'paused'> & {
controlsVisible?: MediaControlsState['controlsVisible'] | undefined;
};Three fields instead of three whole feature states, one fallback instead of two, and the tests no longer need to mock setContentTitle, requestControlsLock, and friends just to construct a state.
Generated by Claude Code
There was a problem hiding this comment.
Correcting my reply above — I took "make it required" one step too far and broke a real consumer.
createPlayer({ features: [metadataFeature] }) has no playback slice, and that is exactly what apps/sandbox/templates/metadata composes. With playback required the adapters bailed out, so that template rendered nothing. This PR updates that template, so I broke the thing it was meant to demonstrate.
Fixed in 560b7c3 by pushing your question one level further out. TitleMediaState now requires all three fields, and TitleCore has no ?? left in it at all:
export type TitleMediaState = Pick<MediaMetadataState, 'contentTitle'> &
Pick<MediaPlaybackState, 'paused'> &
Pick<MediaControlsState, 'controlsVisible'>;The degradation moved out to the adapters, which is where TimeSliderElement already keeps it for its optional buffer slice (...(buffer ?? { buffered: [], seekable: [] }), time-slider-element.ts:161):
paused: this.#playbackState.value?.paused ?? true,
controlsVisible: this.#controlsState.value?.controlsVisible ?? true,So the answer to your question is yes, and more than my first pass managed: no optionality in the core, no branch to reason about, and a player missing either slice still works — a player with nothing that hides the title simply doesn't hide it. Both reduced compositions now have tests: no controlsFeature (the audio presets) and metadataFeature alone (the sandbox).
Generated by Claude Code
There was a problem hiding this comment.
all of these definitions should be moved to the PR where the title is actually added to the skins, so they wouldn't clutter this PR.
There was a problem hiding this comment.
Done in 1c48b8d — removed from all six ui.ts / minimal-ui.ts entries. define/ui/title.ts stays, so <media-title> is still available as a granular import (that is what the docs demo uses).
Worth noting this also fixes a size regression the bundle bot flagged on this PR: every skin entry does import './ui', so registering the element there grew all thirteen skin entries (/video/skin +257 B) for a component none of them render. The stacked skin PR adds the registrations back where they pay for themselves.
Generated by Claude Code
| The component displays the title resolved by the metadata feature. It owns its own | ||
| text content — set the title on the player rather than by passing children. |
There was a problem hiding this comment.
"resolved by the metadata feature" is hella jargony. Heavily consider the guidance in writing-style.md
There was a problem hiding this comment.
Rewritten. "Resolved by the metadata feature" is gone, along with "feature", "state", and "precedence" as reader-facing words. The mechanism is now stated as three places a title can come from, in the order the player tries them:
A title can come from three places. The player uses the first one that has a value:
contentTitle, the title you set.- The title the media reports about itself.
defaultContentTitle, the fallback you set for media that does not name itself.
Generated by Claude Code
| The metadata feature resolves one value from several sources, in order: the title you | ||
| set, the title the media reports, the fallback you set with `defaultContentTitle`, and | ||
| finally an empty string. You can also update it at any time by calling `setContentTitle` | ||
| on the store. |
There was a problem hiding this comment.
This paragraph is so so so important. Make sure it communicates with the utmost clarity and minimal jargon
There was a problem hiding this comment.
Agreed — this is the paragraph people will actually need, so it now states the rule as a rule and then says what it buys the viewer:
The title shows when three things are true: a title exists, the controls are on screen, and playback is paused. Press play and it hides, so it stays out of the way of the video.
A player that leaves out a feature simply has less to hide behind. Audio players have no auto-hiding controls, so their title follows playback alone; a player with no playback at all shows its title for as long as one resolves.
The second paragraph is new in 560b7c3 and describes real behavior now, not a hypothetical — both compositions are covered by tests.
One nearby claim was also just wrong: the page said an empty title "renders nothing." It doesn't — the element stays in the page with no text. That is the whole reason data-has-title exists, so the corrected sentence points at it rather than leaving the reader to discover an invisible empty element sitting on their layout.
Generated by Claude Code
Address review feedback on the title component: - `TitleMediaState` now picks the three fields the core reads instead of intersecting three whole feature states. Playback is required — every preset includes it. Controls stays optional because the audio presets leave out `controlsFeature`; audio chrome never auto-hides. - Drop the bulk `safeDefine(TitleElement)` calls from the `ui` and `minimal-ui` entries. Nothing renders `<media-title>` yet, so they land with the skin work instead. The granular `ui/title` entry is unchanged. - Rewrite the title resolution and visibility prose without jargon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118ZxrT7hBHhZ57GdgCTvUy
…ore-yzkrvh # Conflicts: # site/src/docs.config.ts
Making playback required in 1c48b8d broke every player composed without `playbackFeature` — including the metadata sandbox template this PR updates, which composes `metadataFeature` alone and now rendered nothing. Take the shape `TimeSliderElement` already uses for its optional buffer slice: the core type requires every field, and the adapters substitute a neutral value when a slice is absent. `TitleCore` loses both `??` branches, `TitleMediaState` loses its optional member, and `paused` and `controlsVisible` fall back to the value that keeps the title on screen. Also adds the `ui/title/index.ts` React entry, without which the component was excluded from the build's entry glob and the bundle-size report, and corrects the reference page: an empty title renders an empty element, not nothing.
Refs #1123
Refs #1798
Summary
Adds
<media-title>and<Title>, the UI for the content title that #1946 taught the store to resolve. This is the component layer only — no skin templates or skin styles, which are their own can of worms. #2036 stacks on this and puts the title in the video skins.Revives the salvageable half of the closed #1603. That PR shipped its own metadata state, which #1875 and #1946 have since replaced with a different design, so only its UI survives — and rewritten, not ported.
Changes
TitleCoreprojects metadata, playback, and controls state intotitle,hasTitle, andvisible.data-has-titleto remove the element when there is nothing to show,data-visiblefor the transition.metadataFeatureis the only feature the component needs.playbackFeatureandcontrolsFeatureare each used when present; a player without them has nothing that hides the title, so it stays up for as long as one resolves.@videojs/html/ui/title. No stock skin renders it yet, so no skin entry pays for it.Notable decisions
The core owns the visibility policy, not the skin CSS. #1603 decided visibility in a stylesheet, reaching sideways into the controls' internals:
.media-controls[data-visible]:has([data-paused]) ~ .media-title. That[data-paused]comes frommedia-play-button, so the title silently stopped working in any layout without a play button nested inside its controls.PosterCoreandBufferingIndicatorCoreboth compute their ownvisibleand exposedata-visiblefor CSS to react to; this follows them.Optional features degrade in the adapters, not in the core.
TitleMediaStaterequires all three fields, and the HTML and React adapters substitutepaused: true/controlsVisible: truewhen a slice is absent. That is the shapeTimeSliderElementalready uses for its optional buffer slice (...(buffer ?? { buffered: [], seekable: [] })), and it keeps the branching out of the core.No ARIA. #1603 put
aria-label(the title text) and a conditionalaria-hiddenon a plain<span>.aria-labelon a role-less element is invalid and ignored by most AT, and it duplicated the visible text. The text node is already in the accessibility tree. Thelabelprop existed only to feed that attribute and is gone with it.The component owns its text; no slot. Authors override through
contentTitle/content-title/setContentTitle. A DOM-content override would be a second override mechanism competing with the precedence chaininternal/design/store/resolved-feature-state.mddefines, and primitives in this codebase take no slots.Deliberately not here: skin templates and styles (#2036), and naming the player region from the title (the container has its own don't-clobber-the-author logic in both platforms; worth its own change).
Testing
pnpm -F @videojs/core test src/core/ui/title·pnpm -F @videojs/html test src/ui/title·pnpm -F @videojs/react test src/ui/title— 24 tests.Full suites green: core 1414, html 314, react 403.
pnpm build,pnpm typecheck,pnpm lint,pnpm check:workspace(10/10), andpnpm -F site astro check(0 errors) all pass.The HTML element test attaches a fake media and drives
pausedthrough realplay/pauseevents rather than poking store internals. It also covers the two reduced compositions — nocontrolsFeature(the audio presets) andmetadataFeaturealone (the metadata sandbox template) — because both are real consumers, not hypotheticals.Merged with
mainrather than rebased; the only conflict wassite/src/docs.config.ts, where the API reference grew nested groups. Title sits alphabetically under UI Components.Generated by Claude Code