@lucaismyname/ginger 0.0.5 → 0.0.8

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.
Files changed (34) hide show
  1. package/README.md +745 -0
  2. package/dist/components/controls/Controls.d.ts +7 -3
  3. package/dist/components/controls/Controls.d.ts.map +1 -1
  4. package/dist/components/current/Artwork.d.ts.map +1 -1
  5. package/dist/components/current/Time.d.ts +11 -1
  6. package/dist/components/current/Time.d.ts.map +1 -1
  7. package/dist/components/current/index.d.ts +1 -1
  8. package/dist/components/current/index.d.ts.map +1 -1
  9. package/dist/components/playlist/GingerPlaylist.d.ts.map +1 -1
  10. package/dist/context/GingerContext.d.ts +3 -1
  11. package/dist/context/GingerContext.d.ts.map +1 -1
  12. package/dist/context/GingerLocaleContext.d.ts +9 -0
  13. package/dist/context/GingerLocaleContext.d.ts.map +1 -0
  14. package/dist/context/GingerProvider.d.ts +1 -1
  15. package/dist/context/GingerProvider.d.ts.map +1 -1
  16. package/dist/context/GingerSplitContexts.d.ts +42 -0
  17. package/dist/context/GingerSplitContexts.d.ts.map +1 -0
  18. package/dist/core/transitions.test.d.ts +2 -0
  19. package/dist/core/transitions.test.d.ts.map +1 -0
  20. package/dist/ginger.d.ts +1 -0
  21. package/dist/ginger.d.ts.map +1 -1
  22. package/dist/hooks/useControlBindings.d.ts +40 -0
  23. package/dist/hooks/useControlBindings.d.ts.map +1 -0
  24. package/dist/hooks/useGinger.d.ts +1 -0
  25. package/dist/hooks/useGinger.d.ts.map +1 -1
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.ts +9 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +981 -638
  31. package/dist/index.js.map +1 -1
  32. package/dist/types.d.ts +31 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +3 -2
package/README.md ADDED
@@ -0,0 +1,745 @@
1
+ # ginger
2
+
3
+ **`@lucaismyname/ginger`** is a headless React audio player built on the native **`<audio>`** element. It gives you a provider, a hidden media element, a typed state/control hook, and composable React components for transport controls, track metadata, queue metadata, and playlists.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @lucaismyname/ginger
9
+ ```
10
+
11
+ Peer dependencies:
12
+
13
+ - `react >= 18`
14
+ - `react-dom >= 18`
15
+
16
+ ## Quick Start
17
+
18
+ ```tsx
19
+ import { Ginger } from "@lucaismyname/ginger";
20
+
21
+ const tracks = [
22
+ {
23
+ id: "one",
24
+ title: "One",
25
+ artist: "Demo Artist",
26
+ fileUrl: "https://example.com/audio/one.mp3",
27
+ artworkUrl: "https://example.com/art/one.jpg",
28
+ },
29
+ {
30
+ id: "two",
31
+ title: "Two",
32
+ artist: "Demo Artist",
33
+ fileUrl: "https://example.com/audio/two.mp3",
34
+ },
35
+ ];
36
+
37
+ export function App() {
38
+ return (
39
+ <Ginger.Provider initialTracks={tracks} initialPlaylistMeta={{ title: "My Playlist" }}>
40
+ <Ginger.Player />
41
+ <Ginger.Current.Title />
42
+ <Ginger.Current.Artist />
43
+ <Ginger.Control.PlayPause />
44
+ <Ginger.Control.Next />
45
+ <Ginger.Playlist />
46
+ </Ginger.Provider>
47
+ );
48
+ }
49
+ ```
50
+
51
+ Mount **`<Ginger.Player />`** once inside the same provider tree so the hidden audio element exists. Everything else is optional and can be replaced with your own UI.
52
+
53
+ ## Copy/Paste Examples
54
+
55
+ ### Small Audio Player With Tailwind
56
+
57
+ ```tsx
58
+ import { Ginger } from "@lucaismyname/ginger";
59
+
60
+ const tracks = [
61
+ {
62
+ id: "midnight",
63
+ title: "Midnight Walk",
64
+ artist: "Luca",
65
+ album: "Night Notes",
66
+ fileUrl: "https://example.com/audio/midnight-walk.mp3",
67
+ artworkUrl: "https://images.unsplash.com/photo-1511379938547-c1f69419868d?auto=format&fit=crop&w=600&q=80",
68
+ durationSeconds: 192,
69
+ },
70
+ ];
71
+
72
+ export function TailwindMiniPlayer() {
73
+ return (
74
+ <Ginger.Provider
75
+ initialTracks={tracks}
76
+ initialVolume={0.8}
77
+ className="mx-auto max-w-md"
78
+ >
79
+ <Ginger.Player />
80
+
81
+ <div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm">
82
+ <div className="flex items-center gap-4">
83
+ <Ginger.Current.Artwork
84
+ className="h-16 w-16 overflow-hidden rounded-xl bg-zinc-100"
85
+ imgStyle={{ width: "100%", height: "100%" }}
86
+ />
87
+
88
+ <div className="min-w-0 flex-1">
89
+ <Ginger.Current.Title className="block truncate text-sm font-semibold text-zinc-900" />
90
+ <Ginger.Current.Artist className="block truncate text-sm text-zinc-500" />
91
+ <div className="mt-2">
92
+ <Ginger.Control.SeekBar className="w-full accent-emerald-600" />
93
+ </div>
94
+ <div className="mt-1 flex justify-between text-xs text-zinc-500">
95
+ <Ginger.Current.Elapsed />
96
+ <Ginger.Current.Duration />
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <div className="mt-4 flex items-center justify-between gap-2">
102
+ <Ginger.Control.Previous className="rounded-lg border border-zinc-300 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50" />
103
+ <Ginger.Control.PlayPause className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800" />
104
+ <Ginger.Control.Next className="rounded-lg border border-zinc-300 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50" />
105
+ <Ginger.Control.Mute className="rounded-lg border border-zinc-300 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50" />
106
+ </div>
107
+
108
+ <div className="mt-4 flex items-center gap-3">
109
+ <span className="text-xs text-zinc-500">Volume</span>
110
+ <Ginger.Control.Volume className="flex-1 accent-emerald-600" />
111
+ <Ginger.Control.PlaybackRate className="rounded-md border border-zinc-300 bg-white px-2 py-1 text-sm text-zinc-700" />
112
+ </div>
113
+ </div>
114
+ </Ginger.Provider>
115
+ );
116
+ }
117
+ ```
118
+
119
+ ### Small Audio Player With Vanilla CSS
120
+
121
+ ```tsx
122
+ import { Ginger } from "@lucaismyname/ginger";
123
+ import "./mini-player.css";
124
+
125
+ const tracks = [
126
+ {
127
+ id: "shoreline",
128
+ title: "Shoreline",
129
+ artist: "Sea Echo",
130
+ fileUrl: "https://example.com/audio/shoreline.mp3",
131
+ artworkUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80",
132
+ durationSeconds: 214,
133
+ },
134
+ ];
135
+
136
+ export function VanillaMiniPlayer() {
137
+ return (
138
+ <Ginger.Provider initialTracks={tracks} className="ginger-mini-theme">
139
+ <Ginger.Player />
140
+
141
+ <div className="mini-player">
142
+ <Ginger.Current.Artwork className="mini-player__artwork" />
143
+
144
+ <div className="mini-player__body">
145
+ <Ginger.Current.Title className="mini-player__title" />
146
+ <Ginger.Current.Artist className="mini-player__artist" />
147
+
148
+ <div className="mini-player__seek">
149
+ <Ginger.Control.SeekBar />
150
+ </div>
151
+
152
+ <div className="mini-player__times">
153
+ <Ginger.Current.Elapsed />
154
+ <Ginger.Current.Duration />
155
+ </div>
156
+
157
+ <div className="mini-player__controls">
158
+ <Ginger.Control.Previous className="mini-player__button mini-player__button--ghost" />
159
+ <Ginger.Control.PlayPause className="mini-player__button mini-player__button--primary" />
160
+ <Ginger.Control.Next className="mini-player__button mini-player__button--ghost" />
161
+ <Ginger.Control.Mute className="mini-player__button mini-player__button--ghost" />
162
+ </div>
163
+
164
+ <div className="mini-player__footer">
165
+ <label className="mini-player__volume">
166
+ <span>Volume</span>
167
+ <Ginger.Control.Volume />
168
+ </label>
169
+
170
+ <Ginger.Control.PlaybackRate className="mini-player__rate" />
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </Ginger.Provider>
175
+ );
176
+ }
177
+ ```
178
+
179
+ ```css
180
+ .ginger-mini-theme {
181
+ --ginger-primary-color: #111827;
182
+ --ginger-muted-color: #6b7280;
183
+ --ginger-font-size: 14px;
184
+ --ginger-font-family: Inter, system-ui, sans-serif;
185
+ --ginger-artwork-radius: 14px;
186
+ --ginger-artwork-bg: #f3f4f6;
187
+ }
188
+
189
+ .mini-player {
190
+ display: flex;
191
+ gap: 16px;
192
+ max-width: 420px;
193
+ padding: 16px;
194
+ border: 1px solid #e5e7eb;
195
+ border-radius: 20px;
196
+ background: #ffffff;
197
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
198
+ }
199
+
200
+ .mini-player__artwork {
201
+ width: 72px;
202
+ height: 72px;
203
+ flex: 0 0 72px;
204
+ }
205
+
206
+ .mini-player__body {
207
+ flex: 1;
208
+ min-width: 0;
209
+ }
210
+
211
+ .mini-player__title {
212
+ display: block;
213
+ font-weight: 600;
214
+ color: #111827;
215
+ }
216
+
217
+ .mini-player__artist {
218
+ display: block;
219
+ margin-top: 4px;
220
+ color: #6b7280;
221
+ }
222
+
223
+ .mini-player__seek {
224
+ margin-top: 12px;
225
+ }
226
+
227
+ .mini-player__seek input {
228
+ width: 100%;
229
+ }
230
+
231
+ .mini-player__times {
232
+ display: flex;
233
+ justify-content: space-between;
234
+ margin-top: 6px;
235
+ font-size: 12px;
236
+ color: #6b7280;
237
+ }
238
+
239
+ .mini-player__controls {
240
+ display: flex;
241
+ gap: 8px;
242
+ margin-top: 14px;
243
+ }
244
+
245
+ .mini-player__button {
246
+ border-radius: 10px;
247
+ padding: 8px 12px;
248
+ font: inherit;
249
+ cursor: pointer;
250
+ }
251
+
252
+ .mini-player__button--ghost {
253
+ border: 1px solid #d1d5db;
254
+ background: white;
255
+ color: #111827;
256
+ }
257
+
258
+ .mini-player__button--primary {
259
+ border: 1px solid #111827;
260
+ background: #111827;
261
+ color: white;
262
+ }
263
+
264
+ .mini-player__footer {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 12px;
268
+ margin-top: 14px;
269
+ }
270
+
271
+ .mini-player__volume {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 8px;
275
+ flex: 1;
276
+ font-size: 12px;
277
+ color: #6b7280;
278
+ }
279
+
280
+ .mini-player__volume input {
281
+ flex: 1;
282
+ }
283
+
284
+ .mini-player__rate {
285
+ border: 1px solid #d1d5db;
286
+ border-radius: 8px;
287
+ background: white;
288
+ padding: 6px 8px;
289
+ color: #111827;
290
+ }
291
+ ```
292
+
293
+ ## Core Concepts
294
+
295
+ ### `Ginger.Provider` owns playback state
296
+
297
+ The provider stores queue state, playback state, media state, and playlist metadata.
298
+
299
+ - Props prefixed with **`initial*`** are mount-only defaults.
300
+ - To replace the queue after mount, call **`useGinger().setQueue(...)`**.
301
+ - If you want provider state to fully reset from parent props, remount the provider with a new `key`.
302
+
303
+ ### `Ginger.Player` creates and syncs the hidden audio element
304
+
305
+ The player renders the actual **`<audio>`** element and mirrors reducer state into the browser media API.
306
+
307
+ - You usually render it once near the root of the player subtree.
308
+ - It can stay visually hidden; it exists to drive playback.
309
+ - All transport and display components depend on it.
310
+
311
+ ### Use components, the hook, or both
312
+
313
+ - Use **`Ginger.Control.*`**, **`Ginger.Current.*`**, **`Ginger.Queue.*`**, and **`Ginger.Playlist`** for fast composition.
314
+ - Use **`useGinger()`** when you want total control over layout and styling.
315
+ - Mix both approaches freely in the same provider tree.
316
+
317
+ ## API Reference
318
+
319
+ ### `Ginger.Provider`
320
+
321
+ Wrap all Ginger UI in a single provider.
322
+
323
+ ```tsx
324
+ <Ginger.Provider initialTracks={tracks}>
325
+ <Ginger.Player />
326
+ {/* your UI */}
327
+ </Ginger.Provider>
328
+ ```
329
+
330
+ Props:
331
+
332
+ | Prop | Type | Default | Description |
333
+ |------|------|---------|-------------|
334
+ | `children` | `ReactNode` | required | Player UI inside the provider |
335
+ | `initialTracks` | `Track[]` | `[]` | Initial queue |
336
+ | `initialIndex` | `number` | `0` | Initial current track index |
337
+ | `initialPlaylistMeta` | `PlaylistMeta \| null` | `null` | Queue/playlist metadata |
338
+ | `initialShuffle` | `boolean` | `false` | Start shuffled |
339
+ | `initialRepeatMode` | `"off" \| "all" \| "one"` | `"off"` | Initial repeat mode |
340
+ | `initialPaused` | `boolean` | `true` | Start paused or playing |
341
+ | `initialVolume` | `number` | `1` | Initial volume, clamped `0..1` |
342
+ | `initialMuted` | `boolean` | `false` | Initial muted state |
343
+ | `initialPlaybackRate` | `number` | `1` | Initial playback rate, clamped `0.25..4` |
344
+ | `className` | `string` | `undefined` | Class for the provider wrapper |
345
+ | `style` | `CSSProperties` | `undefined` | Inline styles / CSS variables |
346
+ | `onTrackChange` | `(track, index) => void` | `undefined` | Fires when current track changes |
347
+ | `onPlay` | `() => void` | `undefined` | Fires when state changes to playing |
348
+ | `onPause` | `() => void` | `undefined` | Fires when state changes to paused |
349
+ | `onQueueEnd` | `() => void` | `undefined` | Fires when playback reaches the end with repeat off |
350
+ | `onError` | `(message) => void` | `undefined` | Fires on media/playback errors |
351
+
352
+ ### `Ginger.Player`
353
+
354
+ Renders the backing audio element.
355
+
356
+ ```tsx
357
+ <Ginger.Player preload="metadata" crossOrigin="anonymous" />
358
+ ```
359
+
360
+ Props:
361
+
362
+ | Prop | Type | Default | Description |
363
+ |------|------|---------|-------------|
364
+ | `className` | `string` | `undefined` | Optional class on the `<audio>` element |
365
+ | `style` | `CSSProperties` | `undefined` | Optional inline styles |
366
+ | `preload` | `AudioHTMLAttributes["preload"]` | `"metadata"` | Native audio preload mode |
367
+ | `crossOrigin` | `AudioHTMLAttributes["crossOrigin"]` | `undefined` | Native cross-origin mode |
368
+
369
+ ### `useGinger()`
370
+
371
+ Low-level hook for building custom UIs.
372
+
373
+ ```tsx
374
+ import { useGinger } from "@lucaismyname/ginger";
375
+
376
+ function CustomPlayer() {
377
+ const {
378
+ state,
379
+ currentTrack,
380
+ playbackUi,
381
+ duration,
382
+ remaining,
383
+ progress,
384
+ play,
385
+ pause,
386
+ togglePlayPause,
387
+ seek,
388
+ next,
389
+ prev,
390
+ setVolume,
391
+ } = useGinger();
392
+
393
+ return <div>{currentTrack?.title}</div>;
394
+ }
395
+ ```
396
+
397
+ Returned values:
398
+
399
+ | Key | Description |
400
+ |-----|-------------|
401
+ | `state` | Full `GingerState` object |
402
+ | `currentTrack` | Current track or `null` |
403
+ | `playbackUi` | Derived UI state: `idle`, `loading`, `playing`, `paused`, `ended`, `error` |
404
+ | `duration` | Effective duration using media metadata or `durationSeconds` fallback |
405
+ | `remaining` | Remaining seconds |
406
+ | `progress` | Fraction from `0..1` |
407
+ | `artworkUrl` | Resolved artwork URL |
408
+ | `albumLine` | Resolved album/subtitle line |
409
+ | `play`, `pause`, `togglePlayPause` | Transport actions |
410
+ | `seek` | Seek to a time in seconds |
411
+ | `setVolume`, `setMuted`, `toggleMute` | Volume/mute actions |
412
+ | `setPlaybackRate` | Set playback speed |
413
+ | `next`, `prev` | Queue navigation |
414
+ | `setRepeatMode`, `cycleRepeat` | Repeat controls |
415
+ | `toggleShuffle` | Toggle shuffle |
416
+ | `setQueue` | Replace the queue after mount |
417
+ | `playTrackAt`, `selectTrackAt` | Pick a track by index |
418
+ | `setPlaylistMeta` | Replace playlist metadata |
419
+ | `audioRef` | Ref to the underlying `HTMLAudioElement` |
420
+ | `dispatch` | Raw reducer dispatch for advanced cases |
421
+
422
+ ## React Components
423
+
424
+ ### `Ginger.Control.*`
425
+
426
+ Transport and media controls.
427
+
428
+ | Component | Description | Important props |
429
+ |-----------|-------------|-----------------|
430
+ | `Ginger.Control.PlayPause` | Toggle play / pause | `playLabel`, `pauseLabel`, native button props |
431
+ | `Ginger.Control.Previous` | Go to previous track | native button props |
432
+ | `Ginger.Control.Next` | Go to next track | native button props |
433
+ | `Ginger.Control.Repeat` | Cycle repeat mode | native button props |
434
+ | `Ginger.Control.Shuffle` | Toggle shuffle on/off | native button props |
435
+ | `Ginger.Control.SeekBar` | Controlled range input for time | `inputStyle`, native input props |
436
+ | `Ginger.Control.Volume` | Controlled range input for volume `0..1` | `inputStyle`, native input props |
437
+ | `Ginger.Control.Mute` | Toggle mute on/off | `muteLabel`, `unmuteLabel`, native button props |
438
+ | `Ginger.Control.PlaybackRate` | Select input for playback speed | `rates`, native select props |
439
+
440
+ Example:
441
+
442
+ ```tsx
443
+ <div className="flex items-center gap-2">
444
+ <Ginger.Control.Previous />
445
+ <Ginger.Control.PlayPause />
446
+ <Ginger.Control.Next />
447
+ <Ginger.Control.Mute />
448
+ <Ginger.Control.Volume />
449
+ <Ginger.Control.PlaybackRate />
450
+ </div>
451
+ ```
452
+
453
+ ### `Ginger.Current.*`
454
+
455
+ Displays metadata for the current track and current playback state.
456
+
457
+ Text displays:
458
+
459
+ - `Title`
460
+ - `Artist`
461
+ - `Album`
462
+ - `Description`
463
+ - `Copyright`
464
+ - `Genre`
465
+ - `Label`
466
+ - `Isrc`
467
+ - `TrackNumber`
468
+ - `Year`
469
+
470
+ Shared text-display behavior:
471
+
472
+ - Accept `className`, `style`, `fallback`, `empty`
473
+ - Accept render-prop `children?: (value, state) => ReactNode`
474
+ - Render `null` when no value exists unless `fallback` or `empty` is provided
475
+
476
+ Other current-track components:
477
+
478
+ | Component | Description | Important props |
479
+ |-----------|-------------|-----------------|
480
+ | `Ginger.Current.Artwork` | Current track artwork or playlist artwork fallback | `imgStyle`, `sizes`, `loading`, `decoding`, `onError`, display-base props |
481
+ | `Ginger.Current.Lyrics` | Track lyrics | `preserveWhitespace`, render-prop `children` |
482
+ | `Ginger.Current.FileUrl` | Track `fileUrl`, hidden unless explicitly enabled | `visible`, display-base props |
483
+ | `Ginger.Current.QueueIndex` | Current queue index | `base`, render-prop `children` |
484
+ | `Ginger.Current.QueueLength` | Queue length | render-prop `children` |
485
+ | `Ginger.Current.QueuePosition` | Combined index/length label | `base`, `separator`, render-prop `children` |
486
+ | `Ginger.Current.Elapsed` | Current time string | `format`, render-prop `children` |
487
+ | `Ginger.Current.Duration` | Duration string | `format`, render-prop `children` |
488
+ | `Ginger.Current.Remaining` | Remaining time string | `format`, render-prop `children` |
489
+ | `Ginger.Current.Progress` | Progress as text or render-prop object | render-prop `children` |
490
+ | `Ginger.Current.TimeRail` | Simple visual progress rail | `height`, display-base props |
491
+ | `Ginger.Current.PlaybackState` | Derived state label | render-prop `children` |
492
+ | `Ginger.Current.ErrorMessage` | Media error string | render-prop `children` |
493
+
494
+ Example:
495
+
496
+ ```tsx
497
+ <div>
498
+ <Ginger.Current.Title className="font-semibold" />
499
+ <Ginger.Current.Artist className="text-sm text-zinc-500" />
500
+ <Ginger.Current.Elapsed /> / <Ginger.Current.Duration />
501
+ <Ginger.Current.TimeRail className="mt-2" />
502
+ </div>
503
+ ```
504
+
505
+ ### `Ginger.Queue.*`
506
+
507
+ Displays queue or playlist metadata from `playlistMeta`.
508
+
509
+ | Component | Description |
510
+ |-----------|-------------|
511
+ | `Ginger.Queue.Title` | Playlist title |
512
+ | `Ginger.Queue.Subtitle` | Playlist subtitle |
513
+ | `Ginger.Queue.Description` | Playlist description |
514
+ | `Ginger.Queue.Copyright` | Playlist copyright |
515
+ | `Ginger.Queue.Artwork` | Playlist artwork |
516
+
517
+ These components follow the same fallback/empty behavior as other display components. `Ginger.Queue.Artwork` also accepts `imgStyle`.
518
+
519
+ ### `Ginger.Playlist`
520
+
521
+ Renders the current queue as a clickable list.
522
+
523
+ Props:
524
+
525
+ | Prop | Type | Default | Description |
526
+ |------|------|---------|-------------|
527
+ | `children` | `ReactNode` | `undefined` | Manual mode rows |
528
+ | `rowStyle` | `CSSProperties` | `undefined` | Auto-mode button style override |
529
+ | `renderTrack` | `(track, index, isActive) => ReactNode` | `undefined` | Auto-mode custom row content |
530
+ | `playOnSelect` | `boolean` | `true` | Click plays immediately if true |
531
+ | `...rest` | `HTMLAttributes<HTMLUListElement>` | - | Props passed to the root `<ul>` |
532
+
533
+ Modes:
534
+
535
+ - **Auto mode:** no `children`; Ginger maps `state.tracks` for you
536
+ - **Manual mode:** pass your own rows; usually map `useGinger().state.tracks`
537
+
538
+ Auto mode example:
539
+
540
+ ```tsx
541
+ <Ginger.Playlist
542
+ className="space-y-1"
543
+ renderTrack={(track, index, active) => (
544
+ <span style={{ fontWeight: active ? 600 : 400 }}>
545
+ {index + 1}. {track.title}
546
+ </span>
547
+ )}
548
+ />
549
+ ```
550
+
551
+ ### `Ginger.Playlist.Track`
552
+
553
+ Row helper for manual playlist rendering. Must be used inside `Ginger.Playlist`.
554
+
555
+ Props:
556
+
557
+ | Prop | Type | Description |
558
+ |------|------|-------------|
559
+ | `index` | `number` | Queue index for the row |
560
+ | `liProps` | `LiHTMLAttributes<HTMLLIElement>` | Props for the wrapper `<li>` |
561
+ | `children` | `ReactNode` | Optional custom content |
562
+ | `...rest` | `ButtonHTMLAttributes<HTMLButtonElement>` | Props for the row button |
563
+
564
+ Manual mode example:
565
+
566
+ ```tsx
567
+ import { Ginger, useGinger } from "@lucaismyname/ginger";
568
+
569
+ function PlaylistManual() {
570
+ const { state } = useGinger();
571
+
572
+ return (
573
+ <Ginger.Playlist playOnSelect={false}>
574
+ {state.tracks.map((track, i) => (
575
+ <Ginger.Playlist.Track
576
+ key={track.id ?? `${track.fileUrl}-${i}`}
577
+ index={i}
578
+ className="w-full rounded-lg px-3 py-2 text-left"
579
+ >
580
+ {track.title}
581
+ </Ginger.Playlist.Track>
582
+ ))}
583
+ </Ginger.Playlist>
584
+ );
585
+ }
586
+ ```
587
+
588
+ ## Types
589
+
590
+ ### `Track`
591
+
592
+ ```ts
593
+ type Track = {
594
+ id?: string;
595
+ title: string;
596
+ fileUrl: string;
597
+ artist?: string;
598
+ copyright?: string;
599
+ description?: string;
600
+ album?: string;
601
+ artworkUrl?: string;
602
+ genre?: string;
603
+ year?: number;
604
+ label?: string;
605
+ isrc?: string;
606
+ trackNumber?: number;
607
+ lyrics?: string;
608
+ durationSeconds?: number;
609
+ };
610
+ ```
611
+
612
+ Use `id` when possible for stable identity, especially if duplicate `fileUrl` values can appear in a queue.
613
+
614
+ ### `PlaylistMeta`
615
+
616
+ ```ts
617
+ type PlaylistMeta = {
618
+ id?: string;
619
+ title?: string;
620
+ subtitle?: string;
621
+ artworkUrl?: string;
622
+ copyright?: string;
623
+ description?: string;
624
+ };
625
+ ```
626
+
627
+ ## Styling
628
+
629
+ Ginger is designed to work with your own CSS. Most components accept `className` and `style`, and the provider wrapper exposes a few CSS variables.
630
+
631
+ CSS variables available on `Ginger.Provider`:
632
+
633
+ - `--ginger-primary-color`
634
+ - `--ginger-muted-color`
635
+ - `--ginger-font-size`
636
+ - `--ginger-font-family`
637
+ - `--ginger-playlist-row-padding`
638
+ - `--ginger-artwork-radius`
639
+ - `--ginger-artwork-bg`
640
+ - `--ginger-playlist-active-bg` (playlist current row)
641
+ - `--ginger-buffer-color` (buffered range in `TimeRail` / `BufferRail`)
642
+ - `--ginger-focus-ring` (documented for your own focus styles; native controls vary by browser)
643
+
644
+ The root element under `Ginger.Provider` sets **`data-ginger-playback`** to one of `idle` \| `loading` \| `playing` \| `paused` \| `ended` \| `error` so you can target themes in CSS (e.g. `[data-ginger-playback="error"]`).
645
+
646
+ Example:
647
+
648
+ ```tsx
649
+ <Ginger.Provider
650
+ initialTracks={tracks}
651
+ style={{
652
+ ["--ginger-primary-color" as string]: "#0f172a",
653
+ ["--ginger-muted-color" as string]: "#64748b",
654
+ ["--ginger-artwork-radius" as string]: "16px",
655
+ }}
656
+ >
657
+ <Ginger.Player />
658
+ {/* ... */}
659
+ </Ginger.Provider>
660
+ ```
661
+
662
+ ## Building custom UI
663
+
664
+ - **`useGinger()`** — One object with merged `state`, derived fields (`duration`, `progress`, `playbackUi`, …), actions, `dispatch`, and `audioRef`. Best default when you want everything in one place.
665
+
666
+ - **`useGingerPlayback()`** / **`useGingerMedia()`** — Subscribe to queue/transport vs time/volume/buffering separately so dense UIs re-render less often.
667
+
668
+ - **`useGingerState()`** — Merged `GingerState` only (no actions); use inside custom display components together with hooks above for controls.
669
+
670
+ - **Headless control bindings** (bind to your own components): **`useSeekBarBinding()`**, **`useVolumeSlider()`**, **`usePlayPauseBinding({ playAriaLabel?, pauseAriaLabel? })`**. Each returns props such as `value`, `min`, `max`, handlers, and `ariaLabel` / `ariaValueText` where relevant.
671
+
672
+ - **Locale** — Pass **`locale={partialMessages}`** on `Ginger.Provider` (type **`GingerLocaleMessages`**) to translate built-in control strings; **`useGingerLocale()`** reads the merged messages anywhere under the provider.
673
+
674
+ - **Track extras** — Optional **`metadata?: Record<string, unknown>`** on **`Track`** (and on **`PlaylistMeta`**) is ignored by core logic; use it for badges, flags, or UI-only fields.
675
+
676
+ - **Buffered UI** — **`Ginger.Current.BufferRail`** shows load progress; **`Ginger.Current.TimeRail`** supports **`showBuffered`** to stack a buffered layer behind the played segment.
677
+
678
+ Recipes below cover queue lifecycle and media edge cases.
679
+
680
+ ## Recipes
681
+
682
+ ### Updating the queue after mount
683
+
684
+ `initialTracks`, `initialIndex`, and other `initial*` props apply **only on the first mount** of `Ginger.Provider`. To replace the queue from app state, use `setQueue` / `playTrackAt` / `selectTrackAt` from `useGinger()`, or call `init({ tracks, ... })` for a full reset (same as `INIT`).
685
+
686
+ To re-run initialization when a parent identifier changes (for example switching albums), pass **`initialStateKey`** (e.g. `initialStateKey={albumId}`). When that key changes, Ginger dispatches `INIT` using the **current** `initialTracks`, `initialIndex`, and other `initial*` props.
687
+
688
+ ### Duplicate URLs and stable `id`s
689
+
690
+ If two tracks share the same `fileUrl`, set a unique **`id`** on each `Track` so shuffle/unshuffle and queue identity resolve the correct row.
691
+
692
+ ### CORS and `<Ginger.Player />`
693
+
694
+ Cross-origin audio must be served with compatible CORS headers. If you need the browser to treat the response as CORS-enabled (for example when reading certain metadata), pass **`crossOrigin`** on `Ginger.Player` (e.g. `"anonymous"`).
695
+
696
+ ### Autoplay and `play()` failures
697
+
698
+ If the browser blocks playback (autoplay policy) or `HTMLMediaElement.play()` rejects for another reason, the player dispatches a media error with a short message. **`onError`** still runs (from `errorMessage` in state), and **`Ginger.Current.ErrorMessage`** shows the same string. Associate controls with a visible label using the `id` on `Ginger.Control.SeekBar` and a `<label htmlFor="…">` in your UI.
699
+
700
+ ## Notes
701
+
702
+ ### CORS
703
+
704
+ `fileUrl` must be fetchable by the browser. Cross-origin media must be served with compatible CORS headers.
705
+
706
+ ### SSR
707
+
708
+ There is no `window` at import time, but playback only starts when the audio element mounts on the client. In frameworks with server rendering, render the player in a client component or mount it after hydration.
709
+
710
+ ### Queue updates after mount
711
+
712
+ See [Recipes — Updating the queue after mount](#updating-the-queue-after-mount).
713
+
714
+ ## Monorepo Development
715
+
716
+ | Path | Purpose |
717
+ |------|---------|
718
+ | [`packages/ginger`](packages/ginger) | Publishable library (`@lucaismyname/ginger`) |
719
+ | [`apps/demo`](apps/demo) | Demo app with working examples |
720
+
721
+ ```bash
722
+ npm install --include=dev
723
+ npm run build -w @lucaismyname/ginger
724
+ npm run dev -w ginger-demo
725
+ ```
726
+
727
+ If your npm config sets `omit=dev`, devDependencies may not install. Use `--include=dev` once or adjust your npm config.
728
+
729
+ ## Publish
730
+
731
+ Do **not** run `npm publish` at the repo root. The root package is **`private: true`**.
732
+
733
+ Publish from the workspace:
734
+
735
+ ```bash
736
+ npm run publish:lib
737
+ ```
738
+
739
+ Or from `packages/ginger`:
740
+
741
+ ```bash
742
+ npm publish --access public
743
+ ```
744
+
745
+ `prepublishOnly` runs the library build automatically.