@scarlett-player/ui 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/index.cjs +282 -55
- package/dist/index.d.cts +160 -2
- package/dist/index.d.ts +160 -2
- package/dist/index.js +278 -54
- package/package.json +2 -2
package/dist/index.d.cts
CHANGED
|
@@ -390,6 +390,164 @@ declare function getControlFactory(id: string, owner?: HTMLElement): ControlFact
|
|
|
390
390
|
*/
|
|
391
391
|
declare function resetControlRegistry(): void;
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Timeline extension registry.
|
|
395
|
+
*
|
|
396
|
+
* The control registry lets a plugin contribute a *button*. This lets one
|
|
397
|
+
* contribute an editing layer over the **playback timeline** itself - the clip
|
|
398
|
+
* plugin's in/out handles are the first, and the reason it exists: a clip
|
|
399
|
+
* editor with its own miniature track cannot express a 30 second selection on
|
|
400
|
+
* a two hour source, and forces the viewer to pick points on a rail that is
|
|
401
|
+
* not the one they were just scrubbing.
|
|
402
|
+
*
|
|
403
|
+
* The contract is deliberately narrow, and the direction of the dependency is
|
|
404
|
+
* the point: `@scarlett-player/ui` knows nothing about clips, reads no
|
|
405
|
+
* clip-specific state, and gains no `IPluginAPI` surface. It offers a
|
|
406
|
+
* positioned layer over its rail plus four lifecycle calls, and an extension
|
|
407
|
+
* paints into it.
|
|
408
|
+
*
|
|
409
|
+
* Registrations are keyed by the player container in a `WeakMap`, so two
|
|
410
|
+
* players on one page never see each other's extension and a destroyed player
|
|
411
|
+
* leaves nothing to collect. There is **one** active editing extension per
|
|
412
|
+
* player: a second registration replaces the first, and the replaced one is
|
|
413
|
+
* destroyed. The disposer returned by `registerTimelineExtension` is
|
|
414
|
+
* identity-safe - it only removes the registration it made, so cleanup running
|
|
415
|
+
* after a replacement cannot unmount its successor.
|
|
416
|
+
*
|
|
417
|
+
* ## Ordering
|
|
418
|
+
*
|
|
419
|
+
* Plugin init order is not guaranteed, so both orders work. Register before
|
|
420
|
+
* the UI plugin builds its bar and the extension mounts as soon as the
|
|
421
|
+
* progress bar attaches; register afterwards and it mounts immediately.
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* ```ts
|
|
425
|
+
* const release = registerTimelineExtension(api.container, (surface) => {
|
|
426
|
+
* surface.element.appendChild(myHandles);
|
|
427
|
+
* return {
|
|
428
|
+
* update: () => reposition(surface.getRailRect()),
|
|
429
|
+
* onSeekStart: () => suspendPreview(),
|
|
430
|
+
* onSeekEnd: () => {},
|
|
431
|
+
* destroy: () => myHandles.remove(),
|
|
432
|
+
* };
|
|
433
|
+
* });
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
/**
|
|
437
|
+
* What the UI package offers an extension: a layer to paint into, the rail's
|
|
438
|
+
* measured geometry, and two leases.
|
|
439
|
+
*/
|
|
440
|
+
interface TimelineSurface {
|
|
441
|
+
/**
|
|
442
|
+
* The extension's own layer.
|
|
443
|
+
*
|
|
444
|
+
* A sibling of the `role="slider"` seek element - never inside it, so an
|
|
445
|
+
* extension's controls are not swallowed by the slider's accessibility
|
|
446
|
+
* semantics - positioned with exactly the horizontal geometry of the visible
|
|
447
|
+
* rail and centred on it vertically. It paints above the buffer, chapter and
|
|
448
|
+
* progress layers.
|
|
449
|
+
*
|
|
450
|
+
* The layer itself is `pointer-events: none`; give explicit hit targets
|
|
451
|
+
* inside it `pointer-events: auto`, and plain presses on the rail keep
|
|
452
|
+
* seeking as they always did.
|
|
453
|
+
*/
|
|
454
|
+
element: HTMLElement;
|
|
455
|
+
/**
|
|
456
|
+
* The visible rail's current box, in client coordinates.
|
|
457
|
+
*
|
|
458
|
+
* The same box the seek slider maps a press to, so an extension mapping
|
|
459
|
+
* `clientX` to a time agrees with the player's own seeking to the pixel.
|
|
460
|
+
*
|
|
461
|
+
* @returns The rail's bounding rectangle
|
|
462
|
+
*/
|
|
463
|
+
getRailRect(): DOMRect;
|
|
464
|
+
/**
|
|
465
|
+
* Enter or leave editing mode.
|
|
466
|
+
*
|
|
467
|
+
* While active the control bar and progress bar are held visible (auto-hide
|
|
468
|
+
* cannot take an editor off screen mid-gesture) and the timeline reserves
|
|
469
|
+
* the vertical room an editor needs around the rail. Leaving restores the
|
|
470
|
+
* ordinary geometry and restarts the normal hide delay.
|
|
471
|
+
*
|
|
472
|
+
* Independent of any control's `isMenuOpen()`: an extension opened from a
|
|
473
|
+
* host's own button, in a layout with no matching control at all, still
|
|
474
|
+
* holds the bar.
|
|
475
|
+
*
|
|
476
|
+
* @param active - Whether the extension is editing
|
|
477
|
+
*/
|
|
478
|
+
setEditing(active: boolean): void;
|
|
479
|
+
/**
|
|
480
|
+
* Take or release the pointer lease.
|
|
481
|
+
*
|
|
482
|
+
* While active the timeline's own seeking is suppressed, so dragging an
|
|
483
|
+
* extension's handle past the rail cannot also scrub the video, and the
|
|
484
|
+
* hover tooltip stays out of the way.
|
|
485
|
+
*
|
|
486
|
+
* @param active - Whether the extension owns the current pointer
|
|
487
|
+
*/
|
|
488
|
+
setDragging(active: boolean): void;
|
|
489
|
+
}
|
|
490
|
+
/** What an extension gives back to the UI package. */
|
|
491
|
+
interface TimelineExtension {
|
|
492
|
+
/**
|
|
493
|
+
* Re-read geometry and repaint. Called with the progress bar's own update,
|
|
494
|
+
* so an extension never has to poll or observe the rail itself.
|
|
495
|
+
*/
|
|
496
|
+
update(): void;
|
|
497
|
+
/**
|
|
498
|
+
* An ordinary seek began on the timeline (mouse, touch or keyboard) - not
|
|
499
|
+
* one the extension owns.
|
|
500
|
+
*/
|
|
501
|
+
onSeekStart(): void;
|
|
502
|
+
/** The ordinary seek ended, was released outside the rail, or was cancelled. */
|
|
503
|
+
onSeekEnd(): void;
|
|
504
|
+
/** Unmount: remove everything added to `surface.element` and drop listeners. */
|
|
505
|
+
destroy(): void;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Builds an extension for one player's timeline.
|
|
509
|
+
*
|
|
510
|
+
* @param surface - The layer, geometry and leases (see {@link TimelineSurface})
|
|
511
|
+
* @returns The mounted extension
|
|
512
|
+
*/
|
|
513
|
+
type TimelineExtensionFactory = (surface: TimelineSurface) => TimelineExtension;
|
|
514
|
+
/**
|
|
515
|
+
* Register the editing extension for one player's timeline.
|
|
516
|
+
*
|
|
517
|
+
* One extension is active per player. Registering again replaces the previous
|
|
518
|
+
* one, which is destroyed; the returned disposer only ever removes the
|
|
519
|
+
* registration *it* made, so a late cleanup cannot unmount a replacement.
|
|
520
|
+
*
|
|
521
|
+
* Works before or after the UI plugin has initialised: if the progress bar is
|
|
522
|
+
* already mounted the factory runs immediately, otherwise it runs when the bar
|
|
523
|
+
* attaches.
|
|
524
|
+
*
|
|
525
|
+
* @param owner - The player container, i.e. `api.container`
|
|
526
|
+
* @param factory - Builds the extension for that player's timeline surface
|
|
527
|
+
* @returns Disposer that unmounts this registration, immediately and once
|
|
528
|
+
*/
|
|
529
|
+
declare function registerTimelineExtension(owner: HTMLElement, factory: TimelineExtensionFactory): () => void;
|
|
530
|
+
/**
|
|
531
|
+
* Drop a player's registration whatever made it.
|
|
532
|
+
*
|
|
533
|
+
* For a host tearing a player down wholesale; ordinary plugins use the
|
|
534
|
+
* disposer they were handed.
|
|
535
|
+
*
|
|
536
|
+
* @param owner - The player container
|
|
537
|
+
* @returns Whether a registration was removed
|
|
538
|
+
*/
|
|
539
|
+
declare function unregisterTimelineExtension(owner: HTMLElement): boolean;
|
|
540
|
+
/**
|
|
541
|
+
* Whether a player has an editing extension registered.
|
|
542
|
+
*
|
|
543
|
+
* Lets a plugin feature-detect its own registration without keeping a second
|
|
544
|
+
* copy of the bookkeeping.
|
|
545
|
+
*
|
|
546
|
+
* @param owner - The player container
|
|
547
|
+
* @returns True when a factory is registered for that player
|
|
548
|
+
*/
|
|
549
|
+
declare function hasTimelineExtension(owner: HTMLElement): boolean;
|
|
550
|
+
|
|
393
551
|
/**
|
|
394
552
|
* SVG Icons for UI Controls
|
|
395
553
|
*
|
|
@@ -436,7 +594,7 @@ declare const icons: {
|
|
|
436
594
|
* Modern, minimal design inspired by Mux Player and Vidstack.
|
|
437
595
|
* Uses CSS custom properties for theming.
|
|
438
596
|
*/
|
|
439
|
-
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.sp-container:focus {\n outline: none;\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.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/* 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/* ============================================\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.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, #e50914);\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 .sp-live:hover {\n background: rgba(255, 255, 255, 0.1);\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, #e50914);\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, #e50914);\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, #e50914);\n}\n\n/* ============================================\n Cast Button States\n ============================================ */\n.sp-cast--active {\n color: var(--sp-accent, #e50914);\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 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";
|
|
597
|
+
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.sp-container:focus {\n outline: none;\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/* ============================================\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.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, #e50914);\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 .sp-live:hover {\n background: rgba(255, 255, 255, 0.1);\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, #e50914);\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, #e50914);\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, #e50914);\n}\n\n/* ============================================\n Cast Button States\n ============================================ */\n.sp-cast--active {\n color: var(--sp-accent, #e50914);\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 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";
|
|
440
598
|
|
|
441
599
|
/**
|
|
442
600
|
* UI Controls Plugin for Scarlett Player
|
|
@@ -473,4 +631,4 @@ declare const styles = "\n/* ============================================\n Co
|
|
|
473
631
|
*/
|
|
474
632
|
declare function uiPlugin(config?: UIPluginConfig): IUIPlugin;
|
|
475
633
|
|
|
476
|
-
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 UIPluginConfig, assertFitLayout, uiPlugin as default, getControlFactory, icons, planFit, registerControl, resetControlRegistry, resolveFitItems, styles, uiPlugin, unregisterControl, unregisterControlsFor };
|
|
634
|
+
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, assertFitLayout, uiPlugin as default, getControlFactory, hasTimelineExtension, icons, planFit, registerControl, registerTimelineExtension, resetControlRegistry, resolveFitItems, styles, uiPlugin, unregisterControl, unregisterControlsFor, unregisterTimelineExtension };
|
package/dist/index.d.ts
CHANGED
|
@@ -390,6 +390,164 @@ declare function getControlFactory(id: string, owner?: HTMLElement): ControlFact
|
|
|
390
390
|
*/
|
|
391
391
|
declare function resetControlRegistry(): void;
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Timeline extension registry.
|
|
395
|
+
*
|
|
396
|
+
* The control registry lets a plugin contribute a *button*. This lets one
|
|
397
|
+
* contribute an editing layer over the **playback timeline** itself - the clip
|
|
398
|
+
* plugin's in/out handles are the first, and the reason it exists: a clip
|
|
399
|
+
* editor with its own miniature track cannot express a 30 second selection on
|
|
400
|
+
* a two hour source, and forces the viewer to pick points on a rail that is
|
|
401
|
+
* not the one they were just scrubbing.
|
|
402
|
+
*
|
|
403
|
+
* The contract is deliberately narrow, and the direction of the dependency is
|
|
404
|
+
* the point: `@scarlett-player/ui` knows nothing about clips, reads no
|
|
405
|
+
* clip-specific state, and gains no `IPluginAPI` surface. It offers a
|
|
406
|
+
* positioned layer over its rail plus four lifecycle calls, and an extension
|
|
407
|
+
* paints into it.
|
|
408
|
+
*
|
|
409
|
+
* Registrations are keyed by the player container in a `WeakMap`, so two
|
|
410
|
+
* players on one page never see each other's extension and a destroyed player
|
|
411
|
+
* leaves nothing to collect. There is **one** active editing extension per
|
|
412
|
+
* player: a second registration replaces the first, and the replaced one is
|
|
413
|
+
* destroyed. The disposer returned by `registerTimelineExtension` is
|
|
414
|
+
* identity-safe - it only removes the registration it made, so cleanup running
|
|
415
|
+
* after a replacement cannot unmount its successor.
|
|
416
|
+
*
|
|
417
|
+
* ## Ordering
|
|
418
|
+
*
|
|
419
|
+
* Plugin init order is not guaranteed, so both orders work. Register before
|
|
420
|
+
* the UI plugin builds its bar and the extension mounts as soon as the
|
|
421
|
+
* progress bar attaches; register afterwards and it mounts immediately.
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* ```ts
|
|
425
|
+
* const release = registerTimelineExtension(api.container, (surface) => {
|
|
426
|
+
* surface.element.appendChild(myHandles);
|
|
427
|
+
* return {
|
|
428
|
+
* update: () => reposition(surface.getRailRect()),
|
|
429
|
+
* onSeekStart: () => suspendPreview(),
|
|
430
|
+
* onSeekEnd: () => {},
|
|
431
|
+
* destroy: () => myHandles.remove(),
|
|
432
|
+
* };
|
|
433
|
+
* });
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
/**
|
|
437
|
+
* What the UI package offers an extension: a layer to paint into, the rail's
|
|
438
|
+
* measured geometry, and two leases.
|
|
439
|
+
*/
|
|
440
|
+
interface TimelineSurface {
|
|
441
|
+
/**
|
|
442
|
+
* The extension's own layer.
|
|
443
|
+
*
|
|
444
|
+
* A sibling of the `role="slider"` seek element - never inside it, so an
|
|
445
|
+
* extension's controls are not swallowed by the slider's accessibility
|
|
446
|
+
* semantics - positioned with exactly the horizontal geometry of the visible
|
|
447
|
+
* rail and centred on it vertically. It paints above the buffer, chapter and
|
|
448
|
+
* progress layers.
|
|
449
|
+
*
|
|
450
|
+
* The layer itself is `pointer-events: none`; give explicit hit targets
|
|
451
|
+
* inside it `pointer-events: auto`, and plain presses on the rail keep
|
|
452
|
+
* seeking as they always did.
|
|
453
|
+
*/
|
|
454
|
+
element: HTMLElement;
|
|
455
|
+
/**
|
|
456
|
+
* The visible rail's current box, in client coordinates.
|
|
457
|
+
*
|
|
458
|
+
* The same box the seek slider maps a press to, so an extension mapping
|
|
459
|
+
* `clientX` to a time agrees with the player's own seeking to the pixel.
|
|
460
|
+
*
|
|
461
|
+
* @returns The rail's bounding rectangle
|
|
462
|
+
*/
|
|
463
|
+
getRailRect(): DOMRect;
|
|
464
|
+
/**
|
|
465
|
+
* Enter or leave editing mode.
|
|
466
|
+
*
|
|
467
|
+
* While active the control bar and progress bar are held visible (auto-hide
|
|
468
|
+
* cannot take an editor off screen mid-gesture) and the timeline reserves
|
|
469
|
+
* the vertical room an editor needs around the rail. Leaving restores the
|
|
470
|
+
* ordinary geometry and restarts the normal hide delay.
|
|
471
|
+
*
|
|
472
|
+
* Independent of any control's `isMenuOpen()`: an extension opened from a
|
|
473
|
+
* host's own button, in a layout with no matching control at all, still
|
|
474
|
+
* holds the bar.
|
|
475
|
+
*
|
|
476
|
+
* @param active - Whether the extension is editing
|
|
477
|
+
*/
|
|
478
|
+
setEditing(active: boolean): void;
|
|
479
|
+
/**
|
|
480
|
+
* Take or release the pointer lease.
|
|
481
|
+
*
|
|
482
|
+
* While active the timeline's own seeking is suppressed, so dragging an
|
|
483
|
+
* extension's handle past the rail cannot also scrub the video, and the
|
|
484
|
+
* hover tooltip stays out of the way.
|
|
485
|
+
*
|
|
486
|
+
* @param active - Whether the extension owns the current pointer
|
|
487
|
+
*/
|
|
488
|
+
setDragging(active: boolean): void;
|
|
489
|
+
}
|
|
490
|
+
/** What an extension gives back to the UI package. */
|
|
491
|
+
interface TimelineExtension {
|
|
492
|
+
/**
|
|
493
|
+
* Re-read geometry and repaint. Called with the progress bar's own update,
|
|
494
|
+
* so an extension never has to poll or observe the rail itself.
|
|
495
|
+
*/
|
|
496
|
+
update(): void;
|
|
497
|
+
/**
|
|
498
|
+
* An ordinary seek began on the timeline (mouse, touch or keyboard) - not
|
|
499
|
+
* one the extension owns.
|
|
500
|
+
*/
|
|
501
|
+
onSeekStart(): void;
|
|
502
|
+
/** The ordinary seek ended, was released outside the rail, or was cancelled. */
|
|
503
|
+
onSeekEnd(): void;
|
|
504
|
+
/** Unmount: remove everything added to `surface.element` and drop listeners. */
|
|
505
|
+
destroy(): void;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Builds an extension for one player's timeline.
|
|
509
|
+
*
|
|
510
|
+
* @param surface - The layer, geometry and leases (see {@link TimelineSurface})
|
|
511
|
+
* @returns The mounted extension
|
|
512
|
+
*/
|
|
513
|
+
type TimelineExtensionFactory = (surface: TimelineSurface) => TimelineExtension;
|
|
514
|
+
/**
|
|
515
|
+
* Register the editing extension for one player's timeline.
|
|
516
|
+
*
|
|
517
|
+
* One extension is active per player. Registering again replaces the previous
|
|
518
|
+
* one, which is destroyed; the returned disposer only ever removes the
|
|
519
|
+
* registration *it* made, so a late cleanup cannot unmount a replacement.
|
|
520
|
+
*
|
|
521
|
+
* Works before or after the UI plugin has initialised: if the progress bar is
|
|
522
|
+
* already mounted the factory runs immediately, otherwise it runs when the bar
|
|
523
|
+
* attaches.
|
|
524
|
+
*
|
|
525
|
+
* @param owner - The player container, i.e. `api.container`
|
|
526
|
+
* @param factory - Builds the extension for that player's timeline surface
|
|
527
|
+
* @returns Disposer that unmounts this registration, immediately and once
|
|
528
|
+
*/
|
|
529
|
+
declare function registerTimelineExtension(owner: HTMLElement, factory: TimelineExtensionFactory): () => void;
|
|
530
|
+
/**
|
|
531
|
+
* Drop a player's registration whatever made it.
|
|
532
|
+
*
|
|
533
|
+
* For a host tearing a player down wholesale; ordinary plugins use the
|
|
534
|
+
* disposer they were handed.
|
|
535
|
+
*
|
|
536
|
+
* @param owner - The player container
|
|
537
|
+
* @returns Whether a registration was removed
|
|
538
|
+
*/
|
|
539
|
+
declare function unregisterTimelineExtension(owner: HTMLElement): boolean;
|
|
540
|
+
/**
|
|
541
|
+
* Whether a player has an editing extension registered.
|
|
542
|
+
*
|
|
543
|
+
* Lets a plugin feature-detect its own registration without keeping a second
|
|
544
|
+
* copy of the bookkeeping.
|
|
545
|
+
*
|
|
546
|
+
* @param owner - The player container
|
|
547
|
+
* @returns True when a factory is registered for that player
|
|
548
|
+
*/
|
|
549
|
+
declare function hasTimelineExtension(owner: HTMLElement): boolean;
|
|
550
|
+
|
|
393
551
|
/**
|
|
394
552
|
* SVG Icons for UI Controls
|
|
395
553
|
*
|
|
@@ -436,7 +594,7 @@ declare const icons: {
|
|
|
436
594
|
* Modern, minimal design inspired by Mux Player and Vidstack.
|
|
437
595
|
* Uses CSS custom properties for theming.
|
|
438
596
|
*/
|
|
439
|
-
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.sp-container:focus {\n outline: none;\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.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/* 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/* ============================================\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.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, #e50914);\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 .sp-live:hover {\n background: rgba(255, 255, 255, 0.1);\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, #e50914);\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, #e50914);\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, #e50914);\n}\n\n/* ============================================\n Cast Button States\n ============================================ */\n.sp-cast--active {\n color: var(--sp-accent, #e50914);\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 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";
|
|
597
|
+
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.sp-container:focus {\n outline: none;\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/* ============================================\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.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, #e50914);\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 .sp-live:hover {\n background: rgba(255, 255, 255, 0.1);\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, #e50914);\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, #e50914);\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, #e50914);\n}\n\n/* ============================================\n Cast Button States\n ============================================ */\n.sp-cast--active {\n color: var(--sp-accent, #e50914);\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 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";
|
|
440
598
|
|
|
441
599
|
/**
|
|
442
600
|
* UI Controls Plugin for Scarlett Player
|
|
@@ -473,4 +631,4 @@ declare const styles = "\n/* ============================================\n Co
|
|
|
473
631
|
*/
|
|
474
632
|
declare function uiPlugin(config?: UIPluginConfig): IUIPlugin;
|
|
475
633
|
|
|
476
|
-
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 UIPluginConfig, assertFitLayout, uiPlugin as default, getControlFactory, icons, planFit, registerControl, resetControlRegistry, resolveFitItems, styles, uiPlugin, unregisterControl, unregisterControlsFor };
|
|
634
|
+
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, assertFitLayout, uiPlugin as default, getControlFactory, hasTimelineExtension, icons, planFit, registerControl, registerTimelineExtension, resetControlRegistry, resolveFitItems, styles, uiPlugin, unregisterControl, unregisterControlsFor, unregisterTimelineExtension };
|