react-helios 2.4.0 → 2.7.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
@@ -1,6 +1,6 @@
1
1
  # react-helios
2
2
 
3
- Production-grade React video player with HLS streaming, adaptive quality selection, live stream support, subtitle tracks, VTT sprite sheet thumbnail preview, Picture-in-Picture, and full keyboard control.
3
+ Production-grade React video player with HLS streaming, zero-cost audio mode, adaptive quality selection, live stream support, subtitle tracks, VTT sprite sheet thumbnail preview, Picture-in-Picture, and full keyboard control.
4
4
 
5
5
  ## Installation
6
6
 
@@ -24,8 +24,13 @@ export default function App() {
24
24
  return (
25
25
  <VideoPlayer
26
26
  src="https://example.com/video.mp4"
27
+ poster="https://example.com/poster.jpg"
27
28
  controls
28
- autoplay={false}
29
+ options={{
30
+ autoplay: false,
31
+ loop: false,
32
+ thumbnailVtt: "https://example.com/thumbs/storyboard.vtt",
33
+ }}
29
34
  />
30
35
  );
31
36
  }
@@ -41,12 +46,100 @@ Pass any `.m3u8` URL — HLS.js is initialised automatically:
41
46
  <VideoPlayer
42
47
  src="https://example.com/stream.m3u8"
43
48
  controls
44
- enableHLS // default: true
49
+ options={{
50
+ enableHLS: true, // default: true
51
+ hlsConfig: {
52
+ maxBufferLength: 60,
53
+ capLevelToPlayerSize: true,
54
+ },
55
+ }}
45
56
  />
46
57
  ```
47
58
 
48
59
  On Safari the browser's native HLS engine is used. A **LIVE** badge and **GO LIVE** button appear automatically for live streams.
49
60
 
61
+ ## Audio Mode
62
+
63
+ Audio mode pauses the video element completely (stopping all video decoding), shows the poster artwork, and hands playback off to a lightweight `<audio>` element — so the player uses roughly the same CPU/GPU as a music app instead of a playing video.
64
+
65
+ ```tsx
66
+ <VideoPlayer
67
+ src="https://example.com/stream.m3u8"
68
+ poster="https://example.com/artwork.jpg"
69
+ controls
70
+ options={{
71
+ audioSrc: "https://example.com/audio-only.m3u8",
72
+ audioModeLabel: "Switch to Audio",
73
+ videoModeLabel: "Switch to Video",
74
+ defaultAudioMode: false,
75
+ onAudioModeChange: (isAudio) => console.log("audio mode:", isAudio),
76
+ }}
77
+ />
78
+ ```
79
+
80
+ The audio toggle button only appears in the control bar when `audioSrc` is provided. Custom icons can be passed via `audioModeIcon` / `videoModeIcon`.
81
+
82
+ When switching between modes, position, volume, and playback rate are synced automatically — the listener hears no gap.
83
+
84
+ ### Automatic switching
85
+
86
+ The player uses two independent signals to detect poor conditions and switch to audio mode automatically. Either one firing is enough.
87
+
88
+ **Bandwidth-based** — measures the actual download speed of each HLS fragment and switches when the rolling average drops below a threshold:
89
+
90
+ ```tsx
91
+ import { AUDIO_BANDWIDTH_THRESHOLDS } from "react-helios";
92
+
93
+ <VideoPlayer
94
+ src="https://example.com/stream.m3u8"
95
+ options={{
96
+ audioBandwidthThreshold: AUDIO_BANDWIDTH_THRESHOLDS.FAIR, // recommended
97
+ // audioBandwidthThreshold: 0, // disable bandwidth-based switching
98
+ }}
99
+ />
100
+ ```
101
+
102
+ | Preset | Kbps | Typical connection |
103
+ |--------|------|--------------------|
104
+ | `EXTREME` | 100 | 2G / Edge |
105
+ | `POOR` | 300 | Slow 3G |
106
+ | `FAIR` | 800 | Marginal 3G ← **recommended** |
107
+ | `GOOD` | 1500 | Weak 4G / congested Wi-Fi |
108
+
109
+ **Level-based** — switches when HLS.js drops to a specific quality level (its own ABR algorithm already does the hard work):
110
+
111
+ ```tsx
112
+ import { AUDIO_SWITCH_LEVELS } from "react-helios";
113
+
114
+ <VideoPlayer
115
+ src="https://example.com/stream.m3u8"
116
+ options={{
117
+ audioModeSwitchLevel: AUDIO_SWITCH_LEVELS.LOWEST, // switch at lowest quality level
118
+ }}
119
+ />
120
+ ```
121
+
122
+ | Preset | Value | Meaning |
123
+ |--------|-------|---------|
124
+ | `LOWEST` | 0 | Switch when HLS.js is at the lowest available quality |
125
+ | `SECOND_LOWEST` | 1 | Switch one level above the lowest |
126
+ | `DISABLED` | -1 | Disable level-based switching |
127
+
128
+ Using **both together** is the most reliable approach:
129
+
130
+ ```tsx
131
+ <VideoPlayer
132
+ src="https://example.com/stream.m3u8"
133
+ options={{
134
+ audioSrc: "https://example.com/audio-only.m3u8",
135
+ audioBandwidthThreshold: AUDIO_BANDWIDTH_THRESHOLDS.FAIR,
136
+ audioModeSwitchLevel: AUDIO_SWITCH_LEVELS.LOWEST,
137
+ }}
138
+ />
139
+ ```
140
+
141
+ After the user manually toggles audio mode a 60-second cooldown suppresses automatic switching. The player also probes for bandwidth recovery every 30 seconds while in auto-switched audio mode (configurable via `audioModeRecoveryInterval`).
142
+
50
143
  ## Thumbnail Preview
51
144
 
52
145
  Hover over the progress bar to see a time tooltip. For rich sprite-sheet thumbnails, pass a `thumbnailVtt` URL pointing to a [WebVTT thumbnail file](https://developer.bitmovin.com/playback/docs/webvtt-based-thumbnails).
@@ -54,7 +147,9 @@ Hover over the progress bar to see a time tooltip. For rich sprite-sheet thumbna
54
147
  ```tsx
55
148
  <VideoPlayer
56
149
  src="https://example.com/video.mp4"
57
- thumbnailVtt="https://example.com/thumbs/storyboard.vtt"
150
+ options={{
151
+ thumbnailVtt: "https://example.com/thumbs/storyboard.vtt",
152
+ }}
58
153
  />
59
154
  ```
60
155
 
@@ -80,38 +175,90 @@ The player fetches the VTT file once, parses all cues, and uses CSS `background-
80
175
  To disable the preview entirely:
81
176
 
82
177
  ```tsx
83
- <VideoPlayer src="..." enablePreview={false} />
178
+ <VideoPlayer src="..." options={{ enablePreview: false }} />
84
179
  ```
85
180
 
86
181
  ## Props
87
182
 
183
+ ### Top-level props
184
+
88
185
  | Prop | Type | Default | Description |
89
186
  |------|------|---------|-------------|
90
187
  | `src` | `string` | — | Video URL (MP4, WebM, HLS `.m3u8`, …) |
91
- | `poster` | `string` | — | Poster image shown before playback |
188
+ | `poster` | `string` | — | Poster image shown before playback and in audio mode |
92
189
  | `controls` | `boolean` | `true` | Show the built-in control bar |
190
+ | `className` | `string` | — | CSS class on the player container |
191
+ | `options` | `VideoPlayerOptions` | `{}` | All configuration (see below) |
192
+
193
+ ### `options` — Playback
194
+
195
+ | Option | Type | Default | Description |
196
+ |--------|------|---------|-------------|
93
197
  | `autoplay` | `boolean` | `false` | Start playback on mount |
94
198
  | `muted` | `boolean` | `false` | Start muted |
95
199
  | `loop` | `boolean` | `false` | Loop the video |
96
200
  | `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` | Native `preload` attribute |
97
- | `playbackRates` | `PlaybackRate[]` | `[0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]` | Available speed options |
201
+ | `playbackRates` | `PlaybackRate[]` | `[0.25 2]` | Available speed options |
202
+ | `crossOrigin` | `"anonymous" \| "use-credentials"` | — | CORS attribute for the video element |
203
+ | `subtitles` | `SubtitleTrack[]` | — | Subtitle / caption tracks |
204
+
205
+ ### `options` — HLS
206
+
207
+ | Option | Type | Default | Description |
208
+ |--------|------|---------|-------------|
98
209
  | `enableHLS` | `boolean` | `true` | Enable HLS.js for `.m3u8` sources |
210
+ | `hlsConfig` | `Partial<HlsConfig>` | — | Override any [hls.js config](https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning) option |
211
+
212
+ ### `options` — Preview
213
+
214
+ | Option | Type | Default | Description |
215
+ |--------|------|---------|-------------|
99
216
  | `enablePreview` | `boolean` | `true` | Show thumbnail / time tooltip on progress bar hover |
100
217
  | `thumbnailVtt` | `string` | — | URL to a WebVTT sprite sheet file for rich thumbnail preview |
101
- | `hlsConfig` | `Partial<HlsConfig>` | — | Override any [hls.js configuration](https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning) option |
102
- | `subtitles` | `SubtitleTrack[]` | | Subtitle / caption tracks |
103
- | `crossOrigin` | `"anonymous" \| "use-credentials"` | — | CORS attribute for the video element |
104
- | `className` | `string` | | CSS class on the player container |
105
- | `onPlay` | `() => void` | — | Fired when playback starts |
106
- | `onPause` | `() => void` | | Fired when playback pauses |
107
- | `onEnded` | `() => void` | — | Fired when playback ends |
108
- | `onError` | `(error: VideoError) => void` | | Fired on playback or stream errors |
109
- | `onTimeUpdate` | `(time: number) => void` | — | Fired every ~250 ms during playback |
110
- | `onDurationChange` | `(duration: number) => void` | | Fired when video duration becomes known |
111
- | `onBuffering` | `(isBuffering: boolean) => void` | — | Fired when buffering starts / stops |
112
- | `onTheaterModeChange` | `(isTheater: boolean) => void` | — | Fired when theater mode is toggled |
113
- | `contextMenuItems` | `ContextMenuItem[]` | | Extra items appended to the right-click context menu |
114
- | `controlBarItems` | `ControlBarItem[]` | | Extra icon buttons appended to the right side of the control bar |
218
+
219
+ ### `options` — UI
220
+
221
+ | Option | Type | Default | Description |
222
+ |--------|------|---------|-------------|
223
+ | `autoHideControls` | `boolean` | `true` | Hide control bar on mouse leave when playing (video mode only) |
224
+
225
+ ### `options` — Audio mode
226
+
227
+ | Option | Type | Default | Description |
228
+ |--------|------|---------|-------------|
229
+ | `audioSrc` | `string` | — | Audio-only stream URL; the audio toggle button only shows when this is set |
230
+ | `showAudioButton` | `boolean` | `!!audioSrc` | Force-show or hide the audio toggle button |
231
+ | `defaultAudioMode` | `boolean` | `false` | Start in audio mode |
232
+ | `audioModeLabel` | `string` | `"Audio"` | Label on the toggle button when in video mode |
233
+ | `videoModeLabel` | `string` | `"Video"` | Label on the toggle button when in audio mode |
234
+ | `audioModeIcon` | `ReactNode` | built-in headphones icon | Icon shown when in video mode (click → audio) |
235
+ | `videoModeIcon` | `ReactNode` | built-in video icon | Icon shown when in audio mode (click → video) |
236
+ | `audioModeFallback` | `ReactNode` | — | Custom content shown in audio mode when no `poster` is provided |
237
+ | `logo` | `string \| ReactNode` | — | Logo shown in audio mode when no `poster` or `audioModeFallback` is provided |
238
+ | `audioBandwidthThreshold` | `number` | `300` | Kbps — switch when per-fragment bandwidth average drops below this. `0` = disabled (HLS only) |
239
+ | `audioModeSwitchLevel` | `number` | — | HLS quality level index — switch when HLS.js drops to this level or below. `0` = lowest. `-1` = disabled |
240
+ | `audioModeRecoveryInterval` | `number` | `30000` | Ms between recovery probes while in auto-switched audio mode |
241
+
242
+ ### `options` — Callbacks
243
+
244
+ | Option | Type | Description |
245
+ |--------|------|-------------|
246
+ | `onPlay` | `() => void` | Fired when playback starts |
247
+ | `onPause` | `() => void` | Fired when playback pauses |
248
+ | `onEnded` | `() => void` | Fired when playback ends |
249
+ | `onError` | `(error: VideoError) => void` | Fired on playback or stream errors |
250
+ | `onTimeUpdate` | `(time: number) => void` | Fired every ~250 ms during playback |
251
+ | `onDurationChange` | `(duration: number) => void` | Fired when video duration becomes known |
252
+ | `onBuffering` | `(isBuffering: boolean) => void` | Fired when buffering starts / stops |
253
+ | `onTheaterModeChange` | `(isTheater: boolean) => void` | Fired when theater mode is toggled |
254
+ | `onAudioModeChange` | `(isAudio: boolean) => void` | Fired when audio mode is toggled (manual or automatic) |
255
+
256
+ ### `options` — Custom controls
257
+
258
+ | Option | Type | Description |
259
+ |--------|------|-------------|
260
+ | `contextMenuItems` | `ContextMenuItem[]` | Extra items appended to the right-click context menu |
261
+ | `controlBarItems` | `ControlBarItem[]` | Extra icon buttons appended to the right side of the control bar |
115
262
 
116
263
  ## Quality Selection
117
264
 
@@ -120,8 +267,6 @@ For HLS streams (`.m3u8`) the player automatically parses the available quality
120
267
  - **Speed tab** — always visible, lets you change playback rate.
121
268
  - **Quality tab** — appears only for HLS streams. Lists all levels sorted by bitrate (e.g. 1080p, 720p, 480p) plus an **Auto** option that enables ABR (adaptive bitrate). The current auto-selected level is shown in parentheses next to "Auto".
122
269
 
123
- For plain MP4/WebM files there are no quality levels to switch between, so the Quality tab never appears.
124
-
125
270
  You can also switch quality programmatically via the ref:
126
271
 
127
272
  ```tsx
@@ -131,49 +276,41 @@ playerRef.current?.setQualityLevel(-1); // back to ABR auto
131
276
 
132
277
  ## Custom Control Bar Buttons
133
278
 
134
- Inject your own icon buttons into the right side of the control bar (between the settings gear and the PiP/Theater/Fullscreen buttons) using `controlBarItems`:
279
+ Inject your own icon buttons into the right side of the control bar using `controlBarItems`:
135
280
 
136
281
  ```tsx
137
- import { VideoPlayer, ControlBarItem } from "react-helios";
282
+ import { VideoPlayer } from "react-helios";
283
+ import type { ControlBarItem } from "react-helios";
138
284
 
139
285
  const items: ControlBarItem[] = [
140
286
  {
141
- key: "download",
142
- label: "Download",
143
- icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5v-2z"/></svg>,
144
- onClick: () => downloadVideo(),
145
- },
146
- {
147
- key: "share",
148
- label: "Share",
149
- title: "Share this video",
150
- icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11A2.99 2.99 0 0 0 18 8a3 3 0 1 0-3-3c0 .24.04.47.09.7L8.04 9.81A2.99 2.99 0 0 0 6 9a3 3 0 1 0 3 3c0-.24-.04-.47-.09-.7l7.05-4.11c.52.47 1.2.77 1.96.77a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>,
151
- onClick: () => openShareDialog(),
287
+ key: "bookmark",
288
+ label: "Bookmark",
289
+ title: "Save current position",
290
+ icon: <BookmarkIcon />,
291
+ onClick: () => saveBookmark(playerRef.current?.getState().currentTime ?? 0),
152
292
  },
153
293
  ];
154
294
 
155
- <VideoPlayer src="..." controlBarItems={items} />
295
+ <VideoPlayer src="..." options={{ controlBarItems: items }} />
156
296
  ```
157
297
 
158
- Buttons receive the same `controlButton` CSS class as built-in buttons (hover highlight, active press scale, no focus outline).
159
-
160
298
  ## Context Menu
161
299
 
162
- Right-clicking the player shows a built-in menu (Play/Pause, Loop, Copy URL, Picture-in-Picture). You can append your own items by passing `contextMenuItems`:
300
+ Right-clicking the player shows a built-in menu (Play/Pause, Loop, Copy URL, Picture-in-Picture). Append your own items via `contextMenuItems`:
163
301
 
164
302
  ```tsx
165
- import { VideoPlayer, ContextMenuItem } from "react-helios";
303
+ import { VideoPlayer } from "react-helios";
304
+ import type { ContextMenuItem } from "react-helios";
166
305
 
167
306
  const items: ContextMenuItem[] = [
168
307
  { label: "Add to Watchlist", onClick: () => addToWatchlist() },
169
308
  { label: "Share", onClick: () => openShareDialog() },
170
309
  ];
171
310
 
172
- <VideoPlayer src="..." contextMenuItems={items} />
311
+ <VideoPlayer src="..." options={{ contextMenuItems: items }} />
173
312
  ```
174
313
 
175
- Each item closes the menu automatically after its `onClick` is called.
176
-
177
314
  ## Imperative API (Ref)
178
315
 
179
316
  Use a `ref` to control the player programmatically:
@@ -192,7 +329,7 @@ export default function App() {
192
329
  <button onClick={() => playerRef.current?.pause()}>Pause</button>
193
330
  <button onClick={() => playerRef.current?.seek(30)}>Jump to 30s</button>
194
331
  <button onClick={() => playerRef.current?.setVolume(0.5)}>50% volume</button>
195
- <button onClick={() => playerRef.current?.setPlaybackRate(1.5)}>1.5× speed</button>
332
+ <button onClick={() => playerRef.current?.toggleAudioMode()}>Toggle Audio</button>
196
333
  </>
197
334
  );
198
335
  }
@@ -213,12 +350,13 @@ export default function App() {
213
350
  | `toggleFullscreen` | `() => Promise<void>` | Toggle fullscreen |
214
351
  | `togglePictureInPicture` | `() => Promise<void>` | Toggle Picture-in-Picture |
215
352
  | `toggleTheaterMode` | `() => void` | Toggle theater (wide) mode |
353
+ | `toggleAudioMode` | `() => void` | Toggle audio-only mode |
216
354
  | `getState` | `() => PlayerState` | Snapshot of current player state |
217
355
  | `getVideoElement` | `() => HTMLVideoElement \| null` | Access the underlying `<video>` element |
218
356
 
219
357
  ## Theater Mode
220
358
 
221
- The player fires `onTheaterModeChange` when theater mode is toggled (via the `T` key, the control bar button, or `playerRef.current?.toggleTheaterMode()`). Wire it to your layout state to widen your container:
359
+ The player fires `onTheaterModeChange` when theater mode is toggled. Wire it to your layout state to widen your container:
222
360
 
223
361
  ```tsx
224
362
  "use client";
@@ -237,24 +375,26 @@ export default function Page() {
237
375
  <VideoPlayer
238
376
  src="https://example.com/stream.m3u8"
239
377
  controls
240
- onTheaterModeChange={(t) => setIsTheater(t)}
378
+ options={{
379
+ onTheaterModeChange: (t) => setIsTheater(t),
380
+ }}
241
381
  />
242
382
  </main>
243
383
  );
244
384
  }
245
385
  ```
246
386
 
247
- The player itself does not manage your page layout — it only notifies you so you can adapt your design.
248
-
249
387
  ## Subtitles
250
388
 
251
389
  ```tsx
252
390
  <VideoPlayer
253
391
  src="https://example.com/video.mp4"
254
- subtitles={[
255
- { id: "en", src: "/subs/en.vtt", label: "English", srclang: "en", default: true },
256
- { id: "es", src: "/subs/es.vtt", label: "Español", srclang: "es" },
257
- ]}
392
+ options={{
393
+ subtitles: [
394
+ { id: "en", src: "/subs/en.vtt", label: "English", srclang: "en", default: true },
395
+ { id: "es", src: "/subs/es.vtt", label: "Español", srclang: "es" },
396
+ ],
397
+ }}
258
398
  />
259
399
  ```
260
400
 
@@ -292,6 +432,7 @@ All types are exported from the package:
292
432
  ```ts
293
433
  import type {
294
434
  VideoPlayerProps,
435
+ VideoPlayerOptions,
295
436
  VideoPlayerRef,
296
437
  PlayerState,
297
438
  PlaybackRate,
@@ -300,13 +441,15 @@ import type {
300
441
  BufferedRange,
301
442
  VideoError,
302
443
  VideoErrorCode,
303
- ThumbnailCue,
304
444
  ContextMenuItem,
305
445
  ControlBarItem,
306
446
  } from "react-helios";
307
447
 
448
+ import { AUDIO_BANDWIDTH_THRESHOLDS, AUDIO_SWITCH_LEVELS } from "react-helios";
449
+
308
450
  // VTT utilities (useful for server-side pre-parsing or custom UIs)
309
451
  import { parseThumbnailVtt, findThumbnailCue } from "react-helios";
452
+ import type { ThumbnailCue } from "react-helios";
310
453
  ```
311
454
 
312
455
  ### `PlayerState`
@@ -325,6 +468,7 @@ interface PlayerState {
325
468
  isFullscreen: boolean;
326
469
  isPictureInPicture: boolean;
327
470
  isTheaterMode: boolean;
471
+ isAudioMode: boolean;
328
472
  isLive: boolean;
329
473
  qualityLevels: HLSQualityLevel[];
330
474
  currentQualityLevel: number; // -1 = ABR auto
@@ -349,20 +493,6 @@ interface VideoError {
349
493
  }
350
494
  ```
351
495
 
352
- ### `ThumbnailCue`
353
-
354
- ```ts
355
- interface ThumbnailCue {
356
- start: number; // seconds
357
- end: number; // seconds
358
- url: string; // absolute URL to the sprite image
359
- x: number; // pixel offset within sprite
360
- y: number;
361
- w: number; // cell width in pixels
362
- h: number; // cell height in pixels
363
- }
364
- ```
365
-
366
496
  ### `ControlBarItem`
367
497
 
368
498
  ```ts
@@ -384,9 +514,21 @@ interface ContextMenuItem {
384
514
  }
385
515
  ```
386
516
 
387
- ## Utility Functions
517
+ ### `ThumbnailCue`
388
518
 
389
- The package exports a few helper utilities used internally, exposed for custom integrations:
519
+ ```ts
520
+ interface ThumbnailCue {
521
+ start: number; // seconds
522
+ end: number; // seconds
523
+ url: string; // absolute URL to the sprite image
524
+ x: number; // pixel offset within sprite
525
+ y: number;
526
+ w: number; // cell width in pixels
527
+ h: number; // cell height in pixels
528
+ }
529
+ ```
530
+
531
+ ## Utility Functions
390
532
 
391
533
  ```ts
392
534
  import { formatTime, isHLSUrl, getMimeType } from "react-helios";
@@ -421,16 +563,18 @@ if (cue) {
421
563
  The player is architected to produce **zero React re-renders during playback**:
422
564
 
423
565
  - `timeupdate` and `progress` events are handled by direct DOM mutation (refs), not React state.
424
- - `ProgressBar` and `TimeDisplay` self-subscribe to the video element — the parent tree never re-renders on seek or time change.
566
+ - `ProgressBar` and `TimeDisplay` self-subscribe to the active media element — the parent tree never re-renders on seek or time change.
567
+ - `Controls` and `AudioModeOverlay` are wrapped in `React.memo` — they only re-render when their own props change, not when unrelated state (buffering, errors) updates.
425
568
  - VTT sprite thumbnails are looked up via binary search (O(log n)) and rendered via CSS `background-position` — no hidden `<video>` element, no canvas, no network requests per hover.
426
569
  - Buffered ranges are the only state that triggers a re-render (fires every few seconds during buffering, not 60× per second).
570
+ - In audio mode the `<video>` element is **paused** — the browser stops decoding frames entirely. A lightweight `<audio>` element takes over with `preload="none"` (no network cost at startup). The `<audio>` element only loads its source the first time the user switches to audio mode.
427
571
 
428
572
  ## Project Structure
429
573
 
430
574
  ```
431
575
  react-helios/
432
576
  ├── src/ # Library source
433
- │ ├── components/ # VideoPlayer, Controls, control elements
577
+ │ ├── components/ # VideoPlayer, Controls, AudioModeOverlay, control elements
434
578
  │ ├── hooks/ # useVideoPlayer (state + HLS init)
435
579
  │ ├── lib/ # Types, HLS utilities, VTT parser, format helpers
436
580
  │ └── styles/ # CSS