import { Plugin, IPluginAPI } from '@scarlett-player/core'; export { formatLiveTime, formatTime } from '@scarlett-player/core'; /** * Control bar fit planning. * * The control bar is a single non-wrapping flex row of fixed-width items * inside a host element that clips (`overflow: hidden` on the demo's `#player` * and on tsp-web's wrapper). Below roughly 543px of player width the default * layout renders past the clipping edge, and on a 390px phone with tsp-web's * 17-slot layout the entire right-hand group (settings, captions, cast, PiP, * fullscreen) is off canvas: captions and playback speed are not broken, they * are unreachable. * * This module owns the arithmetic that decides what stays. It is deliberately * pure and DOM free: jsdom has no layout engine, so a fit strategy expressed in * CSS (container queries, width tiers) cannot be unit tested, and a width tier * cannot guarantee a fit anyway because the time readout is 87px or ~130px * depending on the duration, every control hides itself on state, and hosts add * their own registered controls. The caller measures and applies; everything * here is inputs to outputs. * * @packageDocumentation */ /** * How eagerly a control leaves the bar. * * Lower numbers leave first. `'never'` pins a control in the bar at every * width, which is what keeps play, settings and fullscreen reachable below the * point where the bar stops fitting at all. */ type FitRank = number | 'never'; /** * Where a control goes when it is pushed out of the bar. * * `'overflow'` moves the element into the tray, where it is still one tap * away. `'hide'` takes it off screen entirely, and is only right for items * that convey status rather than offering an action. */ type FitExit = 'overflow' | 'hide'; /** * A slot's placement rule: how eagerly it leaves, and where it goes. */ interface FitRule { /** Lower leaves first; `'never'` pins the control in the bar. */ rank: FitRank; /** Tray or off screen. */ exit: FitExit; } /** * A slot's rule paired with the id it applies to, before measurement. */ type FitTemplate = FitRule & { /** Control slot id, as written in the layout. */ id: string; }; /** * One measured control, ready to be planned. */ interface FitItem extends FitTemplate { /** Rendered width in px (border box), or a cached width for items already out of the bar. */ width: number; /** False when the control hid itself (no text tracks, no cast device, and so on). */ visible: boolean; } /** * Where every control should be after the fit. * * All three lists are in layout order, and every input id appears in exactly * one of them. */ interface FitPlan { /** Controls that stay in the bar. */ inBar: string[]; /** Controls that move into the overflow tray. */ overflow: string[]; /** Controls that are taken off screen. */ hidden: string[]; } /** * Default placement rules, lowest rank first. * * The order is a reachability argument, not a taste one: * * - `bandwidth-indicator` (0) is a status glyph, not an action, so it is the * one item that is cheaper to drop than to relocate. * - the skip buttons (1) are the first actions to go because the gestures * plugin covers the same seek by double tap on exactly the devices where the * bar runs out of room. * - `pip` (2) is desktop-shaped and unavailable on iPhone. * - registered controls (3, see {@link UNKNOWN_RANK}), alongside the optional * `keyboard-help` button: it is a convenience, `?` opens the same dialog at * every width, so it relocates to the tray rather than displacing anything. * - the cast buttons (4) go to the tray and are never hidden: AirPlay is how an * iPhone viewer gets the stream onto a television. * - `volume` (5) is close to inert on iOS, where `video.volume` is read only * and the hardware buttons own it. * - `captions` and `quality` (6) are the last actions to leave, and both are * also reachable inside the settings menu, which never moves. They part * company on the way out: `captions` goes to the tray, `quality` hides. * `quality` owns a popover, and the bound that keeps a popover inside the * player (`--sp-menu-max-height`) is sized for a menu anchored in the bar. * The tray strip sits above the bar (60px tall for a single row of buttons, * and taller once they wrap), so a quality menu opened from the tray starts * that much higher than its bound assumes and runs off the top of the player * wherever the bound binds. Nothing is lost by hiding it: the settings menu carries a * Quality row whenever there are qualities to choose. That is a promise about * the layout, not the control, so {@link assertFitLayout} refuses a layout * that has `quality` without `settings` before the bar is ever fitted. * - `time` (7) hides rather than relocating: a time readout inside a tray tells * the viewer nothing, and the scrub tooltip still reports position. * - `play`, `live-indicator`, `settings`, `fullscreen` and `spacer` are pinned. * Settings staying put is what keeps speed and captions two taps away at * every width. */ declare const DEFAULT_PRIORITY: Readonly>; /** * Resolve a layout into placement rules, in layout order. * * @param layout - Control slot ids, exactly as the host configured them * @param priority - Per slot rank overrides; `'never'` pins a control in the bar * @returns One template per slot, ready to be measured and planned */ declare function resolveFitItems(layout: readonly string[], priority?: Record): FitTemplate[]; /** * Refuse a layout the fit would strip of its only quality control. * * `quality` hides when the bar runs out of room, on the strength of the * settings menu carrying a Quality row (see {@link DEFAULT_PRIORITY}). A * layout with `quality` and no `settings` has nowhere for that row to live, so * below the width where the bar fits the viewer would have no way to pick a * rendition at all. The host hears about it from the `uiPlugin()` call rather * than from a viewer on a phone. * * A pinned `quality` (`priority: { quality: 'never' }`) never leaves the bar, * so that layout is accepted. * * @param layout - Control slot ids, exactly as the host configured them * @param priority - Per slot rank overrides, as passed to {@link resolveFitItems} * @throws Error when the layout has `quality` without `settings` and does not pin it */ declare function assertFitLayout(layout: readonly string[], priority?: Record): void; /** * Decide which controls stay in the bar, which move to the tray, and which go * off screen. * * Items that are invisible or have no width are never counted and are never * candidates for removal: a control that hid itself occupies nothing, and * moving it would only make its own next `update()` fight the fit. * * The tray button's own width joins the arithmetic as soon as the first item * moves, which is what stops the oscillation the naive version has ("moving one * item out makes room, so move it back, so it overflows again"). Nothing else * is stateful, so the same inputs always give the same plan and no hysteresis * is needed. * * @param items - Measured controls in layout order; exclude the spacer, whose * rendered width is the bar's own slack and would make a fitting bar look * exactly full. An excluded spacer is still a flex child of the row, so the * caller has to deduct one gap per spacer from `available`: this charges * (n - 1) gaps for the n items it is handed, and the bar lays out one more * @param available - Inner width of the control bar in px, with the bar's own * padding and one gap per excluded spacer already taken off * @param gap - Flex gap between bar items in px * @param overflowButtonWidth - Width of the tray button in px * @returns The target placement for every input id */ declare function planFit(items: readonly FitItem[], available: number, gap: number, overflowButtonWidth: number): FitPlan; /** * UI Controls Plugin Types */ /** * Control slots this package implements itself. */ type BuiltinControlSlot = 'play' | 'skip-backward' | 'skip-forward' | 'volume' | 'progress' | 'time' | 'live-indicator' | 'quality' | 'settings' | 'captions' | 'airplay' | 'chromecast' | 'pip' | 'fullscreen' | 'spacer' | 'bandwidth-indicator' | 'keyboard-help'; /** * A control slot: a built-in, or any id a plugin registered through * {@link registerControl}. * * `string & {}` keeps editor autocomplete listing the built-ins while still * accepting custom ids - a plain `| string` would collapse the union and lose * the suggestions. */ type ControlSlot = BuiltinControlSlot | (string & {}); /** * Builds a control instance. Receives the same plugin API the built-ins get. */ type ControlFactory = (api: IPluginAPI) => Control; /** * Layout configuration for the control bar. */ interface LayoutConfig { /** * Order of controls in the control bar. * * A layout with `quality` must also have `settings`: `quality` hides when * the bar does not fit, and the settings menu is where its Quality row * lives. `uiPlugin()` throws otherwise, unless `quality` is pinned through * `priority` or `responsive` is off. */ controls?: ControlSlot[]; /** Delay in ms before hiding controls (default: 3000) */ hideDelay?: number; } /** * Theme configuration for styling the controls. */ interface ThemeConfig { /** Primary text/icon color (default: '#fff') */ primaryColor?: string; /** Accent color for active states (default: '#e50914') */ accentColor?: string; /** * Accent color for TEXT and active-state glyphs - the LIVE label, the * active rows in the settings and quality menus, the active captions and * cast buttons (default: a lighter tone of the accent, `#ff4d57`). * * Split from `accentColor` because text answers to 4.5:1 where a fill * answers to 3:1: the brand red is 4.38:1 on black and fails as a label * while passing as a progress bar. Unset, it follows `accentColor`, so a * dark brand accent needs this set - `accentTextTone(accentColor)` derives * one that clears AA on the menus from a hex, `rgb()`/`rgba()` or named * `accentColor`, and hands any other form (`hsl()`, `var()`) back as given. */ accentTextColor?: string; /** Control bar background (default: 'rgba(0,0,0,0.7)') */ backgroundColor?: string; /** Control bar height in px (default: 48) */ controlBarHeight?: number; /** Icon size in px (default: 24) */ iconSize?: number; } /** * UI plugin configuration. */ interface UIPluginConfig extends LayoutConfig { /** Theme configuration */ theme?: ThemeConfig; /** * Show the centred big play button over the poster (default: true). * * It is the only play affordance on the picture itself: a mouse click on * the video surface only reveals the control bar, and touch taps belong to * the gestures plugin, so a player showing a poster with this off asks the * viewer to find the small button in the bar. Set it to `false` when the * host page draws its own play affordance over the player. */ bigPlayButton?: boolean; /** * Enable the player's global keyboard shortcuts (default: true). * * `false` attaches no document keydown listener, so Space/K, M, F, arrows, * ?, C and 0-9 do nothing. The controls' own keyboard handling (Enter/Space * on buttons, arrow keys in the settings menu, the help dialog's own keys) * is unchanged and the container stays focusable. */ keyboard?: boolean; /** * Let the bar move low-priority controls into a tray when it does not fit * (default: true). * * The bar is a single non-wrapping row of fixed-width items inside a host * that clips, so without this a narrow player simply renders its right-hand * controls past the edge: on a 390px phone with tsp-web's 17-slot layout that * is settings, captions, cast, PiP and fullscreen, all of them off canvas * (measured 2026-09-05). With it on, the bar measures itself and relocates * the least important controls into a tray behind a "More controls" button. * * `false` restores the pre-1.8 behaviour exactly: no measuring, no observer, * no tray button and no extra DOM. It is the escape hatch for a host that * pins its own layout and would rather clip. */ responsive?: boolean; /** * Override how eagerly individual controls leave the bar. * * Keyed by control slot id; lower ranks leave first and `'never'` pins a * control in the bar at every width. Unlisted slots keep their default * ({@link DEFAULT_PRIORITY}), and any id that is not a built-in defaults to * rank 3. Use it to pin a registered control that owns an overlay positioned * against its own button, or to make a control the host cares about outlive * the rest. * * @example * ```ts * uiPlugin({ priority: { share: 'never', 'skip-forward': 6 } }); * ``` */ priority?: Record; } /** * Base interface for all control components. */ interface Control { /** Render the control element */ render(): HTMLElement; /** Update control state */ update(): void; /** Cleanup when control is destroyed */ destroy(): void; } /** * UI Controls Plugin interface. */ interface IUIPlugin extends Plugin { readonly id: 'ui-controls'; /** Show the controls */ show(): void; /** Hide the controls */ hide(): void; /** Apply a theme configuration */ setTheme(theme: ThemeConfig): void; /** Get the control bar element */ getControlBar(): HTMLElement | null; } /** * Custom control registry. * * The built-in controls are created by a switch in the UI plugin. Anything a * plugin package contributes is registered here instead, so a control-bar * button no longer requires editing this package. * * Registrations are either global or scoped to one player. * * A global registration is the right shape for a stateless factory: it is made * once, and the factory receives the per-instance `IPluginAPI`, so the controls * it builds are already scoped to their player. * * A factory that closes over its own plugin instance - a chapter list, a * playlist panel - must NOT be global. Every player registering one overwrote * the last, so a rebuild in player A handed it player B's control, and the two * players drove one element. Those registrations pass `owner`, the player's * container, and the UI plugin only ever looks up factories owned by its own * container (falling back to the global ones). */ /** Options controlling how far a registration reaches. */ interface RegisterControlOptions { /** * Player container the registration belongs to. * * Omit for a stateless factory that any player may use. Pass `api.container` * whenever the factory closes over per-player state. */ owner?: HTMLElement; } /** * Register a control factory under a slot id. * * The id only takes effect for players whose layout lists it - registering * alone never adds a button anywhere. Hosts opt controls in through * `uiPlugin({ controls: [...] })`. * * Registering an id that already exists in the same scope replaces the factory * and re-notifies, which keeps hot-reload workable. * * @param id - Slot id, conventionally the plugin's own name * @param factory - Builds the control for a given player * @param options - Pass `{ owner: api.container }` when the factory closes over * per-player state, so a second player cannot take it over * * @example * ```ts * // Stateless: any player may build one. * registerControl('share', (api) => new ShareButton(api)); * * // Closes over this plugin instance: scope it to the player. * registerControl('chapters', () => list, { owner: api.container }); * ``` */ declare function registerControl(id: string, factory: ControlFactory, options?: RegisterControlOptions): void; /** * Remove a registered control factory. * * Players already showing the control keep their existing instance until they * rebuild; this only stops future ones being created. * * @param id - Slot id to remove * @param options - Pass the same `owner` the registration used * @returns Whether a factory was registered in that scope under that id */ declare function unregisterControl(id: string, options?: RegisterControlOptions): boolean; /** * Drop every registration a player made. * * Called from a plugin's `destroy()` so a torn-down player leaves nothing * behind that a later player could pick up. * * @param owner - The player container the registrations were scoped to * @returns Number of factories removed */ declare function unregisterControlsFor(owner: HTMLElement): number; /** * Look up a registered factory. * * A factory this player owns wins over a global one of the same id, so a * per-instance control is never served another player's closure. * * @param id - Slot id * @param owner - The looking-up player's container, when it has one * @returns The factory, or null when nothing is registered for that id */ declare function getControlFactory(id: string, owner?: HTMLElement): ControlFactory | null; /** * Clear all registrations and listeners. * * Test-support only - the registry is module-level, so without this a * registration in one test leaks into the next. * * @internal */ declare function resetControlRegistry(): void; /** * Timeline extension registry. * * The control registry lets a plugin contribute a *button*. This lets one * contribute an editing layer over the **playback timeline** itself - the clip * plugin's in/out handles are the first, and the reason it exists: a clip * editor with its own miniature track cannot express a 30 second selection on * a two hour source, and forces the viewer to pick points on a rail that is * not the one they were just scrubbing. * * The contract is deliberately narrow, and the direction of the dependency is * the point: `@scarlett-player/ui` knows nothing about clips, reads no * clip-specific state, and gains no `IPluginAPI` surface. It offers a * positioned layer over its rail plus four lifecycle calls, and an extension * paints into it. * * Registrations are keyed by the player container in a `WeakMap`, so two * players on one page never see each other's extension and a destroyed player * leaves nothing to collect. There is **one** active editing extension per * player: a second registration replaces the first, and the replaced one is * destroyed. The disposer returned by `registerTimelineExtension` is * identity-safe - it only removes the registration it made, so cleanup running * after a replacement cannot unmount its successor. * * ## Ordering * * Plugin init order is not guaranteed, so both orders work. Register before * the UI plugin builds its bar and the extension mounts as soon as the * progress bar attaches; register afterwards and it mounts immediately. * * @example * ```ts * const release = registerTimelineExtension(api.container, (surface) => { * surface.element.appendChild(myHandles); * return { * update: () => reposition(surface.getRailRect()), * onSeekStart: () => suspendPreview(), * onSeekEnd: () => {}, * destroy: () => myHandles.remove(), * }; * }); * ``` */ /** * What the UI package offers an extension: a layer to paint into, the rail's * measured geometry, and two leases. */ interface TimelineSurface { /** * The extension's own layer. * * A sibling of the `role="slider"` seek element - never inside it, so an * extension's controls are not swallowed by the slider's accessibility * semantics - positioned with exactly the horizontal geometry of the visible * rail and centred on it vertically. It paints above the buffer, chapter and * progress layers. * * The layer itself is `pointer-events: none`; give explicit hit targets * inside it `pointer-events: auto`, and plain presses on the rail keep * seeking as they always did. */ element: HTMLElement; /** * The visible rail's current box, in client coordinates. * * The same box the seek slider maps a press to, so an extension mapping * `clientX` to a time agrees with the player's own seeking to the pixel. * * @returns The rail's bounding rectangle */ getRailRect(): DOMRect; /** * Enter or leave editing mode. * * While active the control bar and progress bar are held visible (auto-hide * cannot take an editor off screen mid-gesture) and the timeline reserves * the vertical room an editor needs around the rail. Leaving restores the * ordinary geometry and restarts the normal hide delay. * * Independent of any control's `isMenuOpen()`: an extension opened from a * host's own button, in a layout with no matching control at all, still * holds the bar. * * @param active - Whether the extension is editing */ setEditing(active: boolean): void; /** * Take or release the pointer lease. * * While active the timeline's own seeking is suppressed, so dragging an * extension's handle past the rail cannot also scrub the video, and the * hover tooltip stays out of the way. * * @param active - Whether the extension owns the current pointer */ setDragging(active: boolean): void; } /** What an extension gives back to the UI package. */ interface TimelineExtension { /** * Re-read geometry and repaint. Called with the progress bar's own update, * so an extension never has to poll or observe the rail itself. */ update(): void; /** * An ordinary seek began on the timeline (mouse, touch or keyboard) - not * one the extension owns. */ onSeekStart(): void; /** The ordinary seek ended, was released outside the rail, or was cancelled. */ onSeekEnd(): void; /** Unmount: remove everything added to `surface.element` and drop listeners. */ destroy(): void; } /** * Builds an extension for one player's timeline. * * @param surface - The layer, geometry and leases (see {@link TimelineSurface}) * @returns The mounted extension */ type TimelineExtensionFactory = (surface: TimelineSurface) => TimelineExtension; /** * Register the editing extension for one player's timeline. * * One extension is active per player. Registering again replaces the previous * one, which is destroyed; the returned disposer only ever removes the * registration *it* made, so a late cleanup cannot unmount a replacement. * * Works before or after the UI plugin has initialised: if the progress bar is * already mounted the factory runs immediately, otherwise it runs when the bar * attaches. * * @param owner - The player container, i.e. `api.container` * @param factory - Builds the extension for that player's timeline surface * @returns Disposer that unmounts this registration, immediately and once */ declare function registerTimelineExtension(owner: HTMLElement, factory: TimelineExtensionFactory): () => void; /** * Drop a player's registration whatever made it. * * For a host tearing a player down wholesale; ordinary plugins use the * disposer they were handed. * * @param owner - The player container * @returns Whether a registration was removed */ declare function unregisterTimelineExtension(owner: HTMLElement): boolean; /** * Whether a player has an editing extension registered. * * Lets a plugin feature-detect its own registration without keeping a second * copy of the bookkeeping. * * @param owner - The player container * @returns True when a factory is registered for that player */ declare function hasTimelineExtension(owner: HTMLElement): boolean; /** * SVG Icons for UI Controls * * Clean, minimal icons - 24x24 viewBox with currentColor fill. * Optimized for clarity at small sizes. * * The three glyphs `audio-ui` draws identically come from core's * `SHARED_ICON_PATHS`; the `` wrapper stays here because this package * paints with `fill="currentColor"` and `audio-ui` does not. */ declare const icons: { readonly play: ""; readonly pause: ""; readonly replay: ""; readonly volumeHigh: ""; readonly volumeLow: ""; readonly volumeMute: ""; readonly fullscreen: ""; readonly exitFullscreen: ""; readonly pip: ""; readonly exitPip: ""; readonly settings: ""; readonly chromecast: ""; readonly chromecastConnected: ""; readonly airplay: ""; readonly captions: ""; readonly captionsOff: ""; readonly checkmark: ""; readonly chevronUp: ""; /** Vertical ellipsis for the overflow tray button. */ readonly more: ""; readonly chevronDown: ""; /** X glyph for the keyboard help dialog's close button. */ readonly close: ""; /** Question-mark circle for the keyboard help bar control. */ readonly keyboardHelp: ""; readonly spinner: ""; readonly skipForward: ""; readonly skipBack: ""; readonly forward10: ""; readonly replay10: ""; readonly error: ""; }; /** * CSS Styles for UI Controls * * Modern, minimal design inspired by Mux Player and Vidstack. * Uses CSS custom properties for theming. */ declare const styles = "\n/* ============================================\n Container & Base\n ============================================ */\n.sp-container {\n position: relative;\n /* Own stacking context: the control bar, menus and overlays all carry\n z-index, and without this they compete with the host page's own layers\n instead of staying inside the player. */\n isolation: isolate;\n width: 100%;\n height: 100%;\n background: #000;\n overflow: hidden;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.sp-container video {\n width: 100%;\n height: 100%;\n display: block;\n object-fit: contain;\n}\n\n/* The container is a tab stop - the plugin sets tabindex=\"0\" on it so the\n keyboard shortcuts have somewhere to land - so it must show where focus is.\n A pointer press focuses it too, which is why the ring is on :focus-visible\n and the plain :focus keeps outline:none: clicking the video should not draw\n a box around the player. The offset is negative so the ring is painted\n inside the player box; drawn outside it, a host page's own overflow or a\n flush-fitting wrapper can clip it away. */\n.sp-container:focus {\n outline: none;\n}\n\n.sp-container:focus-visible {\n outline: 3px solid var(--sp-accent, #e50914);\n outline-offset: -3px;\n}\n\n/* ============================================\n Gradient Overlay\n ============================================ */\n.sp-gradient {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 160px;\n background: linear-gradient(\n to top,\n rgba(0, 0, 0, 0.8) 0%,\n rgba(0, 0, 0, 0.4) 50%,\n transparent 100%\n );\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.25s ease;\n z-index: 5;\n}\n\n.sp-gradient--visible {\n opacity: 1;\n}\n\n/* ============================================\n Controls Container\n ============================================ */\n.sp-controls {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n display: flex;\n align-items: center;\n padding: 0 12px 12px;\n /* Composed through a variable so the fullscreen rule below can add the\n device's own inset without restating the 12px. */\n padding-bottom: calc(12px + var(--sp-inset-bottom, 0px));\n gap: 4px;\n /* Declared on the bar because the fit needs the same number the volume rules\n below use: the slider expands mid-interaction and the plugin reserves the\n room in advance (see interactionReserve() in index.ts, which reads this\n property off this element at init). One declaration, so the stylesheet and\n the arithmetic cannot drift. Both the bar and the overflow tray are inside\n .sp-controls, so a volume control inherits it wherever the fit put it. */\n --sp-volume-slider-width: 64px;\n opacity: 0;\n transform: translateY(4px);\n transition: opacity 0.25s ease, transform 0.25s ease;\n z-index: 10;\n}\n\n.sp-controls--visible {\n opacity: 1;\n transform: translateY(0);\n}\n\n.sp-controls--hidden {\n opacity: 0;\n transform: translateY(4px);\n pointer-events: none;\n}\n\n/* ============================================\n Safe Area (fullscreen only)\n\n Scoped to :fullscreen on purpose. Applied unconditionally, the inset would\n push an inline player's controls up on any page whose viewport meta says\n viewport-fit=cover, where there is no notch or home indicator over the\n player at all. Both the bar and the progress wrapper are direct children of\n the container, which is the element that goes fullscreen.\n\n The :-webkit-full-screen twin is a separate rule because an unknown\n pseudo-class anywhere in a selector list invalidates the whole rule.\n\n Nothing is needed in packages/embed/iframe.html: viewport-fit has no effect\n inside an iframe.\n ============================================ */\n:fullscreen > .sp-controls,\n:fullscreen > .sp-progress-wrapper {\n --sp-inset-bottom: env(safe-area-inset-bottom, 0px);\n}\n\n:-webkit-full-screen > .sp-controls,\n:-webkit-full-screen > .sp-progress-wrapper {\n --sp-inset-bottom: env(safe-area-inset-bottom, 0px);\n}\n\n/* ============================================\n Progress Bar (Above Controls)\n ============================================ */\n.sp-progress-wrapper {\n position: absolute;\n bottom: calc(48px + var(--sp-inset-bottom, 0px));\n left: 12px;\n right: 12px;\n height: 20px;\n display: flex;\n align-items: center;\n cursor: pointer;\n z-index: 10;\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.25s ease;\n}\n\n.sp-progress-wrapper--visible {\n opacity: 1;\n pointer-events: auto;\n}\n\n/* Touch: a 20px wrapper is not a 20px target. The control bar is a later\n sibling at the same z-index and spans 0..56px from the bottom, so it wins\n hit-testing in the 48..56 overlap and the exclusive region for scrubbing is\n 12px. The wrapper grows UPWARD to 44px (48..92) because growing downward\n would be swallowed by the bar; the 3px bar itself stays exactly where it was\n (centred 8.5px above the wrapper's bottom edge, which is what\n align-items: center gave it inside 20px). The handle and tooltip\n enlargements are gated behind (hover: hover) and never match a finger, but\n .sp-progress--dragging is not, so the handle still appears mid-drag.\n\n any-pointer, not pointer: (pointer: coarse) describes the PRIMARY pointer\n only, so a hybrid laptop with a mouse and a touchscreen reports fine and kept\n the 12px exclusive region under a finger. (any-pointer: coarse) is true\n whenever a coarse pointer is available at all, which is the population that\n needs the target. The cost on such a machine is 24px of extra hit area for\n the mouse, over the player's own bottom edge. */\n@media (any-pointer: coarse) {\n .sp-progress-wrapper {\n height: 44px;\n align-items: flex-end;\n padding-bottom: 8.5px;\n box-sizing: border-box;\n }\n}\n\n.sp-progress {\n position: relative;\n width: 100%;\n height: 3px;\n background: rgba(255, 255, 255, 0.3);\n border-radius: 1.5px;\n transition: height 0.15s ease;\n}\n\n@media (hover: hover) {\n .sp-progress-wrapper:hover .sp-progress {\n height: 5px;\n }\n}\n\n.sp-progress--dragging {\n height: 5px;\n}\n\n/* ============================================\n Timeline extension layer (timeline-registry.ts)\n\n A zero-height line lying exactly on the rail's centre, spanning exactly the\n rail's width, so an extension can position by percentage and land on the\n same pixels the seek slider maps a press to. The 10px offset is the rail\n centre in BOTH wrapper modes: the fine-pointer wrapper is 20px tall with the\n 3px rail centred (8.5..11.5 from the bottom), and the coarse-pointer wrapper\n is 44px tall with 8.5px of bottom padding and align-items: flex-end, which\n puts the rail in the same place.\n\n Inert by default: only the explicit hit targets an extension puts inside it\n take pointer input, so an ordinary press on the rail still seeks.\n ============================================ */\n.sp-progress__extension {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 10px;\n height: 0;\n z-index: 3;\n pointer-events: none;\n}\n\n/* Editing reserves a lane below the rail for an extension's second handle, by\n lifting the whole wrapper clear of the control bar. The lane above needs no\n reservation - it is over the picture. */\n.sp-progress-wrapper--editing {\n bottom: calc(92px + var(--sp-inset-bottom, 0px));\n}\n\n.sp-progress__track {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n border-radius: inherit;\n overflow: hidden;\n}\n\n.sp-progress__buffered {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n background: rgba(255, 255, 255, 0.4);\n border-radius: inherit;\n transition: width 0.1s linear;\n}\n\n.sp-progress__filled {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n background: var(--sp-accent, #e50914);\n border-radius: inherit;\n}\n\n/* Chapter markers */\n.sp-progress__markers {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n}\n\n.sp-progress__marker {\n position: absolute;\n top: 0;\n width: 2px;\n height: 100%;\n margin-left: -1px;\n background: rgba(0, 0, 0, 0.65);\n}\n\n.sp-progress__handle {\n position: absolute;\n top: 50%;\n width: 14px;\n height: 14px;\n background: var(--sp-accent, #e50914);\n border-radius: 50%;\n transform: translate(-50%, -50%) scale(0);\n transition: transform 0.15s ease;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n}\n\n@media (hover: hover) {\n .sp-progress-wrapper:hover .sp-progress__handle {\n transform: translate(-50%, -50%) scale(1);\n }\n}\n\n.sp-progress--dragging .sp-progress__handle {\n transform: translate(-50%, -50%) scale(1);\n}\n\n/* While an extension owns the pointer the bar must not also look like it is\n scrubbing: the tooltip is suppressed inline by the control, and the handle\n stops responding to hover growth.\n\n This has to come after the :hover and --dragging rules AND match their\n specificity, which is why the hover case is spelled out rather than left to\n the bare class: an extension drag is a pointer drag, so the pointer is over\n the wrapper the whole time and the hover rule is always the competing one. */\n.sp-progress-wrapper--ext-dragging .sp-progress__handle,\n.sp-progress-wrapper--ext-dragging:hover .sp-progress__handle {\n transform: translate(-50%, -50%);\n}\n\n/* Thumbnail Preview */\n.sp-thumbnail-preview {\n position: absolute;\n bottom: calc(100% + 8px);\n transform: translateX(-50%);\n pointer-events: none;\n display: none;\n z-index: 21;\n border-radius: 4px;\n overflow: hidden;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n border: 2px solid rgba(255, 255, 255, 0.2);\n}\n\n.sp-thumbnail-preview__img {\n background-repeat: no-repeat;\n}\n\n/* Progress Tooltip */\n.sp-progress__tooltip {\n position: absolute;\n bottom: calc(100% + 8px);\n padding: 6px 10px;\n background: rgba(20, 20, 20, 0.95);\n color: #fff;\n font-size: 12px;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n border-radius: 4px;\n white-space: nowrap;\n transform: translateX(-50%);\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.15s ease;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);\n}\n\n.sp-progress__tooltip-chapter {\n display: block;\n max-width: 220px;\n overflow: hidden;\n color: rgba(255, 255, 255, 0.75);\n font-weight: 400;\n font-variant-numeric: normal;\n text-overflow: ellipsis;\n}\n\n@media (hover: hover) {\n .sp-progress-wrapper:hover .sp-progress__tooltip {\n opacity: 1;\n }\n}\n\n/* ============================================\n Control Buttons\n ============================================ */\n.sp-control {\n background: none;\n border: none;\n color: rgba(255, 255, 255, 0.9);\n cursor: pointer;\n padding: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 4px;\n transition: color 0.15s ease, transform 0.15s ease, background 0.15s ease;\n flex-shrink: 0;\n min-width: 44px;\n min-height: 44px;\n}\n\n@media (hover: hover) {\n .sp-control:hover {\n color: #fff;\n background: rgba(255, 255, 255, 0.1);\n }\n}\n\n.sp-control:active {\n transform: scale(0.92);\n}\n\n.sp-control:focus-visible {\n outline: 2px solid var(--sp-accent, #e50914);\n outline-offset: 2px;\n}\n\n.sp-control:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n transform: none;\n}\n\n.sp-control:disabled:hover {\n background: none;\n}\n\n.sp-control svg {\n width: 24px;\n height: 24px;\n fill: currentColor;\n display: block;\n}\n\n.sp-control--small svg {\n width: 20px;\n height: 20px;\n}\n\n/* ============================================\n Spacer\n ============================================ */\n.sp-spacer {\n flex: 1;\n min-width: 0;\n}\n\n/* ============================================\n Overflow Tray\n\n The wrapper is deliberately unpositioned: the strip is absolutely\n positioned against .sp-controls (the nearest positioned ancestor), so it\n spans the bar's width and sits directly above it instead of hanging off a\n 44px button.\n\n The strip wraps horizontally and keeps overflow visible. A vertical menu of\n 44px rows would be taller than a portrait phone player (211px at 375px wide,\n measured 2026-09-05) and a scrolling one would clip the popovers registered\n controls own.\n ============================================ */\n.sp-overflow {\n display: flex;\n align-items: center;\n flex-shrink: 0;\n}\n\n.sp-overflow-tray {\n position: absolute;\n bottom: 100%;\n left: 0;\n right: 0;\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 4px;\n padding: 8px 12px;\n background: rgba(20, 20, 20, 0.95);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n border-radius: 8px;\n box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);\n overflow: visible;\n opacity: 0;\n visibility: hidden;\n transform: translateY(8px);\n transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;\n z-index: 20;\n}\n\n.sp-overflow-tray--open {\n opacity: 1;\n visibility: visible;\n transform: translateY(0);\n}\n\n/* Beats a control's own inline style.display = '' on its next update(), so a\n control the fit took off screen stays off screen until the fit says\n otherwise. */\n.sp-control--collapsed {\n display: none !important;\n}\n\n/* ============================================\n Time Display\n ============================================ */\n.sp-time {\n font-size: 13px;\n font-variant-numeric: tabular-nums;\n color: rgba(255, 255, 255, 0.9);\n white-space: nowrap;\n padding: 0 4px;\n letter-spacing: 0.02em;\n}\n\n/* Live with DVR: reserve room for \"-0:00\" so the offset appearing on a\n scrub back, and blanking again at the edge, does not shift the bar. */\n.sp-time--live {\n min-width: 5ch;\n}\n\n/* ============================================\n Volume Control\n ============================================ */\n.sp-volume {\n display: flex;\n align-items: center;\n position: relative;\n}\n\n.sp-volume__slider-wrap {\n width: 0;\n overflow: hidden;\n transition: width 0.2s ease;\n}\n\n/* Both widths come from --sp-volume-slider-width on .sp-controls, which is also\n what the fit reserves for this control. focus-within is deliberately not\n gated on hover: a tap on the mute button focuses it, which is how the slider\n opens on a phone. */\n@media (hover: hover) {\n .sp-volume:hover .sp-volume__slider-wrap {\n width: var(--sp-volume-slider-width);\n }\n}\n\n.sp-volume:focus-within .sp-volume__slider-wrap {\n width: var(--sp-volume-slider-width);\n}\n\n.sp-volume__slider {\n width: 64px;\n height: 3px;\n background: rgba(255, 255, 255, 0.3);\n border-radius: 1.5px;\n cursor: pointer;\n position: relative;\n margin: 0 8px 0 4px;\n}\n\n.sp-volume__level {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n background: #fff;\n border-radius: inherit;\n transition: width 0.1s ease;\n}\n\n/* ============================================\n Live Indicator\n ============================================ */\n/* Accent text uses --sp-accent-text, not --sp-accent: the brand red as TEXT\n is 4.38:1 on black and 3.84:1 on the menus' own rgba(20,20,20,.95), under\n the 4.5:1 WCAG AA wants for 11-13px labels. The lighter default clears it at\n 6.4:1 and 5.7:1. --sp-accent keeps the brand red for progress fills, big\n play and focus rings, which are non-text and need only 3:1. A host that\n themes --sp-accent still colours this text - the chain reads its token\n first - so setting --sp-accent-text is only needed when that colour is too\n dark to read. */\n.sp-live {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--sp-accent-text, var(--sp-accent, #ff4d57));\n /* Opaque, and no lighter than #202020, because this is the one place accent\n TEXT sits on the control bar rather than on a menu. The bar is a gradient\n over the picture - rgba(0, 0, 0, 0.8) at its darkest point, so about #333\n over a white frame - and --sp-accent-text is only ever derived against the\n menus (see contrast.ts). A translucent chip would let the frame through\n and drop a toned accent below 4.5:1; this one pins the surface at exactly\n the colour the tone was measured on. */\n background: #202020;\n cursor: pointer;\n padding: 6px 10px;\n border-radius: 4px;\n transition: background 0.15s ease, opacity 0.15s ease;\n}\n\n@media (hover: hover) {\n /* Darkens rather than lightens: the usual rgba(255, 255, 255, 0.1) would put\n the label on a surface lighter than the one its colour was toned for. */\n .sp-live:hover {\n background: #141414;\n }\n}\n\n.sp-live__dot {\n width: 8px;\n height: 8px;\n background: currentColor;\n border-radius: 50%;\n animation: sp-pulse 2s ease-in-out infinite;\n}\n\n.sp-live--behind {\n opacity: 0.6;\n}\n\n.sp-live--behind .sp-live__dot {\n animation: none;\n}\n\n.sp-live--behind span {\n text-decoration: underline;\n text-underline-offset: 2px;\n}\n\n/* Progress bar live mode: accent color for filled bar */\n.sp-progress--live .sp-progress__filled {\n background: var(--sp-accent, #e50914);\n}\n\n@keyframes sp-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.4; }\n}\n\n/* ============================================\n Quality / Settings Menu\n ============================================ */\n.sp-quality {\n position: relative;\n}\n\n.sp-quality__btn {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n\n.sp-quality__label {\n font-size: 12px;\n font-weight: 500;\n opacity: 0.9;\n}\n\n.sp-quality-menu {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 0;\n /* Bounded to the player, see .sp-settings-panel. border-box because the\n bound is a content-box height by default and this menu adds 8px of padding\n top and bottom: at the 139px bound a 211px player gives, it rendered 155px\n and the host clipped the last 16px of it. */\n box-sizing: border-box;\n max-height: var(--sp-menu-max-height, none);\n overflow-y: auto;\n -webkit-overflow-scrolling: touch;\n background: rgba(20, 20, 20, 0.95);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n border-radius: 8px;\n padding: 8px 0;\n min-width: 150px;\n box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);\n opacity: 0;\n visibility: hidden;\n transform: translateY(8px);\n transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;\n z-index: 20;\n}\n\n.sp-quality-menu--open {\n opacity: 1;\n visibility: visible;\n transform: translateY(0);\n}\n\n.sp-quality-menu__item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 16px;\n font-size: 13px;\n color: rgba(255, 255, 255, 0.8);\n cursor: pointer;\n transition: background 0.1s ease, color 0.1s ease;\n}\n\n.sp-quality-menu__item:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #fff;\n}\n\n.sp-quality-menu__item--active {\n color: var(--sp-accent-text, var(--sp-accent, #ff4d57));\n}\n\n.sp-quality-menu__check {\n width: 16px;\n height: 16px;\n fill: currentColor;\n margin-left: 8px;\n opacity: 0;\n}\n\n.sp-quality-menu__item--active .sp-quality-menu__check {\n opacity: 1;\n}\n\n/* ============================================\n Settings Menu (Gear Icon)\n ============================================ */\n.sp-settings {\n position: relative;\n}\n\n.sp-settings__btn {\n display: flex;\n align-items: center;\n}\n\n.sp-settings-panel {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 0;\n /* Bounded to the room above the control bar, written by the UI plugin's\n ResizeObserver as max(120px, container height - the bar's measured height\n - 16px). The bar is measured rather than assumed because its\n padding-bottom carries the safe-area inset in fullscreen, which moves the\n anchor these menus hang from. The Speed sub-panel is 253px (a\n 37px header plus six 36px rows) against a 211px portrait phone player, so\n without this the host's overflow: hidden cuts off the Back header and the\n first three speeds and playback speed is unreachable (measured at 375x211\n on 2026-09-05). With the variable unset the panel behaves exactly as it\n did before.\n\n Not applied to .sp-overflow-tray: that one has to keep overflow visible so\n the popovers its adopted controls own are not clipped.\n\n border-box because max-height bounds the content box: .sp-settings-panel--main\n is this same element with 4px of padding top and bottom, so wherever the\n bound binds the main menu, it rendered 8px past it and the host clipped the\n difference. The --sub views set padding: 0 and were already exact, which is\n why the browser harness's speed-panel check could not see this. */\n box-sizing: border-box;\n max-height: var(--sp-menu-max-height, none);\n overflow-y: auto;\n -webkit-overflow-scrolling: touch;\n background: rgba(20, 20, 20, 0.95);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n border-radius: 8px;\n min-width: 200px;\n box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);\n opacity: 0;\n visibility: hidden;\n transform: translateY(8px);\n transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;\n z-index: 20;\n}\n\n.sp-settings-panel--open {\n opacity: 1;\n visibility: visible;\n transform: translateY(0);\n}\n\n/* Main menu rows */\n.sp-settings-panel--main {\n padding: 4px 0;\n}\n\n.sp-settings-panel__row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 16px;\n font-size: 13px;\n color: rgba(255, 255, 255, 0.9);\n cursor: pointer;\n transition: background 0.1s ease;\n}\n\n.sp-settings-panel__row:hover {\n background: rgba(255, 255, 255, 0.1);\n}\n\n.sp-settings-panel__label {\n font-weight: 500;\n}\n\n.sp-settings-panel__value {\n display: flex;\n align-items: center;\n gap: 4px;\n color: rgba(255, 255, 255, 0.6);\n font-size: 12px;\n}\n\n.sp-settings-panel__arrow {\n display: flex;\n align-items: center;\n transform: rotate(-90deg);\n}\n\n.sp-settings-panel__arrow svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n}\n\n/* Sub-menu panels */\n.sp-settings-panel--sub {\n padding: 0;\n}\n\n.sp-settings-panel__header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 16px;\n font-size: 13px;\n font-weight: 600;\n color: rgba(255, 255, 255, 0.9);\n cursor: pointer;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n transition: background 0.1s ease;\n}\n\n.sp-settings-panel__header:hover {\n background: rgba(255, 255, 255, 0.1);\n}\n\n.sp-settings-panel__back {\n display: flex;\n align-items: center;\n transform: rotate(-90deg);\n}\n\n.sp-settings-panel__back svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n}\n\n.sp-settings-panel__header-label {\n flex: 1;\n}\n\n.sp-settings-panel__item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 16px;\n font-size: 13px;\n color: rgba(255, 255, 255, 0.8);\n cursor: pointer;\n transition: background 0.1s ease, color 0.1s ease;\n}\n\n.sp-settings-panel__item:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #fff;\n}\n\n.sp-settings-panel__item--active {\n color: var(--sp-accent-text, var(--sp-accent, #ff4d57));\n}\n\n.sp-settings-panel__check {\n width: 16px;\n height: 16px;\n fill: currentColor;\n margin-left: 8px;\n opacity: 0;\n}\n\n.sp-settings-panel__check svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n}\n\n.sp-settings-panel__item--active .sp-settings-panel__check {\n opacity: 1;\n}\n\n/* ============================================\n Captions Button\n ============================================ */\n.sp-captions--active {\n color: var(--sp-accent-text, var(--sp-accent, #ff4d57));\n}\n\n/* ============================================\n Cast Button States\n ============================================ */\n.sp-cast--active {\n color: var(--sp-accent-text, var(--sp-accent, #ff4d57));\n}\n\n.sp-cast--unavailable {\n opacity: 0.4;\n}\n\n/* ============================================\n Big Play Button\n\n z-index 12 puts it above the gradient (5) and above the gestures plugin's\n tap surface (6), so a tap lands on the button and starts playback instead\n of being read as a tap-to-toggle-controls gesture - exactly how the control\n bar's play button (10) already behaves. It stays below the spinner (15) and\n the error overlay (25), both of which own the middle of the picture when\n they are up.\n\n Hidden with visibility, not opacity alone, so it takes no pointer events\n while it is away.\n ============================================ */\n.sp-big-play {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n z-index: 12;\n display: flex;\n align-items: center;\n justify-content: center;\n /* Comfortably past the 44px minimum touch target the control bar uses. */\n width: 72px;\n height: 72px;\n padding: 0;\n border: none;\n border-radius: 50%;\n background: var(--sp-accent, #e50914);\n color: #fff;\n cursor: pointer;\n opacity: 0;\n visibility: hidden;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);\n transition: opacity 0.2s ease, visibility 0.2s, transform 0.15s ease,\n background 0.15s ease;\n}\n\n.sp-big-play--visible {\n opacity: 1;\n visibility: visible;\n}\n\n.sp-big-play svg {\n width: 36px;\n height: 36px;\n fill: currentColor;\n /* Optical centring: the play triangle's mass sits left of the glyph box. */\n margin-left: 3px;\n}\n\n.sp-big-play:hover {\n transform: translate(-50%, -50%) scale(1.06);\n}\n\n.sp-big-play:active {\n transform: translate(-50%, -50%) scale(0.96);\n}\n\n.sp-big-play:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 3px;\n}\n\n/* ============================================\n Error Overlay\n ============================================ */\n.sp-error-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 25;\n opacity: 0;\n visibility: hidden;\n transition: opacity 0.25s ease, visibility 0.25s;\n}\n\n.sp-error-overlay--visible {\n opacity: 1;\n visibility: visible;\n}\n\n.sp-error-overlay__content {\n display: flex;\n flex-direction: column;\n align-items: center;\n text-align: center;\n padding: 24px;\n max-width: 360px;\n}\n\n.sp-error-overlay__icon {\n color: rgba(255, 255, 255, 0.7);\n margin-bottom: 16px;\n}\n\n.sp-error-overlay__icon svg {\n width: 48px;\n height: 48px;\n fill: currentColor;\n}\n\n/* Reconnecting: pulse the icon so the overlay reads as active work,\n not a dead-end error */\n.sp-error-overlay--reconnecting .sp-error-overlay__icon {\n animation: sp-reconnect-pulse 1.2s ease-in-out infinite;\n}\n\n@keyframes sp-reconnect-pulse {\n 0%, 100% { opacity: 0.4; }\n 50% { opacity: 1; }\n}\n\n.sp-error-overlay__message {\n color: rgba(255, 255, 255, 0.9);\n font-size: 15px;\n line-height: 1.5;\n margin: 0 0 24px;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.sp-error-overlay__actions {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n justify-content: center;\n}\n\n.sp-error-overlay__retry {\n background: var(--sp-accent, #e50914);\n color: #fff;\n border: none;\n padding: 12px 24px;\n font-size: 14px;\n font-weight: 600;\n border-radius: 6px;\n cursor: pointer;\n min-width: 120px;\n min-height: 44px;\n transition: background 0.15s ease, transform 0.15s ease;\n font-family: inherit;\n}\n\n.sp-error-overlay__retry:hover {\n filter: brightness(1.1);\n}\n\n.sp-error-overlay__retry:active {\n transform: scale(0.96);\n}\n\n.sp-error-overlay__retry:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 2px;\n}\n\n.sp-error-overlay__dismiss {\n background: none;\n color: rgba(255, 255, 255, 0.7);\n border: 1px solid rgba(255, 255, 255, 0.3);\n padding: 12px 24px;\n font-size: 14px;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n min-width: 100px;\n min-height: 44px;\n transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;\n font-family: inherit;\n}\n\n.sp-error-overlay__dismiss:hover {\n color: #fff;\n border-color: rgba(255, 255, 255, 0.5);\n}\n\n.sp-error-overlay__dismiss:active {\n transform: scale(0.96);\n}\n\n.sp-error-overlay__dismiss:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 2px;\n}\n\n/* ============================================\n Keyboard Help Dialog\n ============================================ */\n\n/* The dialog itself is a scrim over the whole player, rendered inside the\n player's container (never portalled to document.body) so container-scoped\n themes and the player's focus scoping keep applying to it. Above every\n other layer: menus sit at 20 and the error overlay at 25, and help must\n cover both. */\n.sp-kbd-help {\n position: absolute;\n inset: 0;\n z-index: 30;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n/* Bounded to the player's box so a short or narrow player still shows a\n usable dialog: the body below is the part that scrolls, and everything\n else stays pinned. */\n.sp-kbd-help__panel {\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: min(320px, calc(100% - 24px));\n max-height: calc(100% - 24px);\n background: rgba(20, 20, 20, 0.95);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n border-radius: 8px;\n box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);\n}\n\n.sp-kbd-help__header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 16px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.sp-kbd-help__title {\n font-size: 13px;\n font-weight: 600;\n color: rgba(255, 255, 255, 0.9);\n}\n\n.sp-kbd-help__close {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n width: 44px;\n height: 44px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: transparent;\n color: rgba(255, 255, 255, 0.8);\n cursor: pointer;\n}\n\n.sp-kbd-help__close:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #fff;\n}\n\n.sp-kbd-help__close:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 2px;\n}\n\n.sp-kbd-help__close svg {\n width: 18px;\n height: 18px;\n fill: currentColor;\n}\n\n/* The bounded scrollable body: the list scrolls inside the panel while the\n header and its close button stay reachable. */\n.sp-kbd-help__body {\n overflow-y: auto;\n -webkit-overflow-scrolling: touch;\n padding: 8px 16px 12px;\n}\n\n.sp-kbd-help__row {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 12px;\n padding: 6px 0;\n font-size: 13px;\n color: rgba(255, 255, 255, 0.8);\n}\n\n.sp-kbd-help__keys {\n white-space: nowrap;\n font-weight: 600;\n color: rgba(255, 255, 255, 0.9);\n}\n\n.sp-kbd-help__action {\n text-align: right;\n}\n\n.sp-kbd-help__notes {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.sp-kbd-help__note {\n margin: 0 0 4px;\n font-size: 12px;\n line-height: 1.4;\n color: rgba(255, 255, 255, 0.55);\n}\n\n.sp-kbd-help__note:last-child {\n margin-bottom: 0;\n}\n\n/* ============================================\n Buffering Indicator\n ============================================ */\n.sp-buffering {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n z-index: 15;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s ease;\n}\n\n.sp-buffering--visible {\n opacity: 1;\n}\n\n.sp-buffering svg {\n width: 48px;\n height: 48px;\n fill: rgba(255, 255, 255, 0.9);\n filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));\n}\n\n@keyframes sp-spin {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n}\n\n.sp-spin {\n animation: sp-spin 0.8s linear infinite;\n}\n\n/* ============================================\n Reduced Motion\n ============================================ */\n@media (prefers-reduced-motion: reduce) {\n .sp-gradient,\n .sp-controls,\n .sp-progress-wrapper,\n .sp-progress,\n .sp-progress__handle,\n .sp-progress__tooltip,\n .sp-control,\n .sp-volume__slider-wrap,\n .sp-quality-menu,\n .sp-overflow-tray,\n .sp-settings-panel,\n .sp-settings-panel__row,\n .sp-settings-panel__item,\n .sp-settings-panel__header,\n .sp-buffering,\n .sp-big-play,\n .sp-error-overlay,\n .sp-error-overlay__retry,\n .sp-error-overlay__dismiss {\n transition: none;\n }\n\n .sp-big-play:hover,\n .sp-big-play:active {\n transform: translate(-50%, -50%);\n }\n\n .sp-live__dot,\n .sp-spin {\n animation: none;\n }\n}\n\n/* ============================================\n CSS Custom Properties (Theming)\n ============================================\n\n Token defaults live at the use sites as var() fallbacks, not in a :root\n block. A :root declaration wrote --sp-accent, --sp-color, --sp-bg,\n --sp-control-height and --sp-icon-size into the HOST document, so mounting a\n player changed variables the page may own. A .sp-container declaration would\n be no better: the container rule wins for every descendant, so it would\n override a host that themes the player from its own :root. A var() fallback\n does neither - it applies only where nothing else defines the token.\n\n setTheme() writes the same names onto the player's container, which still\n wins over the fallbacks.\n\n --sp-accent-text is the accent applied to TEXT and active-state glyphs,\n split from --sp-accent because the same colour has to clear 4.5:1 there and\n only 3:1 as a fill. It falls back to --sp-accent, so a themed player needs\n it only when the host's accent is too dark to read against the controls;\n setTheme({ accentTextColor }) writes it, and accentTextTone() derives one.\n ============================================ */\n"; /** * Readable accent tones. * * The stylesheet splits the accent in two: `--sp-accent` colours fills, rings * and the big play button (non-text, 3:1), while `--sp-accent-text` colours * the LIVE label and the active rows in the settings and quality menus (text, * 4.5:1). `--sp-accent-text` falls back to `--sp-accent`, so a host that * themes the player gets its own accent on those labels - readable or not. * * This is how a host, the embed or a demo page derives the readable tone of an * accent without shipping the WCAG maths itself. Nothing here runs unless * someone asks: the stylesheet's own default already clears AA. * * It reads the colour forms a host is likely to pass as a brand accent - hex, * `rgb()`/`rgba()` and the CSS named colours - with no colour library behind * it. Anything else (`hsl()`, `var()`, a gradient, `currentColor`) is handed * back as given. */ /** * The nearest readable tone of an accent for text on the player's menus. * * Returns the colour itself, as `#rrggbb`, when it already clears 4.5:1 * there (so `#1f9e8b` stays `#1f9e8b` and `white` comes back as `#ffffff`); * otherwise mixes it toward white in 4% steps and returns the first tone that * does. The hue is kept, so a themed player still reads as its own colour - * the Scarlett red (4.38:1 on black, 3.84:1 on the menus) comes back as a * lighter red. A translucent accent is composited over the menus first, since * that is the colour the viewer sees; the result is always opaque. * * @param color - Accent as hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), * `rgb()`/`rgba()` (comma or space syntax, numbers or * percentages, optional alpha) or a CSS named colour; leading * and trailing space and letter case are ignored * @returns A `#rrggbb` tone clearing AA on the menus, or the input unchanged * when it cannot be parsed (`transparent` or any fully transparent * colour, `hsl()`, a gradient, a `var()`, hex digits without the * `#`, an empty string) * * @example * ```ts * uiPlugin({ theme: { accentColor: brand, accentTextColor: accentTextTone(brand) } }); * ``` */ declare function accentTextTone(color: string): string; /** * UI Controls Plugin for Scarlett Player * * Provides a complete, themeable video player control interface. * Modern design with progress bar above controls. * * @packageDocumentation */ /** * How long a layout slot may stay without a registered control before the * plugin warns about it. * * Plugin init order is not guaranteed, and the plugins that contribute * controls (playlist, chapters) register them from inside * `import('@scarlett-player/ui').then(...)`, which lands at least a microtask * after this plugin has built its bar. Warning inline at build time therefore * flagged every one of those controls on every mount, and again on each * rebuild that followed. The bar is built first and the layout judged after * this grace instead: the ui chunk is normally already loaded so the import * resolves at once, but a host that split it lazily may still be fetching it. * A typo in the layout still warns, just this much later. * * @internal Exported so the tests do not hard-code the number. */ declare const UNRESOLVED_SLOT_GRACE_MS = 1500; /** * Create a UI controls plugin instance. * * @example * ```ts * import { createPlayer } from '@scarlett-player/core'; * import { uiPlugin } from '@scarlett-player/ui'; * * const player = await createPlayer({ * container: '#player', * plugins: [ * uiPlugin({ * hideDelay: 3000, * theme: { accentColor: '#e50914' }, * }), * ], * }); * ``` * * @param config - Layout, theme and fit options * @returns The plugin, ready to be handed to `createPlayer()` * @throws Error when `controls` has `quality` without `settings` while * `responsive` is on and `quality` is not pinned, see {@link assertFitLayout} */ declare function uiPlugin(config?: UIPluginConfig): IUIPlugin; export { type BuiltinControlSlot, type Control, type ControlFactory, type ControlSlot, DEFAULT_PRIORITY, type FitExit, type FitItem, type FitPlan, type FitRank, type FitRule, type FitTemplate, type IUIPlugin, type LayoutConfig, type RegisterControlOptions, type ThemeConfig, type TimelineExtension, type TimelineExtensionFactory, type TimelineSurface, type UIPluginConfig, UNRESOLVED_SLOT_GRACE_MS, accentTextTone, assertFitLayout, uiPlugin as default, getControlFactory, hasTimelineExtension, icons, planFit, registerControl, registerTimelineExtension, resetControlRegistry, resolveFitItems, styles, uiPlugin, unregisterControl, unregisterControlsFor, unregisterTimelineExtension };