@scarlett-player/embed 1.6.0 → 1.8.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 CHANGED
@@ -35,6 +35,34 @@ npm install @scarlett-player/embed
35
35
  pnpm add @scarlett-player/embed
36
36
  ```
37
37
 
38
+ ## Supported sources
39
+
40
+ Every build registers two providers and picks the first one that accepts the
41
+ source URL, by file extension:
42
+
43
+ | Source | Provider | Extensions |
44
+ |--------|----------|------------|
45
+ | HLS | hls.js, or the browser's own HLS in Safari | `.m3u8` |
46
+ | Progressive video | native `<video>` element | `.mp4`, `.m4v`, `.webm`, `.mov`, `.mkv`, `.ogv` |
47
+ | Progressive audio | native media element | `.mp3`, `.m4a`, `.aac`, `.wav`, `.ogg`, `.opus`, `.flac`, `.weba` |
48
+
49
+ HLS always wins for `.m3u8`. The native provider also asks the browser whether
50
+ it can play the format before claiming a source, so an `.mkv` in a browser
51
+ without Matroska support is declined rather than played into a black frame.
52
+
53
+ Before 1.7.1 no embed build registered the native provider at all, and any
54
+ non-HLS source failed with `PROVIDER_NOT_FOUND`. If you are on an older build,
55
+ upgrade rather than working around it.
56
+
57
+ ### Audio build: hls.js/light
58
+
59
+ `embed.audio.js` / `embed.audio.umd.cjs` build on `hls.js/light`, which drops
60
+ in-stream subtitle parsing, ID3 tag parsing and EME/DRM in exchange for a much
61
+ smaller bundle. The audio build ships no captions plugin and audio embeds are
62
+ not DRM sources, so **ID3 timed metadata is the one capability an audio embed
63
+ gives up**. If you need ID3 (in-stream "now playing" updates on a live audio
64
+ stream, typically), use the Full build.
65
+
38
66
  ## Usage
39
67
 
40
68
  ### 1. Declarative (Data Attributes)
@@ -54,7 +82,7 @@ The simplest way to embed a player. Just add the script and use data attributes:
54
82
  data-src="https://example.com/stream.m3u8"
55
83
  ></div>
56
84
 
57
- <!-- Audio player -->
85
+ <!-- Audio player (HLS) -->
58
86
  <div
59
87
  data-scarlett-player
60
88
  data-src="https://example.com/podcast.m3u8"
@@ -63,6 +91,16 @@ The simplest way to embed a player. Just add the script and use data attributes:
63
91
  data-artist="My Podcast"
64
92
  ></div>
65
93
 
94
+ <!-- Audio player (progressive MP3 file) -->
95
+ <div
96
+ data-scarlett-player
97
+ data-src="https://example.com/episode-42.mp3"
98
+ data-type="audio"
99
+ data-title="Episode 42: Deep Dive"
100
+ data-artist="My Podcast"
101
+ data-artwork="https://example.com/podcast-cover.jpg"
102
+ ></div>
103
+
66
104
  <!-- Compact audio player -->
67
105
  <div
68
106
  data-scarlett-player
@@ -90,12 +128,14 @@ The simplest way to embed a player. Just add the script and use data attributes:
90
128
 
91
129
  | Attribute | Type | Default | Description |
92
130
  |-----------|------|---------|-------------|
93
- | `data-src` | string | **required** | Media source URL (HLS .m3u8) |
131
+ | `data-src` | string | **required** | Media source URL. HLS (`.m3u8`) or a progressive file, see [Supported sources](#supported-sources) |
94
132
  | `data-type` | string | `video` | Player type: `video`, `audio`, or `audio-mini` |
95
133
  | `data-autoplay` | boolean | `false` | Auto-play on load |
96
134
  | `data-muted` | boolean | `false` | Start muted |
97
135
  | `data-poster` | string | - | Poster/artwork image URL |
98
136
  | `data-controls` | boolean | `true` | Show/hide UI controls |
137
+ | `data-big-play-button` | boolean | `true` | Centred play button over the poster (video only). Set `false` when your page draws its own play affordance |
138
+ | `data-gestures` | boolean | `true` | Touch gestures on the picture (video only): double-tap the sides to seek, tap to toggle the controls. Touch only, by input type, so a mouse never triggers them. Set `false` if your page owns those gestures |
99
139
  | `data-brand-color` | string | - | Accent color (e.g., `#e50914`) |
100
140
  | `data-primary-color` | string | - | Primary UI color |
101
141
  | `data-background-color` | string | - | Control bar background |
@@ -107,7 +147,6 @@ The simplest way to embed a player. Just add the script and use data attributes:
107
147
  | `data-loop` | boolean | `false` | Loop playback |
108
148
  | `data-playback-rate` | number | `1.0` | Playback speed |
109
149
  | `data-start-time` | number | `0` | Start position (seconds) |
110
- | `data-share-url` | string | - | Canonical page URL for the share plugin. Set this on iframe embeds, or sharing links to the player page instead of yours |
111
150
  | `data-class` | string | - | Custom CSS class(es) |
112
151
 
113
152
  #### Audio-specific Attributes
@@ -137,6 +176,9 @@ For dynamic player creation:
137
176
  muted: true,
138
177
  brandColor: '#e50914',
139
178
  aspectRatio: '16:9',
179
+ // Video only, and optional: omit it to keep the centred play button.
180
+ // false hides it, for a page that draws its own play affordance.
181
+ bigPlayButton: false,
140
182
  });
141
183
 
142
184
  // Create audio player
@@ -149,6 +191,16 @@ For dynamic player creation:
149
191
  artwork: 'https://example.com/artwork.jpg',
150
192
  });
151
193
 
194
+ // Create audio player from a progressive MP3 file
195
+ const mp3Player = await ScarlettPlayer.create({
196
+ container: '#mp3-player',
197
+ src: 'https://example.com/episode-42.mp3',
198
+ type: 'audio',
199
+ title: 'Episode 42: Deep Dive',
200
+ artist: 'Tech Podcast',
201
+ artwork: 'https://example.com/podcast-cover.jpg',
202
+ });
203
+
152
204
  // Create compact audio player
153
205
  const miniPlayer = await ScarlettPlayer.create({
154
206
  container: '#mini-player',
@@ -230,23 +282,28 @@ All data attributes work as URL parameters (use kebab-case):
230
282
  - `autoplay`, `muted`, `loop`
231
283
  - `poster`
232
284
  - `brand-color`, `primary-color`, `background-color`
285
+ - `big-play-button` - omit to keep the centred play button, `false` or `0` to hide it
233
286
 
234
287
  ## Player Types
235
288
 
236
289
  ### Video Player (default)
237
290
 
238
291
  Standard video player with full controls, fullscreen, picture-in-picture support.
292
+ Takes an HLS manifest or a progressive video file.
239
293
 
240
294
  ```html
241
295
  <div data-scarlett-player data-src="video.m3u8"></div>
296
+ <div data-scarlett-player data-src="bout-13.mp4"></div>
242
297
  ```
243
298
 
244
299
  ### Audio Player
245
300
 
246
301
  Full-sized audio player with waveform, track info, and media session integration.
302
+ Takes an HLS manifest or a progressive audio file.
247
303
 
248
304
  ```html
249
305
  <div data-scarlett-player data-src="audio.m3u8" data-type="audio"></div>
306
+ <div data-scarlett-player data-src="episode-42.mp3" data-type="audio"></div>
250
307
  ```
251
308
 
252
309
  ### Compact Audio Player
@@ -287,13 +344,13 @@ All builds are available at `https://assets.thestreamplatform.com/scarlett-playe
287
344
  |-------|-------|----------|
288
345
  | **Full** | `embed.js` / `embed.umd.cjs` | Video + Audio + Analytics + Playlist + Media Session |
289
346
  | **Video** | `embed.video.js` / `embed.video.umd.cjs` | Video player only (lightweight) |
290
- | **Audio** | `embed.audio.js` / `embed.audio.umd.cjs` | Audio + Playlist + Media Session |
347
+ | **Audio** | `embed.audio.js` / `embed.audio.umd.cjs` | Audio + Playlist + Media Session, on `hls.js/light` (no ID3) |
291
348
 
292
349
  **Which build should I use?**
293
350
 
294
351
  - Use **Full** (`embed.umd.cjs`) if you need both video and audio, or want analytics
295
352
  - Use **Video** (`embed.video.umd.cjs`) for video-only sites to reduce bundle size
296
- - Use **Audio** (`embed.audio.umd.cjs`) for audio-only sites (podcasts, music streaming)
353
+ - Use **Audio** (`embed.audio.umd.cjs`) for audio-only sites (podcasts, music streaming). It is built on `hls.js/light`, so it cannot read ID3 timed metadata: see [Audio build: hls.js/light](#audio-build-hlsjslight)
297
354
 
298
355
  **Note:** Using a build without support for a player type will throw an error. For example, using the Video build and setting `data-type="audio"` will fail with a helpful error message.
299
356
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Unified embed creator factory
3
+ *
4
+ * This module creates the embed API with configurable plugin availability.
5
+ * Each build (full, video, audio) uses this with different plugin sets.
6
+ */
7
+ import { ScarlettPlayer, type Plugin } from '@scarlett-player/core';
8
+ import type { EmbedConfig, PlayerType, ScarlettPlayerGlobal } from './types';
9
+ /**
10
+ * Plugin creators that builds can provide.
11
+ *
12
+ * `hls` and `native` are the two provider plugins. They are not
13
+ * interchangeable and they are not alternatives: `PluginManager.selectProvider`
14
+ * walks the registered providers in registration order and takes the first
15
+ * whose `canPlay()` accepts the source, so a build that omits `native` answers
16
+ * `PROVIDER_NOT_FOUND` for every progressive file. That was the shipped
17
+ * behaviour up to 1.7.0: an `.mp3` or `.mp4` `data-src` failed in every embed
18
+ * build, including the audio one.
19
+ */
20
+ export interface PluginCreators {
21
+ hls: () => Plugin;
22
+ /**
23
+ * Native media element provider (progressive MP4/WebM/MOV/MKV/OGV/M4V and
24
+ * MP3/WAV/OGG/FLAC/AAC/M4A/Opus/WebA). Optional so a build can still be
25
+ * assembled without it, but every shipped embed build passes it.
26
+ */
27
+ native?: () => Plugin;
28
+ videoUI?: (config: any) => Plugin;
29
+ audioUI?: (config: any) => Plugin;
30
+ analytics?: (config: any) => Plugin;
31
+ playlist?: (config: any) => Plugin;
32
+ mediaSession?: (config: any) => Plugin;
33
+ watermark?: (config: any) => Plugin;
34
+ captions?: (config: any) => Plugin;
35
+ /**
36
+ * Touch gestures: double-tap the sides to seek, tap to toggle the controls.
37
+ *
38
+ * Video builds only. The embed passes no config at all, because the plugin
39
+ * decides for itself whether to arm: its `enabled` default is `'auto'`,
40
+ * gated on `matchMedia('(any-pointer: coarse)')`, so it installs wherever a
41
+ * coarse pointer exists (a touchscreen laptop included) and a mouse still
42
+ * never triggers any of it. Forcing `enabled: true` here would instead put a
43
+ * gesture surface on a pure-mouse desktop with no touch to serve.
44
+ */
45
+ gestures?: (config: any) => Plugin;
46
+ }
47
+ /**
48
+ * Create an embed player with the given plugins
49
+ */
50
+ export declare function createEmbedPlayer(container: HTMLElement, config: Partial<EmbedConfig>, pluginCreators: PluginCreators, availableTypes: PlayerType[]): Promise<ScarlettPlayer | null>;
51
+ /**
52
+ * Initialize a player from a DOM element with data attributes
53
+ */
54
+ export declare function initElement(element: HTMLElement, pluginCreators: PluginCreators, availableTypes: PlayerType[]): Promise<ScarlettPlayer | null>;
55
+ /**
56
+ * Initialize all players on the page
57
+ */
58
+ export declare function initAll(pluginCreators: PluginCreators, availableTypes: PlayerType[]): Promise<void>;
59
+ /**
60
+ * Create the global ScarlettPlayer API
61
+ */
62
+ export declare function createScarlettPlayerAPI(pluginCreators: PluginCreators, availableTypes: PlayerType[], version: string): ScarlettPlayerGlobal;
63
+ /**
64
+ * Setup auto-initialization on DOMContentLoaded
65
+ */
66
+ export declare function setupAutoInit(pluginCreators: PluginCreators, availableTypes: PlayerType[]): void;
67
+ //# sourceMappingURL=create-embed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-embed.d.ts","sourceRoot":"","sources":["../src/create-embed.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAgB,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,KAAK,EAAE,WAAW,EAAsB,UAAU,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAGjG;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,MAAM,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IAClC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IACnC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IACvC,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;IACnC;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,MAAM,CAAC;CACpC;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,WAAW,EACtB,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC,EAC5B,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,UAAU,EAAE,GAC3B,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CA4IhC;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,WAAW,EACpB,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,UAAU,EAAE,GAC3B,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAehC;AAWD;;GAEG;AACH,wBAAsB,OAAO,CAC3B,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,UAAU,EAAE,GAC3B,OAAO,CAAC,IAAI,CAAC,CAuBf;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,UAAU,EAAE,EAC5B,OAAO,EAAE,MAAM,GACd,oBAAoB,CAyBtB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,cAAc,EAAE,cAAc,EAC9B,cAAc,EAAE,UAAU,EAAE,GAC3B,IAAI,CAUN"}
@@ -9,4 +9,4 @@ function registerControl(id, factory) {
9
9
  export {
10
10
  registerControl
11
11
  };
12
- //# sourceMappingURL=hls2.js.map
12
+ //# sourceMappingURL=embed.audio.index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embed.audio.index.js","sources":["../../plugins/ui/dist/index.js"],"sourcesContent":["// src/index.ts\nimport { enterFullscreen as enterFullscreen2, exitFullscreen as exitFullscreen2, isFullscreen as isFullscreen2 } from \"@scarlett-player/core\";\n\n// src/fit.ts\nvar UNKNOWN_RANK = 3;\nvar DEFAULT_PRIORITY = {\n \"bandwidth-indicator\": { rank: 0, exit: \"hide\" },\n \"skip-backward\": { rank: 1, exit: \"overflow\" },\n \"skip-forward\": { rank: 1, exit: \"overflow\" },\n pip: { rank: 2, exit: \"overflow\" },\n chromecast: { rank: 4, exit: \"overflow\" },\n airplay: { rank: 4, exit: \"overflow\" },\n volume: { rank: 5, exit: \"overflow\" },\n captions: { rank: 6, exit: \"overflow\" },\n quality: { rank: 6, exit: \"hide\" },\n time: { rank: 7, exit: \"hide\" },\n play: { rank: \"never\", exit: \"overflow\" },\n \"live-indicator\": { rank: \"never\", exit: \"overflow\" },\n settings: { rank: \"never\", exit: \"overflow\" },\n fullscreen: { rank: \"never\", exit: \"overflow\" },\n spacer: { rank: \"never\", exit: \"overflow\" }\n};\nfunction resolveFitItems(layout, priority) {\n return layout.map((id) => {\n const rule = DEFAULT_PRIORITY[id];\n const rank = priority?.[id] ?? rule?.rank ?? UNKNOWN_RANK;\n return { id, rank, exit: rule?.exit ?? \"overflow\" };\n });\n}\nfunction assertFitLayout(layout, priority) {\n if (!layout.includes(\"quality\") || layout.includes(\"settings\")) {\n return;\n }\n const [quality] = resolveFitItems([\"quality\"], priority);\n if (quality.rank === \"never\") {\n return;\n }\n throw new Error(\n `uiPlugin: a layout with \"quality\" needs \"settings\" as well. The quality control hides when the bar does not fit, and the settings menu is where its Quality row lives. Add \"settings\" to controls, pin quality with priority: { quality: 'never' }, or set responsive: false.`\n );\n}\nfunction needed(widths, gap, overflowButtonWidth, trayUsed) {\n const content = widths.reduce((sum, width) => sum + width, 0);\n const gaps = gap * Math.max(0, widths.length - 1);\n const tray = trayUsed ? overflowButtonWidth + gap : 0;\n return content + gaps + tray;\n}\nfunction planFit(items, available, gap, overflowButtonWidth) {\n const ids = items.map((item) => item.id);\n if (available <= 0) {\n return { inBar: ids, overflow: [], hidden: [] };\n }\n const counted = items.filter((item) => item.visible && item.width > 0);\n const remaining = [...counted];\n const overflow = /* @__PURE__ */ new Set();\n const hidden = /* @__PURE__ */ new Set();\n while (needed(\n remaining.map((item) => item.width),\n gap,\n overflowButtonWidth,\n overflow.size > 0\n ) > available) {\n let victim = -1;\n for (let i = 0; i < remaining.length; i++) {\n const candidate = remaining[i];\n if (candidate.rank === \"never\") continue;\n if (victim === -1 || candidate.rank <= remaining[victim].rank) {\n victim = i;\n }\n }\n if (victim === -1) break;\n const [removed] = remaining.splice(victim, 1);\n (removed.exit === \"hide\" ? hidden : overflow).add(removed.id);\n }\n return {\n inBar: ids.filter((id) => !overflow.has(id) && !hidden.has(id)),\n overflow: ids.filter((id) => overflow.has(id)),\n hidden: ids.filter((id) => hidden.has(id))\n };\n}\n\n// src/styles.ts\nvar styles = `\n/* ============================================\n Container & Base\n ============================================ */\n.sp-container {\n position: relative;\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 transition: opacity 0.25s ease;\n}\n\n.sp-progress-wrapper--visible {\n opacity: 1;\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:root {\n --sp-accent: #e50914;\n --sp-color: #fff;\n --sp-bg: rgba(0, 0, 0, 0.8);\n --sp-control-height: 48px;\n --sp-icon-size: 24px;\n}\n`;\n\n// src/icons.ts\nvar icons = {\n play: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>`,\n pause: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M6 4h4v16H6V4zm8 0h4v16h-4V4z\"/></svg>`,\n replay: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z\"/></svg>`,\n volumeHigh: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\"/></svg>`,\n volumeLow: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z\"/></svg>`,\n volumeMute: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z\"/></svg>`,\n fullscreen: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/></svg>`,\n exitFullscreen: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z\"/></svg>`,\n pip: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z\"/></svg>`,\n exitPip: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 9h6v2H9z\"/></svg>`,\n settings: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z\"/></svg>`,\n chromecast: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z\"/></svg>`,\n chromecastConnected: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z\"/></svg>`,\n airplay: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M6 22h12l-6-6-6 6zM21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v-2H3V5h18v12h-4v2h4c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z\"/></svg>`,\n captions: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z\"/></svg>`,\n captionsOff: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M19.5 5.5v13h-15v-13h15zM19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2z\"/></svg>`,\n checkmark: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg>`,\n chevronUp: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z\"/></svg>`,\n /** Vertical ellipsis for the overflow tray button. */\n more: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z\"/></svg>`,\n chevronDown: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z\"/></svg>`,\n spinner: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\" class=\"sp-spin\"><path d=\"M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z\"/></svg>`,\n skipForward: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z\"/></svg>`,\n skipBack: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z\"/></svg>`,\n forward10: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z\"/><path d=\"M10.9 16V11.73l-.72.36-.48-.86 1.48-.73h.85V16h-1.13zm2.77-2.14c0-.66.13-1.2.38-1.6.26-.41.66-.62 1.2-.62.55 0 .95.21 1.21.62.25.4.38.94.38 1.6 0 .67-.13 1.2-.38 1.61-.26.41-.66.61-1.21.61-.54 0-.94-.2-1.2-.61-.25-.41-.38-.94-.38-1.61zm1.12 0c0 .45.05.79.15 1.03.1.23.26.35.48.35s.38-.12.49-.35c.1-.24.15-.58.15-1.03s-.05-.78-.15-1.02c-.11-.23-.27-.35-.49-.35s-.38.12-.48.35c-.1.24-.15.57-.15 1.02z\"/></svg>`,\n replay10: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z\"/><path d=\"M10.9 16V11.73l-.72.36-.48-.86 1.48-.73h.85V16h-1.13zm2.77-2.14c0-.66.13-1.2.38-1.6.26-.41.66-.62 1.2-.62.55 0 .95.21 1.21.62.25.4.38.94.38 1.6 0 .67-.13 1.2-.38 1.61-.26.41-.66.61-1.21.61-.54 0-.94-.2-1.2-.61-.25-.41-.38-.94-.38-1.61zm1.12 0c0 .45.05.79.15 1.03.1.23.26.35.48.35s.38-.12.49-.35c.1-.24.15-.58.15-1.03s-.05-.78-.15-1.02c-.11-.23-.27-.35-.49-.35s-.38.12-.48.35c-.1.24-.15.57-.15 1.02z\"/></svg>`,\n error: `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z\"/></svg>`\n};\n\n// src/utils/dom.ts\nfunction createElement(tag, attrs, children) {\n const el = document.createElement(tag);\n if (attrs) {\n for (const [key, value] of Object.entries(attrs)) {\n if (key === \"className\") {\n el.className = value;\n } else {\n el.setAttribute(key, value);\n }\n }\n }\n if (children) {\n for (const child of children) {\n if (typeof child === \"string\") {\n el.appendChild(document.createTextNode(child));\n } else {\n el.appendChild(child);\n }\n }\n }\n return el;\n}\nfunction createButton(className, label, icon) {\n const btn = createElement(\"button\", {\n className: `sp-control ${className}`,\n \"aria-label\": label,\n type: \"button\"\n });\n setHTML(btn, icon);\n return btn;\n}\nfunction getVideo(container) {\n return container.querySelector(\"video\");\n}\nvar lastHTML = /* @__PURE__ */ new WeakMap();\nfunction setHTML(el, html) {\n if (lastHTML.get(el) === html) return false;\n el.innerHTML = html;\n lastHTML.set(el, html);\n return true;\n}\nfunction setAttr(el, name, value) {\n if (el.getAttribute(name) === value) return false;\n el.setAttribute(name, value);\n return true;\n}\n\n// src/utils/format.ts\nfunction formatTime(seconds) {\n if (!isFinite(seconds) || isNaN(seconds)) {\n return \"0:00\";\n }\n const absSeconds = Math.abs(seconds);\n const h = Math.floor(absSeconds / 3600);\n const m = Math.floor(absSeconds % 3600 / 60);\n const s = Math.floor(absSeconds % 60);\n const sign = seconds < 0 ? \"-\" : \"\";\n if (h > 0) {\n return `${sign}${h}:${pad(m)}:${pad(s)}`;\n }\n return `${sign}${m}:${pad(s)}`;\n}\nfunction pad(n) {\n return n < 10 ? `0${n}` : `${n}`;\n}\nfunction formatLiveTime(behindLive) {\n if (behindLive <= 0) {\n return \"LIVE\";\n }\n return `-${formatTime(behindLive)}`;\n}\n\n// src/controls/PlayButton.ts\nvar PlayButton = class {\n constructor(api) {\n this.clickHandler = () => {\n this.toggle();\n };\n this.api = api;\n this.el = createButton(\"sp-play\", \"Play\", icons.play);\n this.el.addEventListener(\"click\", this.clickHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const playing = this.api.getState(\"playing\");\n const ended = this.api.getState(\"ended\");\n let icon;\n let label;\n if (ended) {\n icon = icons.replay;\n label = \"Replay\";\n } else if (playing) {\n icon = icons.pause;\n label = \"Pause\";\n } else {\n icon = icons.play;\n label = \"Play\";\n }\n setHTML(this.el, icon);\n setAttr(this.el, \"aria-label\", label);\n }\n toggle() {\n const video = getVideo(this.api.container);\n if (!video) return;\n const ended = this.api.getState(\"ended\");\n if (ended) {\n video.currentTime = 0;\n video.play().catch(() => {\n });\n } else if (!video.paused) {\n video.pause();\n } else {\n video.play().catch(() => {\n });\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/BigPlayButton.ts\nvar BigPlayButton = class {\n /**\n * @param api - Plugin API for state and container access\n * @param isOverlayVisible - Whether the error overlay is showing; the button\n * must not sit on top of it, and `error` state alone does not say (a\n * dismissed overlay leaves the error behind)\n */\n constructor(api, isOverlayVisible = () => false) {\n /**\n * Latched on the first `playing`.\n *\n * \"Hidden from the first playing onward\" cannot be read off `currentTime`\n * alone: a viewer who pauses in the first fraction of a second is still\n * mid-playback, and the button reappearing over live video would cover the\n * picture.\n */\n this.hasStarted = false;\n this.clickHandler = () => {\n this.start();\n };\n this.api = api;\n this.isOverlayVisible = isOverlayVisible;\n const btn = document.createElement(\"button\");\n btn.className = \"sp-big-play\";\n btn.setAttribute(\"type\", \"button\");\n btn.setAttribute(\"aria-label\", \"Play\");\n setHTML(btn, icons.play);\n btn.addEventListener(\"click\", this.clickHandler);\n this.el = btn;\n }\n render() {\n return this.el;\n }\n /**\n * Show or hide the button, and swap in the replay glyph after `ended`.\n *\n * Driven by the same `scheduleUpdate()` pass as every other control, so the\n * button cannot disagree with the control bar about what state playback is\n * in.\n */\n update() {\n const playing = this.api.getState(\"playing\");\n const ended = this.hasEnded();\n const currentTime = this.api.getState(\"currentTime\");\n const playbackState = this.api.getState(\"playbackState\");\n const error = this.api.getState(\"error\");\n if (playing) {\n this.hasStarted = true;\n }\n let visible;\n if (error || this.isOverlayVisible()) {\n visible = false;\n } else if (playbackState === \"loading\") {\n visible = false;\n } else if (playing) {\n visible = false;\n } else if (ended) {\n visible = true;\n } else if (this.hasStarted || currentTime !== 0) {\n visible = false;\n } else {\n visible = playbackState === \"idle\" || playbackState === \"ready\";\n }\n setHTML(this.el, ended ? icons.replay : icons.play);\n setAttr(this.el, \"aria-label\", ended ? \"Replay\" : \"Play\");\n this.el.classList.toggle(\"sp-big-play--visible\", visible);\n }\n /**\n * Whether playback has actually ended, asked of the media element.\n *\n * NOT the `ended` state key. Measured in Chrome on 2026-09-02: neither\n * provider clears that key on a replay (only `load()` does), so after a\n * viewer replays a video it stays true for the rest of the session, while\n * `video.ended` correctly goes false the moment the position leaves the\n * end. Trusting the key would leave this button sitting over playing video,\n * and would make a later pause bring it back as Replay. The key is the\n * fallback for the window before a provider has created an element.\n */\n hasEnded() {\n const video = getVideo(this.api.container);\n return video ? video.ended : Boolean(this.api.getState(\"ended\"));\n }\n /**\n * Start (or restart) playback.\n *\n * The same two branches as the control bar's play button: restart from zero\n * after the video ended, otherwise just play. There is no pause branch,\n * because this button is never on screen while playback is running. It reads\n * `video.ended` for the same reason `hasEnded()` does.\n */\n start() {\n const video = getVideo(this.api.container);\n if (!video) return;\n if (video.ended) {\n video.currentTime = 0;\n }\n video.play().catch(() => {\n });\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/ThumbnailPreview.ts\nvar ThumbnailPreview = class {\n constructor() {\n this.config = null;\n this.loaded = false;\n this.el = createElement(\"div\", { className: \"sp-thumbnail-preview\" });\n this.img = createElement(\"div\", { className: \"sp-thumbnail-preview__img\" });\n this.el.appendChild(this.img);\n }\n getElement() {\n return this.el;\n }\n setConfig(config) {\n this.config = config;\n this.loaded = false;\n if (config) {\n this.img.style.width = `${config.width}px`;\n this.img.style.height = `${config.height}px`;\n this.el.style.width = `${config.width}px`;\n this.el.style.height = `${config.height}px`;\n const preload = new Image();\n preload.onload = () => {\n this.loaded = true;\n };\n preload.onerror = () => {\n this.config = null;\n this.loaded = false;\n };\n preload.src = config.src;\n }\n }\n /**\n * Update the thumbnail to show the frame at the given time.\n * @param time Time in seconds\n * @param percent Position as 0-1 fraction (for horizontal positioning)\n */\n show(time, percent) {\n if (!this.config || !this.loaded) {\n this.el.style.display = \"none\";\n return;\n }\n const { src, width, height, columns, interval } = this.config;\n const index = Math.floor(time / interval);\n const col = index % columns;\n const row = Math.floor(index / columns);\n this.img.style.backgroundImage = `url(${src})`;\n this.img.style.backgroundPosition = `-${col * width}px -${row * height}px`;\n this.img.style.backgroundSize = `${columns * width}px auto`;\n this.img.style.width = `${width}px`;\n this.img.style.height = `${height}px`;\n this.el.style.left = `${percent * 100}%`;\n this.el.style.display = \"\";\n }\n hide() {\n this.el.style.display = \"none\";\n }\n isConfigured() {\n return this.config !== null;\n }\n destroy() {\n this.el.remove();\n }\n};\n\n// src/controls/ProgressBar.ts\nvar ProgressBar = class {\n constructor(api) {\n this.isDragging = false;\n this.lastSeekTime = 0;\n this.seekThrottleMs = 100;\n // Throttle seeks to max 10/sec\n this.wasPlayingBeforeDrag = false;\n /** Chapter list the marker layer was last built from, to avoid rebuilding every frame. */\n this.renderedChapters = null;\n /** Duration the marker layer was last built against, since positions are a percentage of it. */\n this.renderedDuration = 0;\n this.onMouseDown = (e) => {\n e.preventDefault();\n const video = getVideo(this.api.container);\n this.wasPlayingBeforeDrag = video ? !video.paused : false;\n this.isDragging = true;\n this.el.classList.add(\"sp-progress--dragging\");\n this.lastSeekTime = 0;\n this.seek(e.clientX, true);\n };\n this.onDocMouseMove = (e) => {\n if (this.isDragging) {\n this.seek(e.clientX);\n this.updateVisualPosition(e.clientX);\n }\n };\n this.onMouseUp = (e) => {\n if (this.isDragging) {\n this.seek(e.clientX, true);\n this.isDragging = false;\n this.el.classList.remove(\"sp-progress--dragging\");\n if (this.wasPlayingBeforeDrag) {\n const video = getVideo(this.api.container);\n if (video && video.paused) {\n const resumePlayback = () => {\n video.removeEventListener(\"seeked\", resumePlayback);\n video.play().catch(() => {\n });\n };\n video.addEventListener(\"seeked\", resumePlayback);\n }\n }\n }\n };\n this.onTouchStart = (e) => {\n e.preventDefault();\n const video = getVideo(this.api.container);\n this.wasPlayingBeforeDrag = video ? !video.paused : false;\n this.isDragging = true;\n this.el.classList.add(\"sp-progress--dragging\");\n this.lastSeekTime = 0;\n this.seek(e.touches[0].clientX, true);\n };\n this.onDocTouchMove = (e) => {\n if (this.isDragging) {\n e.preventDefault();\n this.seek(e.touches[0].clientX);\n this.updateVisualPosition(e.touches[0].clientX);\n }\n };\n this.onTouchEnd = (e) => {\n if (this.isDragging) {\n const clientX = e.changedTouches?.[0]?.clientX;\n if (clientX !== void 0) {\n this.seek(clientX, true);\n }\n this.isDragging = false;\n this.el.classList.remove(\"sp-progress--dragging\");\n if (this.wasPlayingBeforeDrag) {\n const video = getVideo(this.api.container);\n if (video && video.paused) {\n const resumePlayback = () => {\n video.removeEventListener(\"seeked\", resumePlayback);\n video.play().catch(() => {\n });\n };\n video.addEventListener(\"seeked\", resumePlayback);\n }\n }\n this.tooltip.style.opacity = \"0\";\n this.thumbnailPreview.hide();\n }\n };\n this.onMouseMove = (e) => {\n this.updateTooltip(e.clientX);\n };\n this.onMouseLeave = () => {\n if (!this.isDragging) {\n this.tooltip.style.opacity = \"0\";\n this.thumbnailPreview.hide();\n }\n };\n this.onKeyDown = (e) => {\n const video = getVideo(this.api.container);\n if (!video) return;\n const step = 5;\n const live = this.api.getState(\"live\");\n const seekableRange = this.api.getState(\"seekableRange\");\n if (live && seekableRange) {\n switch (e.key) {\n case \"ArrowLeft\":\n e.preventDefault();\n video.currentTime = Math.max(seekableRange.start, video.currentTime - step);\n break;\n case \"ArrowRight\":\n e.preventDefault();\n video.currentTime = Math.min(seekableRange.end, video.currentTime + step);\n break;\n case \"Home\":\n e.preventDefault();\n video.currentTime = seekableRange.start;\n break;\n case \"End\":\n e.preventDefault();\n video.currentTime = seekableRange.end;\n break;\n }\n } else {\n const duration = this.api.getState(\"duration\") || 0;\n switch (e.key) {\n case \"ArrowLeft\":\n e.preventDefault();\n video.currentTime = Math.max(0, video.currentTime - step);\n break;\n case \"ArrowRight\":\n e.preventDefault();\n video.currentTime = Math.min(duration, video.currentTime + step);\n break;\n case \"Home\":\n e.preventDefault();\n video.currentTime = 0;\n break;\n case \"End\":\n e.preventDefault();\n video.currentTime = duration;\n break;\n }\n }\n };\n this.api = api;\n this.wrapper = createElement(\"div\", { className: \"sp-progress-wrapper\" });\n this.el = createElement(\"div\", { className: \"sp-progress\" });\n const track = createElement(\"div\", { className: \"sp-progress__track\" });\n this.buffered = createElement(\"div\", { className: \"sp-progress__buffered\" });\n this.filled = createElement(\"div\", { className: \"sp-progress__filled\" });\n this.markers = createElement(\"div\", { className: \"sp-progress__markers\" });\n this.handle = createElement(\"div\", { className: \"sp-progress__handle\" });\n this.tooltip = createElement(\"div\", { className: \"sp-progress__tooltip\" });\n this.tooltip.textContent = \"0:00\";\n this.thumbnailPreview = new ThumbnailPreview();\n track.appendChild(this.buffered);\n track.appendChild(this.filled);\n track.appendChild(this.markers);\n track.appendChild(this.handle);\n this.el.appendChild(track);\n this.el.appendChild(this.thumbnailPreview.getElement());\n this.el.appendChild(this.tooltip);\n this.wrapper.appendChild(this.el);\n this.el.setAttribute(\"role\", \"slider\");\n this.el.setAttribute(\"aria-label\", \"Seek\");\n this.el.setAttribute(\"aria-valuemin\", \"0\");\n this.el.setAttribute(\"aria-valuemax\", \"0\");\n this.el.setAttribute(\"aria-valuenow\", \"0\");\n this.el.setAttribute(\"aria-valuetext\", \"0:00\");\n this.el.setAttribute(\"tabindex\", \"0\");\n this.wrapper.addEventListener(\"mousedown\", this.onMouseDown);\n this.wrapper.addEventListener(\"mousemove\", this.onMouseMove);\n this.wrapper.addEventListener(\"mouseleave\", this.onMouseLeave);\n this.wrapper.addEventListener(\"touchstart\", this.onTouchStart, { passive: false });\n this.el.addEventListener(\"keydown\", this.onKeyDown);\n document.addEventListener(\"mousemove\", this.onDocMouseMove);\n document.addEventListener(\"mouseup\", this.onMouseUp);\n document.addEventListener(\"touchmove\", this.onDocTouchMove, { passive: false });\n document.addEventListener(\"touchend\", this.onTouchEnd);\n document.addEventListener(\"touchcancel\", this.onTouchEnd);\n }\n render() {\n return this.wrapper;\n }\n /** Show the progress bar */\n show() {\n this.wrapper.classList.add(\"sp-progress-wrapper--visible\");\n }\n /** Hide the progress bar */\n hide() {\n this.wrapper.classList.remove(\"sp-progress-wrapper--visible\");\n }\n /** Set thumbnail sprite configuration */\n setThumbnails(config) {\n this.thumbnailPreview.setConfig(config);\n }\n update() {\n const currentTime = this.api.getState(\"currentTime\") || 0;\n const duration = this.api.getState(\"duration\") || 0;\n const bufferedRanges = this.api.getState(\"buffered\");\n const live = this.api.getState(\"live\");\n const seekableRange = this.api.getState(\"seekableRange\");\n const thumbnails = this.api.getState(\"thumbnails\");\n if (thumbnails && !this.thumbnailPreview.isConfigured()) {\n this.thumbnailPreview.setConfig(thumbnails);\n }\n this.el.classList.toggle(\"sp-progress--live\", !!live);\n this.updateMarkers(duration, live, seekableRange);\n if (live && seekableRange) {\n const rangeLength = seekableRange.end - seekableRange.start;\n if (rangeLength > 0) {\n const progress = (currentTime - seekableRange.start) / rangeLength * 100;\n this.filled.style.width = `${Math.max(0, Math.min(100, progress))}%`;\n this.handle.style.left = `${Math.max(0, Math.min(100, progress))}%`;\n }\n if (bufferedRanges && bufferedRanges.length > 0) {\n const rangeLength2 = seekableRange.end - seekableRange.start;\n if (rangeLength2 > 0) {\n const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);\n const bufferedPercent = (bufferedEnd - seekableRange.start) / rangeLength2 * 100;\n this.buffered.style.width = `${Math.max(0, Math.min(100, bufferedPercent))}%`;\n }\n }\n this.el.setAttribute(\"aria-valuemax\", String(Math.floor(seekableRange.end)));\n this.el.setAttribute(\"aria-valuenow\", String(Math.floor(currentTime)));\n this.el.setAttribute(\"aria-valuetext\", `${Math.floor(seekableRange.end - currentTime)} seconds behind live`);\n } else if (duration > 0) {\n const progress = currentTime / duration * 100;\n this.filled.style.width = `${progress}%`;\n this.handle.style.left = `${progress}%`;\n if (bufferedRanges && bufferedRanges.length > 0) {\n const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);\n const bufferedPercent = bufferedEnd / duration * 100;\n this.buffered.style.width = `${bufferedPercent}%`;\n }\n this.el.setAttribute(\"aria-valuemax\", String(Math.floor(duration)));\n this.el.setAttribute(\"aria-valuenow\", String(Math.floor(currentTime)));\n this.el.setAttribute(\"aria-valuetext\", formatTime(currentTime));\n }\n }\n /**\n * Label of the chapter containing a point on the timeline.\n *\n * Mirrors the chapters plugin's own lookup: a start time belongs to its\n * chapter, an end time belongs to the next one, and a point in a gap between\n * sparse chapters belongs to neither.\n *\n * @param time - Position in seconds\n * @returns The chapter label, or null when the point is outside every chapter\n */\n chapterLabelAt(time) {\n const chapters = this.api.getState(\"chapters\") ?? [];\n for (let i = chapters.length - 1; i >= 0; i--) {\n const chapter = chapters[i];\n if (time < chapter.time) continue;\n const next = chapters[i + 1];\n const end = chapter.endTime ?? (next ? next.time : Infinity);\n return time < end ? chapter.label : null;\n }\n return null;\n }\n /**\n * Paint chapter dividers along the track.\n *\n * Reads the `chapters` state that core owns, so this works whether the list\n * came from the chapters plugin or the host set it directly, and renders\n * nothing at all when there are none.\n *\n * Rebuilds only when the list or the duration actually changed. `update()`\n * runs on every time update, and rebuilding a dozen nodes 4 times a second\n * would churn the DOM for no reason.\n *\n * @param duration - Media duration in seconds\n * @param live - Whether the media is live\n * @param seekableRange - DVR window, when the media is live\n */\n updateMarkers(duration, live, seekableRange) {\n const chapters = this.api.getState(\"chapters\") ?? [];\n const range = live ? seekableRange ? seekableRange.end - seekableRange.start : 0 : duration;\n if (chapters.length === 0 || range <= 0) {\n if (this.renderedChapters !== null) {\n this.markers.textContent = \"\";\n this.renderedChapters = null;\n this.renderedDuration = 0;\n }\n return;\n }\n if (chapters === this.renderedChapters && range === this.renderedDuration) {\n return;\n }\n const origin = live && seekableRange ? seekableRange.start : 0;\n this.markers.textContent = \"\";\n for (const chapter of chapters) {\n if (chapter.time <= origin) continue;\n const percent = (chapter.time - origin) / range * 100;\n if (percent <= 0 || percent >= 100) continue;\n const marker = createElement(\"div\", { className: \"sp-progress__marker\" });\n marker.style.left = `${percent}%`;\n marker.title = chapter.label;\n this.markers.appendChild(marker);\n }\n this.renderedChapters = chapters;\n this.renderedDuration = range;\n }\n getTimeFromPosition(clientX) {\n const rect = this.el.getBoundingClientRect();\n const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n const live = this.api.getState(\"live\");\n const seekableRange = this.api.getState(\"seekableRange\");\n if (live && seekableRange) {\n const rangeLength = seekableRange.end - seekableRange.start;\n return seekableRange.start + percent * rangeLength;\n }\n const duration = this.api.getState(\"duration\") || 0;\n return percent * duration;\n }\n updateTooltip(clientX) {\n const rect = this.el.getBoundingClientRect();\n const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n const time = this.getTimeFromPosition(clientX);\n const live = this.api.getState(\"live\");\n const seekableRange = this.api.getState(\"seekableRange\");\n if (live && seekableRange) {\n const behindLive = seekableRange.end - time;\n this.tooltip.textContent = formatLiveTime(behindLive);\n } else {\n this.tooltip.textContent = formatTime(time);\n }\n const chapterLabel = this.chapterLabelAt(time);\n if (chapterLabel) {\n const label = createElement(\"span\", { className: \"sp-progress__tooltip-chapter\" });\n label.textContent = chapterLabel;\n this.tooltip.appendChild(label);\n }\n this.tooltip.style.left = `${percent * 100}%`;\n if (this.thumbnailPreview.isConfigured()) {\n this.thumbnailPreview.show(time, percent);\n }\n }\n updateVisualPosition(clientX) {\n const rect = this.el.getBoundingClientRect();\n const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n this.filled.style.width = `${percent * 100}%`;\n this.handle.style.left = `${percent * 100}%`;\n }\n seek(clientX, force = false) {\n const video = getVideo(this.api.container);\n if (!video) return;\n const now = Date.now();\n if (!force && this.isDragging && now - this.lastSeekTime < this.seekThrottleMs) {\n return;\n }\n this.lastSeekTime = now;\n const time = this.getTimeFromPosition(clientX);\n video.currentTime = time;\n }\n destroy() {\n this.wrapper.removeEventListener(\"mousedown\", this.onMouseDown);\n this.wrapper.removeEventListener(\"mousemove\", this.onMouseMove);\n this.wrapper.removeEventListener(\"mouseleave\", this.onMouseLeave);\n this.wrapper.removeEventListener(\"touchstart\", this.onTouchStart);\n document.removeEventListener(\"mousemove\", this.onDocMouseMove);\n document.removeEventListener(\"mouseup\", this.onMouseUp);\n document.removeEventListener(\"touchmove\", this.onDocTouchMove);\n document.removeEventListener(\"touchend\", this.onTouchEnd);\n document.removeEventListener(\"touchcancel\", this.onTouchEnd);\n this.thumbnailPreview.destroy();\n this.wrapper.remove();\n }\n};\n\n// src/controls/TimeDisplay.ts\nvar TimeDisplay = class {\n constructor(api) {\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-time\" });\n this.el.setAttribute(\"aria-live\", \"off\");\n }\n render() {\n return this.el;\n }\n update() {\n const live = this.api.getState(\"live\");\n const currentTime = this.api.getState(\"currentTime\") || 0;\n const duration = this.api.getState(\"duration\") || 0;\n if (live) {\n const seekableRange = this.api.getState(\"seekableRange\");\n if (seekableRange) {\n const behindLive = seekableRange.end - currentTime;\n this.el.textContent = formatLiveTime(behindLive);\n } else {\n this.el.textContent = formatLiveTime(0);\n }\n } else {\n this.el.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;\n }\n }\n destroy() {\n this.el.remove();\n }\n};\n\n// src/controls/VolumeControl.ts\nvar VolumeControl = class {\n constructor(api) {\n this.isDragging = false;\n this.onMouseDown = (e) => {\n e.preventDefault();\n this.isDragging = true;\n this.setVolume(this.getVolumeFromPosition(e.clientX));\n };\n this.onDocMouseMove = (e) => {\n if (this.isDragging) {\n this.setVolume(this.getVolumeFromPosition(e.clientX));\n }\n };\n this.onMouseUp = () => {\n this.isDragging = false;\n };\n this.onTouchStart = (e) => {\n e.preventDefault();\n this.isDragging = true;\n this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));\n };\n this.onDocTouchMove = (e) => {\n if (this.isDragging) {\n e.preventDefault();\n this.setVolume(this.getVolumeFromPosition(e.touches[0].clientX));\n }\n };\n this.onTouchEnd = () => {\n this.isDragging = false;\n };\n this.onKeyDown = (e) => {\n const video = getVideo(this.api.container);\n if (!video) return;\n const step = 0.1;\n switch (e.key) {\n case \"ArrowUp\":\n case \"ArrowRight\":\n e.preventDefault();\n this.setVolume(video.volume + step);\n break;\n case \"ArrowDown\":\n case \"ArrowLeft\":\n e.preventDefault();\n this.setVolume(video.volume - step);\n break;\n }\n };\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-volume\" });\n this.btn = createElement(\"button\", {\n className: \"sp-control sp-volume__btn\",\n \"aria-label\": \"Mute\",\n type: \"button\"\n });\n this.btn.innerHTML = icons.volumeHigh;\n this.btn.onclick = () => this.toggleMute();\n const sliderWrap = createElement(\"div\", { className: \"sp-volume__slider-wrap\" });\n this.slider = createElement(\"div\", { className: \"sp-volume__slider\" });\n this.slider.setAttribute(\"role\", \"slider\");\n this.slider.setAttribute(\"aria-label\", \"Volume\");\n this.slider.setAttribute(\"aria-valuemin\", \"0\");\n this.slider.setAttribute(\"aria-valuemax\", \"100\");\n this.slider.setAttribute(\"tabindex\", \"0\");\n this.level = createElement(\"div\", { className: \"sp-volume__level\" });\n this.slider.appendChild(this.level);\n sliderWrap.appendChild(this.slider);\n this.el.appendChild(this.btn);\n this.el.appendChild(sliderWrap);\n this.slider.addEventListener(\"mousedown\", this.onMouseDown);\n this.slider.addEventListener(\"touchstart\", this.onTouchStart, { passive: false });\n this.slider.addEventListener(\"keydown\", this.onKeyDown);\n document.addEventListener(\"mousemove\", this.onDocMouseMove);\n document.addEventListener(\"mouseup\", this.onMouseUp);\n document.addEventListener(\"touchmove\", this.onDocTouchMove, { passive: false });\n document.addEventListener(\"touchend\", this.onTouchEnd);\n document.addEventListener(\"touchcancel\", this.onTouchEnd);\n }\n render() {\n return this.el;\n }\n update() {\n const volume = this.api.getState(\"volume\") ?? 1;\n const muted = this.api.getState(\"muted\") ?? false;\n let icon;\n let label;\n if (muted || volume === 0) {\n icon = icons.volumeMute;\n label = \"Unmute\";\n } else if (volume < 0.5) {\n icon = icons.volumeLow;\n label = \"Mute\";\n } else {\n icon = icons.volumeHigh;\n label = \"Mute\";\n }\n setHTML(this.btn, icon);\n setAttr(this.btn, \"aria-label\", label);\n const displayVolume = muted ? 0 : volume;\n const width = `${displayVolume * 100}%`;\n if (this.level.style.width !== width) {\n this.level.style.width = width;\n }\n const volumePercent = Math.round(displayVolume * 100);\n setAttr(this.slider, \"aria-valuenow\", String(volumePercent));\n setAttr(this.slider, \"aria-valuetext\", `${volumePercent}%`);\n }\n toggleMute() {\n const video = getVideo(this.api.container);\n if (!video) return;\n video.muted = !video.muted;\n }\n setVolume(percent) {\n const video = getVideo(this.api.container);\n if (!video) return;\n const vol = Math.max(0, Math.min(1, percent));\n video.volume = vol;\n if (vol > 0 && video.muted) {\n video.muted = false;\n }\n }\n getVolumeFromPosition(clientX) {\n const rect = this.slider.getBoundingClientRect();\n return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n }\n destroy() {\n this.slider.removeEventListener(\"mousedown\", this.onMouseDown);\n this.slider.removeEventListener(\"touchstart\", this.onTouchStart);\n this.slider.removeEventListener(\"keydown\", this.onKeyDown);\n document.removeEventListener(\"mousemove\", this.onDocMouseMove);\n document.removeEventListener(\"mouseup\", this.onMouseUp);\n document.removeEventListener(\"touchmove\", this.onDocTouchMove);\n document.removeEventListener(\"touchend\", this.onTouchEnd);\n document.removeEventListener(\"touchcancel\", this.onTouchEnd);\n this.el.remove();\n }\n};\n\n// src/controls/LiveIndicator.ts\nvar LiveIndicator = class {\n constructor(api) {\n this.handleClick = () => {\n this.seekToLive();\n };\n this.handleKeyDown = (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n this.seekToLive();\n }\n };\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-live\" });\n this.dot = createElement(\"div\", { className: \"sp-live__dot\" });\n this.label = document.createElement(\"span\");\n this.label.textContent = \"LIVE\";\n this.el.appendChild(this.dot);\n this.el.appendChild(this.label);\n this.el.setAttribute(\"role\", \"button\");\n this.el.setAttribute(\"aria-label\", \"Live broadcast - currently at live edge\");\n this.el.setAttribute(\"tabindex\", \"0\");\n this.el.addEventListener(\"click\", this.handleClick);\n this.el.addEventListener(\"keydown\", this.handleKeyDown);\n }\n render() {\n return this.el;\n }\n update() {\n const live = this.api.getState(\"live\");\n const liveEdge = this.api.getState(\"liveEdge\");\n this.el.style.display = live ? \"\" : \"none\";\n if (liveEdge) {\n this.el.classList.remove(\"sp-live--behind\");\n this.label.textContent = \"LIVE\";\n this.dot.setAttribute(\"aria-hidden\", \"true\");\n this.el.setAttribute(\"aria-label\", \"Live broadcast - currently at live edge\");\n } else {\n this.el.classList.add(\"sp-live--behind\");\n this.label.textContent = \"GO LIVE\";\n this.dot.setAttribute(\"aria-hidden\", \"true\");\n this.el.setAttribute(\"aria-label\", \"Live broadcast - behind live edge, click to seek to live\");\n }\n }\n seekToLive() {\n const video = getVideo(this.api.container);\n if (!video) return;\n const seekableRange = this.api.getState(\"seekableRange\");\n if (seekableRange) {\n video.currentTime = seekableRange.end;\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.handleClick);\n this.el.removeEventListener(\"keydown\", this.handleKeyDown);\n this.el.remove();\n }\n};\n\n// src/controls/QualityMenu.ts\nvar QualityMenu = class {\n constructor(api) {\n this.isOpen = false;\n this.lastQualitiesJson = \"\";\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-quality\" });\n this.btn = createButton(\"sp-quality__btn\", \"Quality\", icons.settings);\n this.btnLabel = createElement(\"span\", { className: \"sp-quality__label\" });\n this.btnLabel.textContent = \"Auto\";\n this.btn.appendChild(this.btnLabel);\n this.btn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n this.toggle();\n });\n this.menu = createElement(\"div\", { className: \"sp-quality-menu\" });\n this.menu.setAttribute(\"role\", \"menu\");\n this.menu.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n });\n this.el.appendChild(this.btn);\n this.el.appendChild(this.menu);\n this.closeHandler = (e) => {\n if (!this.el.contains(e.target)) {\n this.close();\n }\n };\n document.addEventListener(\"click\", this.closeHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const qualities = this.api.getState(\"qualities\") || [];\n const currentQuality = this.api.getState(\"currentQuality\");\n this.el.style.display = qualities.length > 0 ? \"\" : \"none\";\n this.btnLabel.textContent = currentQuality?.label || \"Auto\";\n const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));\n const currentId = currentQuality?.id || \"auto\";\n if (qualitiesJson !== this.lastQualitiesJson) {\n this.lastQualitiesJson = qualitiesJson;\n this.rebuildMenu(qualities);\n }\n this.updateActiveStates(currentId);\n }\n rebuildMenu(qualities) {\n this.menu.innerHTML = \"\";\n const autoItem = this.createMenuItem(\"Auto\", \"auto\");\n this.menu.appendChild(autoItem);\n const sorted = [...qualities].sort((a, b) => b.height - a.height);\n for (const q of sorted) {\n if (q.id === \"auto\") continue;\n const item = this.createMenuItem(q.label, q.id);\n this.menu.appendChild(item);\n }\n }\n updateActiveStates(activeId) {\n const items = this.menu.querySelectorAll(\".sp-quality-menu__item\");\n items.forEach((item) => {\n const id = item.getAttribute(\"data-quality-id\");\n const isActive = id === activeId;\n item.classList.toggle(\"sp-quality-menu__item--active\", isActive);\n });\n }\n createMenuItem(label, qualityId) {\n const item = createElement(\"div\", {\n className: \"sp-quality-menu__item\"\n });\n item.setAttribute(\"role\", \"menuitem\");\n item.setAttribute(\"data-quality-id\", qualityId);\n const labelSpan = createElement(\"span\", { className: \"sp-quality-menu__label\" });\n labelSpan.textContent = label;\n item.appendChild(labelSpan);\n item.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.selectQuality(qualityId);\n });\n return item;\n }\n selectQuality(qualityId) {\n this.api.emit(\"quality:select\", {\n quality: qualityId,\n auto: qualityId === \"auto\"\n });\n this.close();\n }\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n open() {\n this.isOpen = true;\n this.menu.classList.add(\"sp-quality-menu--open\");\n this.btn.setAttribute(\"aria-expanded\", \"true\");\n }\n close() {\n this.isOpen = false;\n this.menu.classList.remove(\"sp-quality-menu--open\");\n this.btn.setAttribute(\"aria-expanded\", \"false\");\n }\n destroy() {\n document.removeEventListener(\"click\", this.closeHandler);\n this.el.remove();\n }\n};\n\n// src/controls/CastButton.ts\nfunction isChromecastSupported() {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent;\n return /Chrome/.test(ua) && !/Edge|Edg/.test(ua);\n}\nfunction isAirPlaySupported() {\n if (typeof HTMLVideoElement === \"undefined\") return false;\n return typeof HTMLVideoElement.prototype.webkitShowPlaybackTargetPicker === \"function\";\n}\nvar CastButton = class {\n constructor(api, type) {\n this.api = api;\n this.type = type;\n this.supported = type === \"chromecast\" ? isChromecastSupported() : isAirPlaySupported();\n const icon = type === \"chromecast\" ? icons.chromecast : icons.airplay;\n const label = type === \"chromecast\" ? \"Cast\" : \"AirPlay\";\n this.el = createButton(`sp-cast sp-cast--${type}`, label, icon);\n this.el.addEventListener(\"click\", () => this.handleClick());\n if (!this.supported) {\n this.el.style.display = \"none\";\n }\n }\n render() {\n return this.el;\n }\n update() {\n if (!this.supported) {\n this.el.style.display = \"none\";\n return;\n }\n if (this.type === \"chromecast\") {\n const available = this.api.getState(\"chromecastAvailable\");\n const active = this.api.getState(\"chromecastActive\");\n this.el.style.display = \"\";\n this.el.disabled = !available && !active;\n this.el.classList.toggle(\"sp-cast--active\", !!active);\n this.el.classList.toggle(\"sp-cast--unavailable\", !available && !active);\n if (active) {\n setHTML(this.el, icons.chromecastConnected);\n setAttr(this.el, \"aria-label\", \"Stop casting\");\n } else {\n setHTML(this.el, icons.chromecast);\n setAttr(this.el, \"aria-label\", available ? \"Cast\" : \"No Cast devices found\");\n }\n } else {\n const active = this.api.getState(\"airplayActive\");\n this.el.style.display = \"\";\n this.el.disabled = false;\n this.el.classList.toggle(\"sp-cast--active\", !!active);\n this.el.classList.remove(\"sp-cast--unavailable\");\n setAttr(this.el, \"aria-label\", active ? \"Stop AirPlay\" : \"AirPlay\");\n }\n }\n handleClick() {\n if (this.type === \"chromecast\") {\n this.handleChromecast();\n } else {\n this.handleAirPlay();\n }\n }\n handleChromecast() {\n const chromecast = this.api.getPlugin(\"chromecast\");\n if (!chromecast) return;\n if (chromecast.isConnected()) {\n chromecast.endSession();\n } else {\n chromecast.requestSession().catch(() => {\n });\n }\n }\n async handleAirPlay() {\n const airplayPlugin = this.api.getPlugin(\"airplay\");\n if (airplayPlugin) {\n await airplayPlugin.showPicker();\n } else {\n const video = getVideo(this.api.container);\n video?.webkitShowPlaybackTargetPicker?.();\n }\n }\n destroy() {\n this.el.remove();\n }\n};\n\n// src/controls/PipButton.ts\nvar PipButton = class {\n constructor(api) {\n this.clickHandler = () => {\n void this.toggle().catch(() => {\n });\n };\n this.api = api;\n const probe = document.createElement(\"video\");\n this.supported = \"pictureInPictureEnabled\" in document || \"webkitSetPresentationMode\" in probe;\n this.el = createButton(\"sp-pip\", \"Picture-in-Picture\", icons.pip);\n this.el.addEventListener(\"click\", this.clickHandler);\n if (!this.supported) {\n this.el.style.display = \"none\";\n } else {\n this.el.disabled = true;\n this.el.setAttribute(\"aria-disabled\", \"true\");\n }\n }\n render() {\n return this.el;\n }\n /** Whether the media element is ready to enter PiP (metadata loaded). */\n isMediaReady() {\n const video = getVideo(this.api.container);\n return !!video && video.readyState >= HTMLMediaElement.HAVE_METADATA;\n }\n update() {\n if (!this.supported) return;\n const pip = !!this.api.getState(\"pip\");\n const enabled = pip || this.isMediaReady();\n this.el.disabled = !enabled;\n setAttr(this.el, \"aria-disabled\", String(!enabled));\n setHTML(this.el, pip ? icons.exitPip : icons.pip);\n setAttr(this.el, \"aria-label\", pip ? \"Exit Picture-in-Picture\" : \"Picture-in-Picture\");\n this.el.classList.toggle(\"sp-pip--active\", pip);\n }\n async toggle() {\n const video = getVideo(this.api.container);\n if (!video) {\n this.api.logger.warn(\"PiP: video element not found\");\n return;\n }\n const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === \"picture-in-picture\";\n if (!isInPip && video.readyState < HTMLMediaElement.HAVE_METADATA) {\n this.api.logger.debug(\"PiP: ignored, media not ready\", {\n readyState: video.readyState\n });\n return;\n }\n try {\n if (isInPip) {\n if (document.pictureInPictureElement) {\n await document.exitPictureInPicture();\n } else if (video.webkitSetPresentationMode) {\n video.webkitSetPresentationMode(\"inline\");\n }\n this.api.logger.debug(\"PiP: exited\");\n } else {\n if (video.requestPictureInPicture) {\n await video.requestPictureInPicture();\n } else if (video.webkitSetPresentationMode) {\n video.webkitSetPresentationMode(\"picture-in-picture\");\n }\n this.api.logger.debug(\"PiP: entered\");\n }\n } catch (e) {\n const message = e instanceof Error ? e.message : String(e);\n this.api.logger.warn(\"PiP: failed\", { error: message });\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/FullscreenButton.ts\nimport { enterFullscreen, exitFullscreen, isFullscreen } from \"@scarlett-player/core\";\nvar FullscreenButton = class {\n constructor(api) {\n this.clickHandler = () => {\n this.toggle();\n };\n this.api = api;\n this.el = createButton(\"sp-fullscreen\", \"Fullscreen\", icons.fullscreen);\n this.el.addEventListener(\"click\", this.clickHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const fullscreen = this.api.getState(\"fullscreen\");\n if (fullscreen) {\n setHTML(this.el, icons.exitFullscreen);\n setAttr(this.el, \"aria-label\", \"Exit fullscreen\");\n } else {\n setHTML(this.el, icons.fullscreen);\n setAttr(this.el, \"aria-label\", \"Fullscreen\");\n }\n }\n /**\n * Enter or leave fullscreen.\n *\n * The direction comes from the browser rather than from the `fullscreen`\n * state key: state is a report of what happened, and a stale one would invert\n * the button. Rejections are swallowed because the browser refuses these\n * routinely (no user gesture, denied by permission policy) and an unhandled\n * rejection helps nobody.\n */\n async toggle() {\n const container = this.api.container;\n try {\n if (isFullscreen(container)) {\n await exitFullscreen(container);\n } else {\n await enterFullscreen(container);\n }\n } catch {\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/Spacer.ts\nvar Spacer = class {\n constructor() {\n this.el = createElement(\"div\", { className: \"sp-spacer\" });\n }\n render() {\n return this.el;\n }\n update() {\n }\n destroy() {\n this.el.remove();\n }\n};\n\n// src/controls/ErrorOverlay.ts\nfunction getUserMessage(error) {\n if (!error) return \"Something went wrong.\";\n const code = error.code;\n if (code) {\n switch (code) {\n case \"MEDIA_NETWORK_ERROR\":\n return \"Having trouble connecting. Check your internet and try again.\";\n case \"MEDIA_DECODE_ERROR\":\n return \"This video can't be played right now.\";\n case \"SOURCE_LOAD_FAILED\":\n case \"SOURCE_NOT_SUPPORTED\":\n case \"PROVIDER_NOT_FOUND\":\n return \"Unable to load video. Please try again.\";\n case \"PLAYBACK_FAILED\":\n return \"Playback stopped unexpectedly. Please try again.\";\n case \"MEDIA_APPEND_ERROR\":\n return \"Video playback was interrupted. Please try again.\";\n case \"MEDIA_BUFFER_FULL\":\n return \"Your device is low on video memory. Close other apps or tabs and try again.\";\n case \"PLAYLIST_INVALID\":\n return \"The stream is temporarily unavailable. Please try again.\";\n }\n }\n const msg = error.message?.toLowerCase() || \"\";\n if (msg.includes(\"network\") || msg.includes(\"timeout\") || msg.includes(\"fetch\") || msg.includes(\"connection\")) {\n return \"Having trouble connecting. Check your internet and try again.\";\n }\n if (msg.includes(\"manifest\")) {\n return \"Unable to load video. Please try again.\";\n }\n if (msg.includes(\"decode\") || msg.includes(\"media\") || msg.includes(\"format\") || msg.includes(\"codec\")) {\n return \"This video can't be played right now.\";\n }\n if (msg.includes(\"not found\") || msg.includes(\"404\") || msg.includes(\"source\") || msg.includes(\"not supported\")) {\n return \"Video not found.\";\n }\n return \"Something went wrong.\";\n}\nvar ErrorOverlay = class {\n constructor(api) {\n this.visible = false;\n this.lastSource = null;\n this.handleRetry = () => {\n if (this.retryBtn.disabled) return;\n this.retryBtn.disabled = true;\n this.hide();\n const source = this.api.getState(\"source\");\n const src = source?.src || this.lastSource;\n if (src) {\n this.api.emit(\"error:retry\", { src });\n }\n setTimeout(() => {\n this.retryBtn.disabled = false;\n }, 1e3);\n };\n this.handleDismiss = () => {\n this.hide();\n this.api.emit(\"error:dismiss\", void 0);\n };\n this.api = api;\n const overlay = document.createElement(\"div\");\n overlay.className = \"sp-error-overlay\";\n overlay.setAttribute(\"role\", \"alert\");\n overlay.setAttribute(\"aria-live\", \"assertive\");\n const content = document.createElement(\"div\");\n content.className = \"sp-error-overlay__content\";\n const iconEl = document.createElement(\"div\");\n iconEl.className = \"sp-error-overlay__icon\";\n iconEl.innerHTML = icons.error;\n const messageEl = document.createElement(\"p\");\n messageEl.className = \"sp-error-overlay__message\";\n messageEl.textContent = \"Something went wrong.\";\n const actions = document.createElement(\"div\");\n actions.className = \"sp-error-overlay__actions\";\n this.retryBtn = document.createElement(\"button\");\n this.retryBtn.className = \"sp-error-overlay__retry\";\n this.retryBtn.setAttribute(\"type\", \"button\");\n this.retryBtn.setAttribute(\"aria-label\", \"Try again\");\n this.retryBtn.textContent = \"Try Again\";\n this.retryBtn.addEventListener(\"click\", this.handleRetry);\n this.dismissBtn = document.createElement(\"button\");\n this.dismissBtn.className = \"sp-error-overlay__dismiss\";\n this.dismissBtn.setAttribute(\"type\", \"button\");\n this.dismissBtn.setAttribute(\"aria-label\", \"Go back\");\n this.dismissBtn.textContent = \"Go Back\";\n this.dismissBtn.addEventListener(\"click\", this.handleDismiss);\n actions.appendChild(this.retryBtn);\n actions.appendChild(this.dismissBtn);\n content.appendChild(iconEl);\n content.appendChild(messageEl);\n content.appendChild(actions);\n overlay.appendChild(content);\n this.el = overlay;\n }\n render() {\n return this.el;\n }\n /** Show the error overlay with the given error */\n show(error) {\n const message = getUserMessage(error);\n const messageEl = this.el.querySelector(\".sp-error-overlay__message\");\n if (messageEl) {\n messageEl.textContent = message;\n }\n const source = this.api.getState(\"source\");\n if (source?.src) {\n this.lastSource = source.src;\n }\n this.visible = true;\n this.retryBtn.disabled = false;\n this.el.classList.remove(\"sp-error-overlay--reconnecting\");\n this.el.classList.add(\"sp-error-overlay--visible\");\n }\n /**\n * Show the reconnecting state.\n *\n * Displayed while the provider auto-reconnects after a fatal error, so the\n * viewer sees the player working on the problem instead of a dead-end\n * error. Try Again stays available for viewers who want to force an\n * immediate attempt.\n */\n showReconnecting() {\n const messageEl = this.el.querySelector(\".sp-error-overlay__message\");\n if (messageEl) {\n messageEl.textContent = \"Connection lost. Reconnecting...\";\n }\n const source = this.api.getState(\"source\");\n if (source?.src) {\n this.lastSource = source.src;\n }\n this.visible = true;\n this.retryBtn.disabled = false;\n this.el.classList.add(\"sp-error-overlay--reconnecting\");\n this.el.classList.add(\"sp-error-overlay--visible\");\n }\n /** Hide the error overlay */\n hide() {\n this.visible = false;\n this.el.classList.remove(\"sp-error-overlay--visible\");\n this.el.classList.remove(\"sp-error-overlay--reconnecting\");\n }\n isVisible() {\n return this.visible;\n }\n update() {\n const playbackState = this.api.getState(\"playbackState\");\n if (this.visible && playbackState !== \"error\" && playbackState !== \"loading\") {\n const playing = this.api.getState(\"playing\");\n if (playing) {\n this.hide();\n }\n }\n }\n destroy() {\n this.retryBtn.removeEventListener(\"click\", this.handleRetry);\n this.dismissBtn.removeEventListener(\"click\", this.handleDismiss);\n this.el.remove();\n }\n};\n\n// src/controls/SettingsMenu.ts\nvar SPEED_OPTIONS = [\n { label: \"0.5x\", value: 0.5 },\n { label: \"0.75x\", value: 0.75 },\n { label: \"Normal\", value: 1 },\n { label: \"1.25x\", value: 1.25 },\n { label: \"1.5x\", value: 1.5 },\n { label: \"2x\", value: 2 }\n];\nvar SettingsMenu = class {\n constructor(api) {\n this.isOpen = false;\n this.currentPanel = \"main\";\n this.lastQualitiesJson = \"\";\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-settings\" });\n this.btn = createButton(\"sp-settings__btn\", \"Settings\", icons.settings);\n this.btn.setAttribute(\"aria-haspopup\", \"true\");\n this.btn.setAttribute(\"aria-expanded\", \"false\");\n this.btn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n this.toggle();\n });\n this.panel = createElement(\"div\", { className: \"sp-settings-panel\" });\n this.panel.setAttribute(\"role\", \"menu\");\n this.panel.addEventListener(\"click\", (e) => e.stopPropagation());\n this.el.appendChild(this.btn);\n this.el.appendChild(this.panel);\n this.closeHandler = (e) => {\n if (!this.el.contains(e.target)) {\n this.close();\n }\n };\n document.addEventListener(\"click\", this.closeHandler);\n this.keyHandler = (e) => {\n if (!this.isOpen) return;\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n if (this.currentPanel !== \"main\") {\n this.showPanel(\"main\");\n } else {\n this.close();\n this.btn.focus();\n }\n return;\n }\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n e.stopPropagation();\n this.navigateItems(e.key === \"ArrowDown\" ? 1 : -1);\n return;\n }\n if (e.key === \"Tab\") {\n e.preventDefault();\n e.stopPropagation();\n this.navigateItems(e.shiftKey ? -1 : 1);\n }\n };\n document.addEventListener(\"keydown\", this.keyHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const qualities = this.api.getState(\"qualities\") || [];\n const qualitiesJson = JSON.stringify(qualities.map((q) => q.id));\n if (qualitiesJson !== this.lastQualitiesJson) {\n this.lastQualitiesJson = qualitiesJson;\n if (this.isOpen && this.currentPanel === \"quality\") {\n this.renderQualityPanel();\n }\n }\n if (this.isOpen) {\n if (this.currentPanel === \"quality\") {\n this.updateQualityActiveStates();\n } else if (this.currentPanel === \"speed\") {\n this.updateSpeedActiveStates();\n } else if (this.currentPanel === \"captions\") {\n this.updateCaptionsActiveStates();\n }\n }\n }\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n open() {\n this.isOpen = true;\n this.currentPanel = \"main\";\n this.renderMainPanel();\n this.panel.classList.add(\"sp-settings-panel--open\");\n this.btn.setAttribute(\"aria-expanded\", \"true\");\n this.focusFirstItem();\n }\n close() {\n this.isOpen = false;\n this.currentPanel = \"main\";\n this.panel.classList.remove(\"sp-settings-panel--open\");\n this.btn.setAttribute(\"aria-expanded\", \"false\");\n }\n showPanel(panel) {\n this.currentPanel = panel;\n switch (panel) {\n case \"main\":\n this.renderMainPanel();\n break;\n case \"quality\":\n this.renderQualityPanel();\n break;\n case \"speed\":\n this.renderSpeedPanel();\n break;\n case \"captions\":\n this.renderCaptionsPanel();\n break;\n }\n this.focusFirstItem();\n }\n renderMainPanel() {\n this.panel.innerHTML = \"\";\n this.panel.className = \"sp-settings-panel sp-settings-panel--open sp-settings-panel--main\";\n const qualities = this.api.getState(\"qualities\") || [];\n const currentQuality = this.api.getState(\"currentQuality\");\n const playbackRate = this.api.getState(\"playbackRate\") ?? 1;\n if (qualities.length > 0) {\n const qualityRow = this.createMainRow(\n \"Quality\",\n currentQuality?.label || \"Auto\",\n () => this.showPanel(\"quality\")\n );\n this.panel.appendChild(qualityRow);\n }\n const textTracks = this.api.getState(\"textTracks\") || [];\n if (textTracks.length > 0) {\n const currentTextTrack = this.api.getState(\"currentTextTrack\");\n const captionsLabel = currentTextTrack ? currentTextTrack.label : \"Off\";\n const captionsRow = this.createMainRow(\n \"Captions\",\n captionsLabel,\n () => this.showPanel(\"captions\")\n );\n this.panel.appendChild(captionsRow);\n }\n const speedLabel = playbackRate === 1 ? \"Normal\" : `${playbackRate}x`;\n const speedRow = this.createMainRow(\n \"Speed\",\n speedLabel,\n () => this.showPanel(\"speed\")\n );\n this.panel.appendChild(speedRow);\n }\n createMainRow(label, value, onClick2) {\n const row = createElement(\"div\", { className: \"sp-settings-panel__row\" });\n row.setAttribute(\"role\", \"menuitem\");\n row.setAttribute(\"tabindex\", \"0\");\n row.setAttribute(\"aria-haspopup\", \"true\");\n const labelEl = createElement(\"span\", { className: \"sp-settings-panel__label\" });\n labelEl.textContent = label;\n const rightSide = createElement(\"span\", { className: \"sp-settings-panel__value\" });\n rightSide.textContent = value;\n const arrow = createElement(\"span\", { className: \"sp-settings-panel__arrow\" });\n arrow.innerHTML = icons.chevronDown;\n rightSide.appendChild(arrow);\n row.appendChild(labelEl);\n row.appendChild(rightSide);\n row.addEventListener(\"click\", (e) => {\n e.preventDefault();\n onClick2();\n });\n row.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onClick2();\n }\n });\n return row;\n }\n renderQualityPanel() {\n this.panel.innerHTML = \"\";\n this.panel.className = \"sp-settings-panel sp-settings-panel--open sp-settings-panel--sub\";\n const header = this.createSubHeader(\"Quality\");\n this.panel.appendChild(header);\n const qualities = this.api.getState(\"qualities\") || [];\n const currentQuality = this.api.getState(\"currentQuality\");\n const activeId = currentQuality?.id || \"auto\";\n const autoItem = this.createMenuItem(\"Auto\", \"auto\", activeId === \"auto\");\n autoItem.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.selectQuality(\"auto\");\n });\n this.panel.appendChild(autoItem);\n const sorted = [...qualities].sort(\n (a, b) => b.height - a.height\n );\n for (const q of sorted) {\n if (q.id === \"auto\") continue;\n const item = this.createMenuItem(q.label, q.id, q.id === activeId);\n item.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.selectQuality(q.id);\n });\n this.panel.appendChild(item);\n }\n }\n renderSpeedPanel() {\n this.panel.innerHTML = \"\";\n this.panel.className = \"sp-settings-panel sp-settings-panel--open sp-settings-panel--sub\";\n const header = this.createSubHeader(\"Speed\");\n this.panel.appendChild(header);\n const currentRate = this.api.getState(\"playbackRate\") ?? 1;\n for (const opt of SPEED_OPTIONS) {\n const isActive = Math.abs(currentRate - opt.value) < 0.01;\n const item = this.createMenuItem(opt.label, String(opt.value), isActive);\n item.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.selectSpeed(opt.value);\n });\n this.panel.appendChild(item);\n }\n }\n renderCaptionsPanel() {\n this.panel.innerHTML = \"\";\n this.panel.className = \"sp-settings-panel sp-settings-panel--open sp-settings-panel--sub\";\n const header = this.createSubHeader(\"Captions\");\n this.panel.appendChild(header);\n const textTracks = this.api.getState(\"textTracks\") || [];\n const currentTextTrack = this.api.getState(\"currentTextTrack\");\n const activeId = currentTextTrack?.id || \"off\";\n const offItem = this.createMenuItem(\"Off\", \"off\", activeId === \"off\");\n offItem.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.selectCaption(null);\n });\n this.panel.appendChild(offItem);\n for (const track of textTracks) {\n const item = this.createMenuItem(track.label, track.id, track.id === activeId);\n item.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.selectCaption(track.id);\n });\n this.panel.appendChild(item);\n }\n }\n selectCaption(trackId) {\n this.api.emit(\"track:text\", { trackId });\n this.close();\n }\n updateCaptionsActiveStates() {\n const currentTextTrack = this.api.getState(\"currentTextTrack\");\n const activeId = currentTextTrack?.id || \"off\";\n const items = this.panel.querySelectorAll(\".sp-settings-panel__item\");\n items.forEach((item) => {\n const id = item.getAttribute(\"data-id\");\n item.classList.toggle(\"sp-settings-panel__item--active\", id === activeId);\n });\n }\n createSubHeader(title) {\n const header = createElement(\"div\", { className: \"sp-settings-panel__header\" });\n header.setAttribute(\"role\", \"menuitem\");\n header.setAttribute(\"tabindex\", \"0\");\n const backArrow = createElement(\"span\", { className: \"sp-settings-panel__back\" });\n backArrow.innerHTML = icons.chevronUp;\n const label = createElement(\"span\", { className: \"sp-settings-panel__header-label\" });\n label.textContent = title;\n header.appendChild(backArrow);\n header.appendChild(label);\n header.addEventListener(\"click\", (e) => {\n e.preventDefault();\n this.showPanel(\"main\");\n });\n header.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n this.showPanel(\"main\");\n }\n });\n return header;\n }\n createMenuItem(label, dataId, isActive) {\n const item = createElement(\"div\", {\n className: `sp-settings-panel__item${isActive ? \" sp-settings-panel__item--active\" : \"\"}`\n });\n item.setAttribute(\"role\", \"menuitem\");\n item.setAttribute(\"tabindex\", \"0\");\n item.setAttribute(\"data-id\", dataId);\n const labelEl = createElement(\"span\");\n labelEl.textContent = label;\n const check = createElement(\"span\", { className: \"sp-settings-panel__check\" });\n check.innerHTML = icons.checkmark;\n item.appendChild(labelEl);\n item.appendChild(check);\n item.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n item.click();\n }\n });\n return item;\n }\n selectQuality(qualityId) {\n this.api.emit(\"quality:select\", {\n quality: qualityId,\n auto: qualityId === \"auto\"\n });\n this.close();\n }\n selectSpeed(rate) {\n this.api.emit(\"playback:ratechange\", { rate });\n const video = this.api.container.querySelector(\"video\");\n if (video) {\n video.playbackRate = rate;\n }\n this.close();\n }\n updateQualityActiveStates() {\n const currentQuality = this.api.getState(\"currentQuality\");\n const activeId = currentQuality?.id || \"auto\";\n const items = this.panel.querySelectorAll(\".sp-settings-panel__item\");\n items.forEach((item) => {\n const id = item.getAttribute(\"data-id\");\n item.classList.toggle(\"sp-settings-panel__item--active\", id === activeId);\n });\n }\n updateSpeedActiveStates() {\n const currentRate = this.api.getState(\"playbackRate\") ?? 1;\n const items = this.panel.querySelectorAll(\".sp-settings-panel__item\");\n items.forEach((item) => {\n const id = item.getAttribute(\"data-id\");\n const value = parseFloat(id || \"1\");\n item.classList.toggle(\n \"sp-settings-panel__item--active\",\n Math.abs(currentRate - value) < 0.01\n );\n });\n }\n getFocusableItems() {\n return Array.from(\n this.panel.querySelectorAll('[role=\"menuitem\"]')\n );\n }\n focusFirstItem() {\n requestAnimationFrame(() => {\n const items = this.getFocusableItems();\n if (items.length > 0) {\n items[0].focus();\n }\n });\n }\n navigateItems(direction) {\n const items = this.getFocusableItems();\n if (items.length === 0) return;\n const active = document.activeElement;\n const currentIndex = items.indexOf(active);\n let nextIndex;\n if (currentIndex === -1) {\n nextIndex = direction === 1 ? 0 : items.length - 1;\n } else {\n nextIndex = (currentIndex + direction + items.length) % items.length;\n }\n items[nextIndex].focus();\n }\n getPanel() {\n return this.currentPanel;\n }\n isMenuOpen() {\n return this.isOpen;\n }\n destroy() {\n document.removeEventListener(\"click\", this.closeHandler);\n document.removeEventListener(\"keydown\", this.keyHandler);\n this.el.remove();\n }\n};\n\n// src/controls/SkipButton.ts\nvar DEFAULT_SKIP_SECONDS = 10;\nvar SkipButton = class {\n constructor(api, direction, seconds = DEFAULT_SKIP_SECONDS) {\n this.clickHandler = () => {\n this.skip();\n };\n this.api = api;\n this.direction = direction;\n this.seconds = seconds;\n const icon = direction === \"backward\" ? icons.replay10 : icons.forward10;\n const label = direction === \"backward\" ? `Rewind ${seconds} seconds` : `Forward ${seconds} seconds`;\n this.el = createButton(\n `sp-skip sp-skip--${direction}`,\n label,\n icon\n );\n this.el.addEventListener(\"click\", this.clickHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const live = this.api.getState(\"live\");\n const duration = this.api.getState(\"duration\") ?? 0;\n const seekableRange = this.api.getState(\"seekableRange\");\n if (live && !seekableRange) {\n this.el.style.display = \"none\";\n return;\n }\n if (live && seekableRange) {\n this.el.style.display = \"\";\n return;\n }\n if (duration === 0) {\n this.el.style.display = \"none\";\n return;\n }\n this.el.style.display = \"\";\n }\n skip() {\n const video = getVideo(this.api.container);\n if (!video) return;\n const live = this.api.getState(\"live\");\n const seekableRange = this.api.getState(\"seekableRange\");\n if (live && seekableRange) {\n if (this.direction === \"backward\") {\n video.currentTime = Math.max(seekableRange.start, video.currentTime - this.seconds);\n } else {\n video.currentTime = Math.min(seekableRange.end, video.currentTime + this.seconds);\n }\n return;\n }\n const duration = video.duration || 0;\n if (!duration || !isFinite(duration)) return;\n if (this.direction === \"backward\") {\n video.currentTime = Math.max(0, video.currentTime - this.seconds);\n } else {\n video.currentTime = Math.min(duration, video.currentTime + this.seconds);\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/CaptionsButton.ts\nvar CaptionsButton = class {\n constructor(api) {\n this.clickHandler = () => {\n this.toggle();\n };\n this.api = api;\n this.el = createButton(\"sp-captions\", \"Captions\", icons.captionsOff);\n this.el.addEventListener(\"click\", this.clickHandler);\n }\n render() {\n return this.el;\n }\n update() {\n const textTracks = this.api.getState(\"textTracks\") || [];\n const currentTrack = this.api.getState(\"currentTextTrack\");\n if (textTracks.length === 0) {\n this.el.style.display = \"none\";\n return;\n }\n this.el.style.display = \"\";\n if (currentTrack) {\n setHTML(this.el, icons.captions);\n setAttr(this.el, \"aria-label\", `Captions: ${currentTrack.label}`);\n this.el.classList.add(\"sp-captions--active\");\n } else {\n setHTML(this.el, icons.captionsOff);\n setAttr(this.el, \"aria-label\", \"Captions\");\n this.el.classList.remove(\"sp-captions--active\");\n }\n }\n toggle() {\n const textTracks = this.api.getState(\"textTracks\") || [];\n const currentTrack = this.api.getState(\"currentTextTrack\");\n if (textTracks.length === 0) return;\n if (currentTrack) {\n this.api.emit(\"track:text\", { trackId: null });\n } else {\n this.api.emit(\"track:text\", { trackId: textTracks[0].id });\n }\n }\n destroy() {\n this.el.removeEventListener(\"click\", this.clickHandler);\n this.el.remove();\n }\n};\n\n// src/controls/BandwidthIndicator.ts\nvar ICON_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12.55a11 11 0 0 1 14.08 0\"/><path d=\"M1.42 9a16 16 0 0 1 21.16 0\"/><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"/><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"/><line x1=\"2\" y1=\"2\" x2=\"22\" y2=\"22\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>`;\nvar BandwidthIndicator = class {\n constructor(api) {\n this.api = api;\n this.el = createElement(\"div\", { className: \"sp-bandwidth-indicator\" });\n this.el.innerHTML = ICON_SVG;\n this.el.setAttribute(\"aria-label\", \"Bandwidth is limiting video quality\");\n this.el.setAttribute(\"title\", \"Bandwidth is limiting video quality\");\n this.el.style.display = \"none\";\n }\n render() {\n return this.el;\n }\n update() {\n const bandwidth = this.api.getState(\"bandwidth\");\n const qualities = this.api.getState(\"qualities\");\n if (!bandwidth || !qualities || qualities.length === 0) {\n this.el.style.display = \"none\";\n return;\n }\n const highestBitrate = Math.max(...qualities.map((q) => q.bitrate));\n if (highestBitrate > 0 && bandwidth < highestBitrate) {\n this.el.style.display = \"\";\n } else {\n this.el.style.display = \"none\";\n }\n }\n destroy() {\n this.el.remove();\n }\n};\n\n// src/controls/OverflowTray.ts\nvar OverflowTray = class {\n /**\n * @param api - Plugin API, kept for parity with the other controls and for\n * logging; the tray itself reads no state\n */\n constructor(api) {\n this.api = api;\n this.isOpen = false;\n this.toggleHandler = () => {\n this.isOpen ? this.close() : this.open();\n };\n this.el = createElement(\"div\", { className: \"sp-overflow\" });\n this.btn = createButton(\"sp-overflow__btn\", \"More controls\", icons.more);\n this.btn.setAttribute(\"aria-haspopup\", \"true\");\n this.btn.setAttribute(\"aria-expanded\", \"false\");\n this.btn.addEventListener(\"click\", this.toggleHandler);\n this.panel = createElement(\"div\", {\n className: \"sp-overflow-tray\",\n role: \"group\",\n \"aria-label\": \"More controls\"\n });\n this.el.appendChild(this.btn);\n this.el.appendChild(this.panel);\n this.el.style.display = \"none\";\n this.closeHandler = (e) => {\n if (!this.el.contains(e.target)) {\n this.close();\n }\n };\n document.addEventListener(\"click\", this.closeHandler);\n this.keyHandler = (e) => {\n if (!this.isOpen || e.key !== \"Escape\") return;\n e.preventDefault();\n e.stopPropagation();\n this.close();\n this.btn.focus();\n };\n document.addEventListener(\"keydown\", this.keyHandler);\n }\n /**\n * The bar item to place in the control bar.\n *\n * @returns The wrapper holding the button and the strip\n */\n render() {\n return this.el;\n }\n /**\n * No state of its own: the fit loop owns what is inside it.\n */\n update() {\n }\n /**\n * Move a control's element into the tray.\n *\n * The element is moved as-is, so its class, icon, aria-label, event handlers\n * and `update()` all keep working.\n *\n * @param el - The control element leaving the bar\n */\n adopt(el) {\n this.panel.appendChild(el);\n this.syncVisibility();\n }\n /**\n * Take a control's element back out of the tray.\n *\n * The caller decides where in the bar it goes; this only detaches it and\n * updates the button's visibility.\n *\n * @param el - The control element returning to the bar\n * @returns The same element, detached\n */\n release(el) {\n if (el.parentNode === this.panel) {\n this.panel.removeChild(el);\n }\n this.syncVisibility();\n return el;\n }\n /**\n * Whether an element is currently held by the tray.\n *\n * @param el - Element to test\n * @returns True when the tray is its parent\n */\n holds(el) {\n return el.parentNode === this.panel;\n }\n /**\n * Open the strip.\n */\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.panel.classList.add(\"sp-overflow-tray--open\");\n this.btn.setAttribute(\"aria-expanded\", \"true\");\n }\n /**\n * Close the strip.\n *\n * Deliberately not called after a control inside it is used: skip, PiP and\n * cast are things a viewer taps more than once in a row.\n */\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.panel.classList.remove(\"sp-overflow-tray--open\");\n this.btn.setAttribute(\"aria-expanded\", \"false\");\n }\n /**\n * Show the button only while the tray holds something the viewer can see.\n *\n * A control that hid itself (no cast device on the network, no text tracks)\n * can be sitting in the tray with `display: none`, and a button that opens an\n * empty strip is worse than no button at all.\n */\n syncVisibility() {\n const usable = Array.from(this.panel.children).some(\n (child) => child.style.display !== \"none\"\n );\n this.el.style.display = usable ? \"\" : \"none\";\n if (!usable && this.isOpen) {\n this.close();\n }\n }\n /**\n * Re-check the button's visibility after the controls have updated\n * themselves.\n */\n refresh() {\n this.syncVisibility();\n }\n /**\n * Remove the document listeners and the tray itself.\n *\n * Adopted elements are left where they are: they belong to their own\n * controls, which are destroyed by the plugin alongside this one.\n */\n destroy() {\n document.removeEventListener(\"click\", this.closeHandler);\n document.removeEventListener(\"keydown\", this.keyHandler);\n this.btn.removeEventListener(\"click\", this.toggleHandler);\n this.el.remove();\n this.api.logger.debug(\"Overflow tray destroyed\");\n }\n};\n\n// src/control-registry.ts\nvar registry = /* @__PURE__ */ new Map();\nvar listeners = /* @__PURE__ */ new Set();\nfunction registerControl(id, factory) {\n registry.set(id, factory);\n for (const listener of listeners) {\n listener(id);\n }\n}\nfunction unregisterControl(id) {\n return registry.delete(id);\n}\nfunction getControlFactory(id) {\n return registry.get(id) ?? null;\n}\nfunction onControlRegistered(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\nfunction resetControlRegistry() {\n registry.clear();\n listeners.clear();\n}\n\n// src/version.ts\nvar PKG_VERSION = true ? \"1.7.1\" : \"0.0.0-dev\";\n\n// src/index.ts\nvar DEFAULT_LAYOUT = [\n \"play\",\n \"skip-backward\",\n \"skip-forward\",\n \"volume\",\n \"time\",\n \"live-indicator\",\n \"bandwidth-indicator\",\n \"spacer\",\n \"settings\",\n \"captions\",\n \"chromecast\",\n \"airplay\",\n \"pip\",\n \"fullscreen\"\n];\nvar DEFAULT_HIDE_DELAY = 3e3;\nvar UNMEASURED_CONTROL_WIDTH = 48;\nvar FALLBACK_VOLUME_SLIDER_WIDTH = 64;\nvar OVERFLOW_BUTTON_WIDTH = 44;\nvar FALLBACK_BAR_PADDING_X = 24;\nvar FALLBACK_BAR_GAP = 4;\nvar MENU_HEIGHT_RESERVE = 16;\nvar FALLBACK_BAR_HEIGHT = 56;\nvar MIN_MENU_HEIGHT = 120;\nfunction uiPlugin(config = {}) {\n let api;\n let controlBar = null;\n let gradient = null;\n let progressBar = null;\n let bufferingIndicator = null;\n let errorOverlay = null;\n let bigPlayButton = null;\n let styleEl = null;\n let controls = [];\n let hideTimeout = null;\n let stateUnsubscribe = null;\n let controlRegistryUnsubscribe = null;\n let errorUnsubscribe = null;\n let reconnectingUnsubscribe = null;\n let recoveredUnsubscribe = null;\n let controlsVisible = true;\n let rafHandle = null;\n let tray = null;\n let entries = [];\n let timeEntry = null;\n let resizeObserver = null;\n let barPaddingX = FALLBACK_BAR_PADDING_X;\n let barGap = FALLBACK_BAR_GAP;\n let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;\n let lastFitSignature = null;\n let fitPending = true;\n const layout = config.controls || DEFAULT_LAYOUT;\n const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;\n const showBigPlayButton = config.bigPlayButton !== false;\n const responsive = config.responsive !== false;\n if (responsive) {\n assertFitLayout(layout, config.priority);\n }\n const createControl = (slot) => {\n switch (slot) {\n case \"play\":\n return new PlayButton(api);\n case \"skip-backward\":\n return new SkipButton(api, \"backward\");\n case \"skip-forward\":\n return new SkipButton(api, \"forward\");\n case \"volume\":\n return new VolumeControl(api);\n case \"progress\":\n return null;\n case \"time\":\n return new TimeDisplay(api);\n case \"live-indicator\":\n return new LiveIndicator(api);\n case \"bandwidth-indicator\":\n return new BandwidthIndicator(api);\n case \"quality\":\n return new QualityMenu(api);\n case \"settings\":\n return new SettingsMenu(api);\n case \"captions\":\n return new CaptionsButton(api);\n case \"chromecast\":\n return new CastButton(api, \"chromecast\");\n case \"airplay\":\n return new CastButton(api, \"airplay\");\n case \"pip\":\n return new PipButton(api);\n case \"fullscreen\":\n return new FullscreenButton(api);\n case \"spacer\":\n return new Spacer();\n default: {\n const factory = getControlFactory(slot);\n if (factory) {\n try {\n return factory(api);\n } catch (error) {\n api.logger.error(`Control factory for \"${slot}\" threw`, { error });\n return null;\n }\n }\n api.logger.warn(`Unknown control slot: ${slot}`);\n return null;\n }\n }\n };\n const populateControlBar = () => {\n if (!controlBar) {\n return;\n }\n const rules = new Map(\n resolveFitItems(layout, config.priority).map((template) => [template.id, template])\n );\n for (const slot of layout) {\n const control = createControl(slot);\n if (!control) {\n continue;\n }\n controls.push(control);\n const el = control.render();\n controlBar.appendChild(el);\n const rule = rules.get(slot);\n const entry = {\n slot,\n control,\n el,\n rank: rule?.rank ?? \"never\",\n exit: rule?.exit ?? \"overflow\",\n width: -1\n };\n entries.push(entry);\n if (slot === \"time\") {\n timeEntry = entry;\n }\n }\n if (responsive) {\n tray = new OverflowTray(api);\n controls.push(tray);\n controlBar.appendChild(tray.render());\n placeTrayButton();\n }\n };\n const placeTrayButton = () => {\n if (!controlBar || !tray) {\n return;\n }\n const trayEl = tray.render();\n const fullscreen = entries.find((entry) => entry.slot === \"fullscreen\");\n const before = fullscreen && fullscreen.el.parentNode === controlBar ? fullscreen.el : null;\n if (before) {\n if (trayEl.nextSibling !== before) {\n controlBar.insertBefore(trayEl, before);\n }\n return;\n }\n if (controlBar.lastChild !== trayEl) {\n controlBar.appendChild(trayEl);\n }\n };\n const visibilitySignature = () => {\n let flags = \"\";\n for (const entry of entries) {\n flags += entry.el.style.display === \"none\" ? \"0\" : \"1\";\n }\n return `${flags}:${timeEntry?.el.textContent?.length ?? 0}`;\n };\n const applyFit = (plan) => {\n if (!controlBar || !tray) {\n return;\n }\n const overflow = new Set(plan.overflow);\n const hidden = new Set(plan.hidden);\n for (const entry of entries) {\n if (entry.slot === \"spacer\") {\n continue;\n }\n if (hidden.has(entry.slot)) {\n if (tray.holds(entry.el)) {\n returnToBar(entry);\n }\n entry.el.classList.add(\"sp-control--collapsed\");\n continue;\n }\n entry.el.classList.remove(\"sp-control--collapsed\");\n if (overflow.has(entry.slot)) {\n if (!tray.holds(entry.el)) {\n tray.adopt(entry.el);\n }\n } else if (tray.holds(entry.el)) {\n returnToBar(entry);\n }\n }\n tray.refresh();\n placeTrayButton();\n };\n const returnToBar = (entry) => {\n if (!controlBar || !tray) {\n return;\n }\n const el = tray.release(entry.el);\n const trayEl = tray.render();\n let before = trayEl.parentNode === controlBar ? trayEl : null;\n for (let i = entries.indexOf(entry) + 1; i < entries.length; i++) {\n if (entries[i].el.parentNode === controlBar) {\n before = entries[i].el;\n break;\n }\n }\n controlBar.insertBefore(el, before);\n };\n const expandedWidth = (entry) => {\n if (entry.slot !== \"volume\") {\n return 0;\n }\n const wrap = entry.el.querySelector(\".sp-volume__slider-wrap\");\n return wrap ? wrap.getBoundingClientRect().width : 0;\n };\n const interactionReserve = (entry) => entry.slot === \"volume\" ? volumeSliderWidth : 0;\n const readBarMetrics = (bar) => {\n const barStyle = getComputedStyle(bar);\n const paddingLeft = parseFloat(barStyle.paddingLeft);\n const paddingRight = parseFloat(barStyle.paddingRight);\n const gap = parseFloat(barStyle.columnGap || barStyle.gap);\n const sliderWidth = parseFloat(\n barStyle.getPropertyValue(\"--sp-volume-slider-width\")\n );\n barPaddingX = Number.isFinite(paddingLeft) && Number.isFinite(paddingRight) ? paddingLeft + paddingRight : FALLBACK_BAR_PADDING_X;\n barGap = Number.isFinite(gap) ? gap : FALLBACK_BAR_GAP;\n volumeSliderWidth = Number.isFinite(sliderWidth) ? sliderWidth : FALLBACK_VOLUME_SLIDER_WIDTH;\n };\n const fitControls = () => {\n if (!responsive || !controlBar || !tray) {\n return;\n }\n if (controlBar.clientWidth === 0) {\n return;\n }\n readBarMetrics(controlBar);\n const spacerGaps = entries.filter(\n (entry) => entry.slot === \"spacer\" && entry.el.style.display !== \"none\"\n ).length;\n const available = controlBar.clientWidth - barPaddingX - spacerGaps * barGap;\n const items = [];\n for (const entry of entries) {\n if (entry.slot === \"spacer\") {\n continue;\n }\n const visible = entry.el.style.display !== \"none\";\n const measurable = visible && entry.el.parentNode === controlBar && !entry.el.classList.contains(\"sp-control--collapsed\");\n if (measurable) {\n entry.width = entry.el.getBoundingClientRect().width - expandedWidth(entry);\n } else if (entry.width < 0) {\n entry.width = UNMEASURED_CONTROL_WIDTH;\n }\n items.push({\n id: entry.slot,\n rank: entry.rank,\n exit: entry.exit,\n width: entry.width + interactionReserve(entry),\n visible\n });\n }\n const trayEl = tray.render();\n const trayWidth = trayEl.style.display === \"none\" ? 0 : trayEl.getBoundingClientRect().width;\n applyFit(planFit(items, available, barGap, trayWidth || OVERFLOW_BUTTON_WIDTH));\n lastFitSignature = visibilitySignature();\n fitPending = false;\n };\n const maybeFit = () => {\n if (!responsive) {\n return;\n }\n if (!fitPending && visibilitySignature() === lastFitSignature) {\n return;\n }\n fitControls();\n };\n const applyMenuBounds = (height) => {\n const barHeight = controlBar?.offsetHeight || FALLBACK_BAR_HEIGHT;\n api?.container?.style.setProperty(\n \"--sp-menu-max-height\",\n `${Math.max(MIN_MENU_HEIGHT, Math.round(height) - barHeight - MENU_HEIGHT_RESERVE)}px`\n );\n };\n const rebuildControlBar = () => {\n if (!controlBar) {\n return;\n }\n controls.forEach((c) => c.destroy());\n controls = [];\n entries = [];\n timeEntry = null;\n tray = null;\n controlBar.replaceChildren();\n lastFitSignature = null;\n fitPending = true;\n populateControlBar();\n updateControls();\n };\n const updateControls = () => {\n controls.forEach((c) => c.update());\n progressBar?.update();\n const waiting = api?.getState(\"waiting\");\n const seeking = api?.getState(\"seeking\");\n const playbackState = api?.getState(\"playbackState\");\n const isLoading = playbackState === \"loading\";\n const showSpinner = waiting || seeking && !api?.getState(\"paused\") || isLoading;\n bufferingIndicator?.classList.toggle(\"sp-buffering--visible\", !!showSpinner);\n errorOverlay?.update();\n bigPlayButton?.update();\n maybeFit();\n };\n const scheduleUpdate = () => {\n if (rafHandle !== null) return;\n rafHandle = requestAnimationFrame(() => {\n rafHandle = null;\n updateControls();\n });\n };\n const showControls = () => {\n if (controlsVisible) {\n resetHideTimer();\n return;\n }\n controlsVisible = true;\n controlBar?.classList.add(\"sp-controls--visible\");\n controlBar?.classList.remove(\"sp-controls--hidden\");\n gradient?.classList.add(\"sp-gradient--visible\");\n progressBar?.show();\n api?.setState(\"controlsVisible\", true);\n resetHideTimer();\n };\n const hideControls = () => {\n const paused = api?.getState(\"paused\");\n if (paused) return;\n controlsVisible = false;\n controlBar?.classList.remove(\"sp-controls--visible\");\n controlBar?.classList.add(\"sp-controls--hidden\");\n gradient?.classList.remove(\"sp-gradient--visible\");\n progressBar?.hide();\n api?.setState(\"controlsVisible\", false);\n };\n const resetHideTimer = () => {\n if (hideTimeout) {\n clearTimeout(hideTimeout);\n }\n hideTimeout = setTimeout(hideControls, hideDelay);\n };\n let last_pointer_type = null;\n const handlePointerActivity = (event) => {\n last_pointer_type = event.pointerType;\n };\n const handleInteraction = () => {\n if (last_pointer_type === \"touch\") {\n const gestures = api?.getPlugin(\"gestures\");\n if (gestures?.ownsTapInteraction()) return;\n }\n showControls();\n };\n const handleMouseLeave = () => {\n hideControls();\n };\n const handleKeyDown = (e) => {\n if (!api.container.contains(document.activeElement)) return;\n const activeEl = document.activeElement;\n if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {\n return;\n }\n const video = api.container.querySelector(\"video\");\n if (!video) return;\n const live = api.getState(\"live\");\n const seekableRange = api.getState(\"seekableRange\");\n switch (e.key) {\n case \" \":\n case \"k\":\n e.preventDefault();\n if (video.paused) {\n video.play().catch(() => {\n });\n } else {\n video.pause();\n }\n break;\n case \"m\":\n e.preventDefault();\n video.muted = !video.muted;\n break;\n case \"f\":\n e.preventDefault();\n if (isFullscreen2(api.container)) {\n exitFullscreen2(api.container).catch(() => {\n });\n } else {\n enterFullscreen2(api.container).catch(() => {\n });\n }\n break;\n case \"ArrowLeft\":\n e.preventDefault();\n if (live && seekableRange) {\n video.currentTime = Math.max(seekableRange.start, video.currentTime - 5);\n } else {\n video.currentTime = Math.max(0, video.currentTime - 5);\n }\n showControls();\n break;\n case \"ArrowRight\":\n e.preventDefault();\n if (live && seekableRange) {\n video.currentTime = Math.min(seekableRange.end, video.currentTime + 5);\n } else {\n video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);\n }\n showControls();\n break;\n case \"ArrowUp\":\n e.preventDefault();\n video.volume = Math.min(1, video.volume + 0.1);\n showControls();\n break;\n case \"ArrowDown\":\n e.preventDefault();\n video.volume = Math.max(0, video.volume - 0.1);\n showControls();\n break;\n }\n };\n return {\n id: \"ui-controls\",\n name: \"UI Controls\",\n type: \"ui\",\n version: PKG_VERSION,\n async init(pluginApi) {\n api = pluginApi;\n styleEl = document.createElement(\"style\");\n styleEl.textContent = styles;\n document.head.appendChild(styleEl);\n if (config.theme) {\n this.setTheme(config.theme);\n }\n const container = api.container;\n if (!container) {\n api.logger.error(\"UI plugin: container not found\");\n return;\n }\n const containerStyle = getComputedStyle(container);\n if (containerStyle.position === \"static\") {\n container.style.position = \"relative\";\n }\n const isPlaying = api.getState(\"playing\");\n gradient = document.createElement(\"div\");\n gradient.className = isPlaying ? \"sp-gradient\" : \"sp-gradient sp-gradient--visible\";\n container.appendChild(gradient);\n bufferingIndicator = document.createElement(\"div\");\n bufferingIndicator.className = \"sp-buffering\";\n bufferingIndicator.innerHTML = icons.spinner;\n bufferingIndicator.setAttribute(\"aria-hidden\", \"true\");\n container.appendChild(bufferingIndicator);\n errorOverlay = new ErrorOverlay(api);\n container.appendChild(errorOverlay.render());\n errorUnsubscribe = api.on(\"error\", (payload) => {\n if (payload?.fatal) {\n const error = api.getState(\"error\") || payload;\n errorOverlay?.show(error);\n }\n });\n reconnectingUnsubscribe = api.on(\"error:reconnecting\", () => {\n errorOverlay?.showReconnecting();\n });\n recoveredUnsubscribe = api.on(\"error:recovered\", () => {\n errorOverlay?.hide();\n });\n if (showBigPlayButton) {\n bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);\n container.appendChild(bigPlayButton.render());\n }\n progressBar = new ProgressBar(api);\n container.appendChild(progressBar.render());\n if (!isPlaying) {\n progressBar.show();\n }\n controlBar = document.createElement(\"div\");\n controlBar.className = isPlaying ? \"sp-controls sp-controls--hidden\" : \"sp-controls sp-controls--visible\";\n controlBar.setAttribute(\"role\", \"toolbar\");\n controlBar.setAttribute(\"aria-label\", \"Video controls\");\n populateControlBar();\n container.appendChild(controlBar);\n if (responsive && typeof ResizeObserver === \"function\") {\n resizeObserver = new ResizeObserver((observed) => {\n applyMenuBounds(observed[0]?.contentRect.height ?? container.clientHeight);\n fitPending = true;\n scheduleUpdate();\n });\n resizeObserver.observe(container);\n }\n controlRegistryUnsubscribe = onControlRegistered((id) => {\n if (!layout.includes(id)) {\n return;\n }\n api.logger.debug(`Control \"${id}\" registered after init, rebuilding control bar`);\n rebuildControlBar();\n });\n container.addEventListener(\"pointerdown\", handlePointerActivity, { passive: true });\n container.addEventListener(\"pointermove\", handlePointerActivity, { passive: true });\n container.addEventListener(\"mousemove\", handleInteraction);\n container.addEventListener(\"mouseenter\", handleInteraction);\n container.addEventListener(\"mouseleave\", handleMouseLeave);\n container.addEventListener(\"touchstart\", handleInteraction, { passive: true });\n container.addEventListener(\"click\", handleInteraction);\n document.addEventListener(\"keydown\", handleKeyDown);\n stateUnsubscribe = api.subscribeToState(scheduleUpdate);\n updateControls();\n if (!container.hasAttribute(\"tabindex\")) {\n container.setAttribute(\"tabindex\", \"0\");\n }\n controlsVisible = !isPlaying;\n api.setState(\"controlsVisible\", controlsVisible);\n if (isPlaying) {\n resetHideTimer();\n }\n api.logger.debug(\"UI controls plugin initialized\");\n },\n async destroy() {\n if (hideTimeout) {\n clearTimeout(hideTimeout);\n hideTimeout = null;\n }\n if (rafHandle !== null) {\n cancelAnimationFrame(rafHandle);\n rafHandle = null;\n }\n resizeObserver?.disconnect();\n resizeObserver = null;\n api?.container?.style.removeProperty(\"--sp-menu-max-height\");\n stateUnsubscribe?.();\n stateUnsubscribe = null;\n errorUnsubscribe?.();\n errorUnsubscribe = null;\n reconnectingUnsubscribe?.();\n reconnectingUnsubscribe = null;\n recoveredUnsubscribe?.();\n recoveredUnsubscribe = null;\n if (api?.container) {\n api.container.removeEventListener(\"pointerdown\", handlePointerActivity);\n api.container.removeEventListener(\"pointermove\", handlePointerActivity);\n api.container.removeEventListener(\"mousemove\", handleInteraction);\n api.container.removeEventListener(\"mouseenter\", handleInteraction);\n api.container.removeEventListener(\"mouseleave\", handleMouseLeave);\n api.container.removeEventListener(\"touchstart\", handleInteraction);\n api.container.removeEventListener(\"click\", handleInteraction);\n }\n document.removeEventListener(\"keydown\", handleKeyDown);\n controlRegistryUnsubscribe?.();\n controlRegistryUnsubscribe = null;\n controls.forEach((c) => c.destroy());\n controls = [];\n entries = [];\n timeEntry = null;\n tray = null;\n progressBar?.destroy();\n progressBar = null;\n errorOverlay?.destroy();\n errorOverlay = null;\n bigPlayButton?.destroy();\n bigPlayButton = null;\n controlBar?.remove();\n controlBar = null;\n gradient?.remove();\n gradient = null;\n bufferingIndicator?.remove();\n bufferingIndicator = null;\n styleEl?.remove();\n styleEl = null;\n api?.logger.debug(\"UI controls plugin destroyed\");\n },\n // Public API\n show() {\n showControls();\n },\n hide() {\n controlsVisible = false;\n controlBar?.classList.remove(\"sp-controls--visible\");\n controlBar?.classList.add(\"sp-controls--hidden\");\n gradient?.classList.remove(\"sp-gradient--visible\");\n progressBar?.hide();\n api?.setState(\"controlsVisible\", false);\n },\n setTheme(theme) {\n const root = api?.container || document.documentElement;\n if (theme.primaryColor) {\n root.style.setProperty(\"--sp-color\", theme.primaryColor);\n }\n if (theme.accentColor) {\n root.style.setProperty(\"--sp-accent\", theme.accentColor);\n }\n if (theme.backgroundColor) {\n root.style.setProperty(\"--sp-bg\", theme.backgroundColor);\n }\n if (theme.controlBarHeight) {\n root.style.setProperty(\"--sp-control-height\", `${theme.controlBarHeight}px`);\n }\n if (theme.iconSize) {\n root.style.setProperty(\"--sp-icon-size\", `${theme.iconSize}px`);\n }\n },\n getControlBar() {\n return controlBar;\n }\n };\n}\nvar index_default = uiPlugin;\nexport {\n DEFAULT_PRIORITY,\n assertFitLayout,\n index_default as default,\n formatLiveTime,\n formatTime,\n getControlFactory,\n icons,\n planFit,\n registerControl,\n resetControlRegistry,\n resolveFitItems,\n styles,\n uiPlugin,\n unregisterControl\n};\n"],"names":[],"mappings":"AA0rGA,IAAI,WAA2B,oBAAI,IAAG;AACtC,IAAI,YAA4B,oBAAI,IAAG;AACvC,SAAS,gBAAgB,IAAI,SAAS;AACpC,WAAS,IAAI,IAAI,OAAO;AACxB,aAAW,YAAY,WAAW;AAChC,aAAS,EAAE;AAAA,EACb;AACF;"}