muzical-ui 0.1.1

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 (117) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CLAUDE.md +1 -0
  4. package/LICENSE.md +21 -0
  5. package/README.md +36 -0
  6. package/app/favicon.ico +0 -0
  7. package/app/globals.css +67 -0
  8. package/app/layout.tsx +49 -0
  9. package/app/musicbrainz/page.tsx +6 -0
  10. package/app/page.tsx +12 -0
  11. package/app/settings/display/page.tsx +11 -0
  12. package/app/settings/layout.tsx +19 -0
  13. package/app/settings/library/page.tsx +11 -0
  14. package/app/settings/page.tsx +5 -0
  15. package/app/settings/playback/page.tsx +11 -0
  16. package/app/settings/youtube/page.tsx +11 -0
  17. package/bin/stt-ui.js +25 -0
  18. package/components/AlbumCoverThumb.tsx +82 -0
  19. package/components/BrowsePanel.tsx +64 -0
  20. package/components/DisplaySettingsPanel.tsx +30 -0
  21. package/components/FavoriteStarButton.tsx +41 -0
  22. package/components/LibraryBrowser.tsx +1180 -0
  23. package/components/LibraryProvider.tsx +1023 -0
  24. package/components/LibraryScanNotification.tsx +62 -0
  25. package/components/LibraryScanOptionsSection.tsx +123 -0
  26. package/components/LibrarySettingsPanel.tsx +116 -0
  27. package/components/LibraryStatistics.tsx +54 -0
  28. package/components/MusicBrainzBrowser.tsx +395 -0
  29. package/components/MusicBrainzTrackRow.tsx +52 -0
  30. package/components/MusicPlayer.tsx +1531 -0
  31. package/components/PanelResizeHandle.tsx +65 -0
  32. package/components/PlaybackSettingsPanel.tsx +32 -0
  33. package/components/QueueLoadingSpinner.tsx +19 -0
  34. package/components/SettingsNav.tsx +37 -0
  35. package/components/SettingsOverview.tsx +34 -0
  36. package/components/SettingsShell.tsx +47 -0
  37. package/components/SettingsSwitchRow.tsx +38 -0
  38. package/components/ThemeProvider.tsx +75 -0
  39. package/components/ThemeToggle.tsx +38 -0
  40. package/components/YouTubeSettingsPanel.tsx +79 -0
  41. package/components/YouTubeStreamNotification.tsx +30 -0
  42. package/components/format-library-root-added.ts +13 -0
  43. package/components/settings-nav-items.ts +40 -0
  44. package/eslint.config.mjs +18 -0
  45. package/lib/format-duration.ts +9 -0
  46. package/lib/format-total-library-duration.ts +14 -0
  47. package/lib/library/audio-filename.ts +31 -0
  48. package/lib/library/collect-tracks-for-meta.ts +91 -0
  49. package/lib/library/compute-library-stats.ts +37 -0
  50. package/lib/library/constants.ts +27 -0
  51. package/lib/library/cover-bytes-cache.ts +59 -0
  52. package/lib/library/default-library-scan-preferences.ts +13 -0
  53. package/lib/library/extract-cover-bytes-from-audio-file.ts +41 -0
  54. package/lib/library/extract-cover-object-url-from-audio-file.ts +31 -0
  55. package/lib/library/favorite-keys.ts +14 -0
  56. package/lib/library/format-fs-access-error.ts +29 -0
  57. package/lib/library/idb.ts +270 -0
  58. package/lib/library/read-audio-metadata.ts +34 -0
  59. package/lib/library/read-stored-library-scan-preferences.ts +43 -0
  60. package/lib/library/resolve-track-file.ts +26 -0
  61. package/lib/library/scan-preferences-to-tree-options.ts +15 -0
  62. package/lib/library/scan-progress-label.ts +18 -0
  63. package/lib/library/scan-progress-percent.ts +19 -0
  64. package/lib/library/scan-progress-tick.ts +9 -0
  65. package/lib/library/scan-tree.ts +191 -0
  66. package/lib/library/write-stored-library-scan-preferences.ts +19 -0
  67. package/lib/mock-playlist.ts +47 -0
  68. package/lib/musicbrainz/build-musicbrainz-lucene-queries.ts +46 -0
  69. package/lib/musicbrainz/escape-lucene-term.ts +6 -0
  70. package/lib/musicbrainz/fetch-musicbrainz-json.ts +55 -0
  71. package/lib/musicbrainz/fetch-release-tracks.ts +53 -0
  72. package/lib/musicbrainz/group-tracks-by-album.ts +26 -0
  73. package/lib/musicbrainz/group-tracks-by-artist.ts +23 -0
  74. package/lib/musicbrainz/merge-tracks-by-id.ts +16 -0
  75. package/lib/musicbrainz/musicbrainz-recording-to-track.ts +42 -0
  76. package/lib/musicbrainz/pick-preferred-release.ts +32 -0
  77. package/lib/musicbrainz/pick-release-group-release-id.ts +12 -0
  78. package/lib/musicbrainz/release-group-artist-name.ts +13 -0
  79. package/lib/musicbrainz/search-musicbrainz-recordings.ts +33 -0
  80. package/lib/musicbrainz/search-musicbrainz-release-groups.ts +24 -0
  81. package/lib/musicbrainz/search-musicbrainz.ts +65 -0
  82. package/lib/musicbrainz/types.ts +43 -0
  83. package/lib/musicbrainz.ts +3 -0
  84. package/lib/playback/build-queue-from-snapshot.ts +49 -0
  85. package/lib/playback/parse-persisted-track.ts +45 -0
  86. package/lib/playback/read-stored-playback-snapshot.ts +45 -0
  87. package/lib/playback/write-stored-playback-snapshot.ts +19 -0
  88. package/lib/theme-constants.ts +4 -0
  89. package/lib/theme-init-script.ts +9 -0
  90. package/lib/youtube/clear-youtube-data-api-blocked.ts +8 -0
  91. package/lib/youtube/collect-youtube-prefetch-targets.ts +20 -0
  92. package/lib/youtube/is-youtube-quota-error-message.ts +7 -0
  93. package/lib/youtube/mark-youtube-data-api-blocked.ts +8 -0
  94. package/lib/youtube/prefetch-youtube-video-ids.ts +55 -0
  95. package/lib/youtube/read-stored-youtube-api-key.ts +16 -0
  96. package/lib/youtube/read-youtube-data-api-blocked.ts +12 -0
  97. package/lib/youtube/search-youtube-video-id.ts +60 -0
  98. package/lib/youtube/should-use-youtube-search-playback.ts +19 -0
  99. package/lib/youtube/write-stored-youtube-api-key.ts +18 -0
  100. package/next.config.ts +7 -0
  101. package/package.json +94 -0
  102. package/pnpm-workspace.yaml +6 -0
  103. package/postcss.config.mjs +7 -0
  104. package/public/file.svg +1 -0
  105. package/public/globe.svg +1 -0
  106. package/public/next.svg +1 -0
  107. package/public/vercel.svg +1 -0
  108. package/public/window.svg +1 -0
  109. package/tsconfig.json +34 -0
  110. package/types/file-system-access.d.ts +22 -0
  111. package/types/library-root-meta.ts +5 -0
  112. package/types/library-scan-preferences.ts +9 -0
  113. package/types/library-scan-progress.ts +8 -0
  114. package/types/persisted-playback-snapshot.ts +11 -0
  115. package/types/queue.ts +7 -0
  116. package/types/scan-tree-options.ts +6 -0
  117. package/types/track.ts +29 -0
@@ -0,0 +1,1531 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
5
+ import BrowsePanel from '@/components/BrowsePanel'
6
+ import { useLibrary } from '@/components/LibraryProvider'
7
+
8
+ declare global {
9
+ interface Window {
10
+ onYouTubeIframeAPIReady?: () => void
11
+ YT?: any
12
+ }
13
+ }
14
+ import type { Track } from '@/types/track'
15
+ import { formatDuration } from '@/lib/format-duration'
16
+ import { getCoverBytesForTrack } from '@/lib/library/cover-bytes-cache'
17
+ import ThemeToggle from '@/components/ThemeToggle'
18
+ import FavoriteStarButton from '@/components/FavoriteStarButton'
19
+ import PanelResizeHandle from '@/components/PanelResizeHandle'
20
+ import QueueLoadingSpinner from '@/components/QueueLoadingSpinner'
21
+ import YouTubeStreamNotification from '@/components/YouTubeStreamNotification'
22
+ import readStoredYoutubeApiKey from '@/lib/youtube/read-stored-youtube-api-key'
23
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
24
+ import shouldUseYoutubeSearchPlayback from '@/lib/youtube/should-use-youtube-search-playback'
25
+
26
+ const STORAGE_LIBRARY_PANEL_PX = 'muzical.panelWidth.library'
27
+ const STORAGE_QUEUE_PANEL_PX = 'muzical.panelWidth.queue'
28
+ const STORAGE_REPEAT_MODE = 'muzical.repeatMode'
29
+ const STORAGE_SHUFFLE = 'muzical.shuffle'
30
+ const STORAGE_PLAYBACK_RATE = 'muzical.playbackRate'
31
+
32
+ type RepeatMode = 'off' | 'all' | 'one'
33
+
34
+ const PLAYBACK_RATES: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 2]
35
+
36
+ function readStoredRepeatMode(): RepeatMode {
37
+ if (typeof window === 'undefined') return 'all'
38
+ const v = window.localStorage.getItem(STORAGE_REPEAT_MODE)
39
+ if (v === 'off' || v === 'all' || v === 'one') return v
40
+ return 'all'
41
+ }
42
+
43
+ function readStoredShuffle(): boolean {
44
+ if (typeof window === 'undefined') return false
45
+ try {
46
+ return window.localStorage.getItem(STORAGE_SHUFFLE) === '1'
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ function readStoredPlaybackRate(): number {
53
+ if (typeof window === 'undefined') return 1
54
+ const v = Number.parseFloat(window.localStorage.getItem(STORAGE_PLAYBACK_RATE) ?? '')
55
+ if (!Number.isFinite(v)) return 1
56
+ return PLAYBACK_RATES.includes(v) ? v : 1
57
+ }
58
+
59
+ function persistRepeatMode(mode: RepeatMode): void {
60
+ try {
61
+ window.localStorage.setItem(STORAGE_REPEAT_MODE, mode)
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ }
66
+
67
+ function persistShuffle(on: boolean): void {
68
+ try {
69
+ window.localStorage.setItem(STORAGE_SHUFFLE, on ? '1' : '0')
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+
75
+ function persistPlaybackRate(rate: number): void {
76
+ try {
77
+ window.localStorage.setItem(STORAGE_PLAYBACK_RATE, String(rate))
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
82
+ const LIBRARY_PANEL_MIN = 300
83
+ const LIBRARY_PANEL_MAX = 960
84
+ const QUEUE_PANEL_MIN = 420
85
+ // Minimum width for the player panel (aside). Used to clamp resizes.
86
+ const PLAYER_PANEL_MIN = 350
87
+
88
+ function clampPanelPx(n: number, lo: number, hi: number): number {
89
+ return Math.min(hi, Math.max(lo, n))
90
+ }
91
+
92
+ function readStoredPanelPx(key: string, fallback: number): number {
93
+ if (typeof window === 'undefined') return fallback
94
+ const v = Number.parseInt(window.localStorage.getItem(key) ?? '', 10)
95
+ return Number.isFinite(v) ? v : fallback
96
+ }
97
+
98
+ function clampLibraryQueueWidths(
99
+ rowWidthPx: number,
100
+ libraryPx: number,
101
+ queuePx: number,
102
+ ): { libraryPx: number; queuePx: number } {
103
+ if (rowWidthPx <= 0) return { libraryPx, queuePx }
104
+ const maxSum = Math.max(
105
+ LIBRARY_PANEL_MIN + QUEUE_PANEL_MIN,
106
+ rowWidthPx - PLAYER_PANEL_MIN,
107
+ )
108
+ let L = clampPanelPx(libraryPx, LIBRARY_PANEL_MIN, LIBRARY_PANEL_MAX)
109
+ // Intentionally no hard `QUEUE_PANEL_MAX` cap: the player panel minimum width is
110
+ // enforced via `maxSum` below, and we don't want queue width to prevent it.
111
+ let Q = Math.max(QUEUE_PANEL_MIN, queuePx)
112
+ if (L + Q > maxSum) {
113
+ const over = L + Q - maxSum
114
+ const takeFromL = Math.min(over, L - LIBRARY_PANEL_MIN)
115
+ L -= takeFromL
116
+ let r = over - takeFromL
117
+ const takeFromQ = Math.min(r, Q - QUEUE_PANEL_MIN)
118
+ Q -= takeFromQ
119
+ r -= takeFromQ
120
+ if (r > 0) L = Math.max(LIBRARY_PANEL_MIN, L - r)
121
+ }
122
+ return { libraryPx: L, queuePx: Q }
123
+ }
124
+
125
+ function clampQueuePanelWidth(rowWidthPx: number, libraryPx: number, queuePx: number): number {
126
+ const maxQ = rowWidthPx - libraryPx - PLAYER_PANEL_MIN
127
+ return clampPanelPx(queuePx, QUEUE_PANEL_MIN, Math.max(QUEUE_PANEL_MIN, maxQ))
128
+ }
129
+
130
+ function IconPlay(props: { className?: string }) {
131
+ return (
132
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
133
+ <path d="M8 5v14l11-7L8 5z" />
134
+ </svg>
135
+ )
136
+ }
137
+
138
+ function IconPause(props: { className?: string }) {
139
+ return (
140
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
141
+ <path d="M6 5h4v14H6V5zm8 0h4v14h-4V5z" />
142
+ </svg>
143
+ )
144
+ }
145
+
146
+ function IconSkipBack(props: { className?: string }) {
147
+ return (
148
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
149
+ <path d="M6 6h2v12H6V6zm3.5 6 8.5 6V6l-8.5 6z" />
150
+ </svg>
151
+ )
152
+ }
153
+
154
+ function IconSkipForward(props: { className?: string }) {
155
+ return (
156
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
157
+ <path d="M16 18h2V6h-2v12zM6 18l8.5-6L6 6v12z" />
158
+ </svg>
159
+ )
160
+ }
161
+
162
+ function IconVolume(props: { className?: string }) {
163
+ return (
164
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
165
+ <path d="M3 10v4h4l5 5V5L7 10H3zm13.5 2A4.5 4.5 0 0 0 14 7.97v8.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" />
166
+ </svg>
167
+ )
168
+ }
169
+
170
+ function IconQueue(props: { className?: string }) {
171
+ return (
172
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
173
+ <path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2zm12 1v6l5-3-5-3z" />
174
+ </svg>
175
+ )
176
+ }
177
+
178
+ function IconSettings(props: { className?: string }) {
179
+ return (
180
+ <svg
181
+ viewBox="0 0 24 24"
182
+ fill="none"
183
+ stroke="currentColor"
184
+ strokeWidth="2"
185
+ strokeLinecap="round"
186
+ strokeLinejoin="round"
187
+ aria-hidden
188
+ className={props.className}
189
+ >
190
+ <circle cx="12" cy="12" r="3" />
191
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
192
+ </svg>
193
+ )
194
+ }
195
+
196
+ function IconRepeatLoop(props: { className?: string; dimmed?: boolean }) {
197
+ return (
198
+ <svg
199
+ viewBox="0 0 24 24"
200
+ fill="none"
201
+ stroke="currentColor"
202
+ strokeWidth="2"
203
+ strokeLinecap="round"
204
+ strokeLinejoin="round"
205
+ aria-hidden
206
+ className={[props.className, props.dimmed ? 'opacity-40' : ''].filter(Boolean).join(' ')}
207
+ >
208
+ <path d="M17 1l4 4-4 4" />
209
+ <path d="M3 11V9a4 4 0 0 1 4-4h14" />
210
+ <path d="M7 23l-4-4 4-4" />
211
+ <path d="M21 13v2a4 4 0 0 1-4 4H3" />
212
+ </svg>
213
+ )
214
+ }
215
+
216
+ function IconShuffle(props: { className?: string }) {
217
+ return (
218
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden className={props.className}>
219
+ <path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" strokeLinecap="round" strokeLinejoin="round" />
220
+ </svg>
221
+ )
222
+ }
223
+
224
+ /**
225
+ * Local-library player: queue from scanned folders, `<audio>` playback via object URLs.
226
+ */
227
+ export default function MusicPlayer() {
228
+ const {
229
+ queue,
230
+ libraryTracks,
231
+ isScanning,
232
+ resolveFileForTrack,
233
+ bumpTrackDuration,
234
+ removeFromQueue,
235
+ clearQueue,
236
+ compactLists,
237
+ reorderQueueItems,
238
+ recentlyPlayedTrackIds,
239
+ recordRecentlyPlayedTrack,
240
+ isFavoriteSong,
241
+ toggleFavoriteTrack,
242
+ addToQueue,
243
+ favoriteSongIds,
244
+ playbackRestore,
245
+ consumePlaybackRestore,
246
+ reportPlayback,
247
+ isQueueReady,
248
+ } = useLibrary()
249
+ const [activeQueueId, setActiveQueueId] = useState<string | null>(null)
250
+ const [isPlaying, setIsPlaying] = useState(false)
251
+ const [positionSec, setPositionSec] = useState(0)
252
+ const [mediaDuration, setMediaDuration] = useState(0)
253
+ const [volume, setVolume] = useState(0.85)
254
+ const [loadError, setLoadError] = useState<string | null>(null)
255
+ const [streamResolving, setStreamResolving] = useState(false)
256
+ const [forceSearchFallback, setForceSearchFallback] = useState(false)
257
+ const [coverArtUrl, setCoverArtUrl] = useState<string | null>(null)
258
+ const [layoutLg, setLayoutLg] = useState(false)
259
+ const [libraryPanelPx, setLibraryPanelPx] = useState(440)
260
+ const [queuePanelPx, setQueuePanelPx] = useState(300)
261
+ const [repeatMode, setRepeatMode] = useState<RepeatMode>('all')
262
+ const [shuffle, setShuffle] = useState(false)
263
+ const [playbackRate, setPlaybackRate] = useState(1)
264
+
265
+ const mainRowRef = useRef<HTMLDivElement>(null)
266
+ const shuffleHistoryRef = useRef<number[]>([])
267
+ const pendingRestorePositionRef = useRef<number | null>(null)
268
+ const activeQueueIdRef = useRef<string | null>(null)
269
+ const lastPlaybackReportMsRef = useRef(0)
270
+ const libraryPanelPxRef = useRef(440)
271
+ const queuePanelPxRef = useRef(300)
272
+ const [dragOverQueueId, setDragOverQueueId] = useState<string | null>(null)
273
+ const [draggingQueueId, setDraggingQueueId] = useState<string | null>(null)
274
+ const panelResizeSessionRef = useRef<
275
+ | { kind: 'library-queue'; startLib: number; startQ: number }
276
+ | { kind: 'queue-player'; startQ: number }
277
+ | null
278
+ >(null)
279
+
280
+ const audioRef = useRef<HTMLAudioElement | null>(null)
281
+ const objectUrlRef = useRef<string | null>(null)
282
+ const coverObjectUrlRef = useRef<string | null>(null)
283
+ const youtubePlayerRef = useRef<any | null>(null)
284
+ const youtubeContainerRef = useRef<HTMLDivElement | null>(null)
285
+ const isPlayingRef = useRef(isPlaying)
286
+ const playbackRateRef = useRef(playbackRate)
287
+
288
+ useLayoutEffect(() => {
289
+ isPlayingRef.current = isPlaying
290
+ }, [isPlaying])
291
+
292
+ useLayoutEffect(() => {
293
+ playbackRateRef.current = playbackRate
294
+ }, [playbackRate])
295
+
296
+ useLayoutEffect(() => {
297
+ libraryPanelPxRef.current = libraryPanelPx
298
+ queuePanelPxRef.current = queuePanelPx
299
+ }, [libraryPanelPx, queuePanelPx])
300
+
301
+ useEffect(() => {
302
+ queueMicrotask(() => {
303
+ setLibraryPanelPx(readStoredPanelPx(STORAGE_LIBRARY_PANEL_PX, 440))
304
+ setQueuePanelPx(readStoredPanelPx(STORAGE_QUEUE_PANEL_PX, 300))
305
+ setRepeatMode(readStoredRepeatMode())
306
+ setShuffle(readStoredShuffle())
307
+ setPlaybackRate(readStoredPlaybackRate())
308
+ })
309
+ }, [])
310
+
311
+ useEffect(() => {
312
+ if (!shuffle) shuffleHistoryRef.current = []
313
+ }, [shuffle])
314
+
315
+ useLayoutEffect(() => {
316
+ if (typeof window === 'undefined') return
317
+ const mq = window.matchMedia('(min-width: 1024px)')
318
+ const apply = (): void => {
319
+ setLayoutLg(mq.matches)
320
+ }
321
+ apply()
322
+ mq.addEventListener('change', apply)
323
+ return () => mq.removeEventListener('change', apply)
324
+ }, [])
325
+
326
+ useEffect(() => {
327
+ activeQueueIdRef.current = activeQueueId
328
+ }, [activeQueueId])
329
+
330
+ useEffect(() => {
331
+ if (!playbackRestore) return
332
+ const { activeQueueId: nextActiveId, positionSec: nextPos } = playbackRestore
333
+ pendingRestorePositionRef.current = nextPos
334
+ consumePlaybackRestore()
335
+ void Promise.resolve().then(() => {
336
+ setActiveQueueId(nextActiveId)
337
+ setPositionSec(nextPos)
338
+ setIsPlaying(false)
339
+ })
340
+ }, [playbackRestore, consumePlaybackRestore])
341
+
342
+ useEffect(() => {
343
+ const el = audioRef.current
344
+ const pos = el && Number.isFinite(el.currentTime) ? el.currentTime : 0
345
+ reportPlayback(activeQueueId, pos)
346
+ }, [activeQueueId, reportPlayback])
347
+
348
+ const activeIndex = useMemo(() => {
349
+ if (queue.length === 0) return -1
350
+ if (activeQueueId) {
351
+ const i = queue.findIndex((q) => q.queueId === activeQueueId)
352
+ if (i >= 0) return i
353
+ }
354
+ return 0
355
+ }, [queue, activeQueueId])
356
+
357
+ const current: Track | undefined = activeIndex >= 0 ? queue[activeIndex]?.track : undefined
358
+ const lastRecordedTrackIdRef = useRef<string | null>(null)
359
+
360
+ useEffect(() => {
361
+ if (!isPlaying) return
362
+ const id = current?.id ?? ''
363
+ if (!id) return
364
+ if (lastRecordedTrackIdRef.current === id) return
365
+ lastRecordedTrackIdRef.current = id
366
+ recordRecentlyPlayedTrack(id)
367
+ }, [current?.id, isPlaying, recordRecentlyPlayedTrack])
368
+
369
+ const recentlyPlayedTracks = useMemo(() => {
370
+ if (recentlyPlayedTrackIds.length === 0) return []
371
+ const byId = new Map<string, Track>()
372
+ for (const t of libraryTracks) byId.set(t.id, t)
373
+ const out: Track[] = []
374
+ for (const id of recentlyPlayedTrackIds) {
375
+ const t = byId.get(id)
376
+ if (t) out.push(t)
377
+ if (out.length >= 8) break
378
+ }
379
+ return out
380
+ }, [recentlyPlayedTrackIds, libraryTracks])
381
+
382
+ const suggestedTracks = useMemo(() => {
383
+ if (libraryTracks.length === 0) return []
384
+ const byId = new Map<string, Track>()
385
+ for (const t of libraryTracks) byId.set(t.id, t)
386
+
387
+ const seen = new Set<string>()
388
+ const out: Track[] = []
389
+
390
+ for (const t of recentlyPlayedTracks) {
391
+ seen.add(t.id)
392
+ }
393
+
394
+ for (const id of favoriteSongIds) {
395
+ const t = byId.get(id)
396
+ if (!t) continue
397
+ if (seen.has(t.id)) continue
398
+ seen.add(t.id)
399
+ out.push(t)
400
+ if (out.length >= 12) return out
401
+ }
402
+
403
+ for (let i = 0; i < libraryTracks.length; i++) {
404
+ const t = libraryTracks[i]
405
+ if (seen.has(t.id)) continue
406
+ seen.add(t.id)
407
+ out.push(t)
408
+ if (out.length >= 12) break
409
+ }
410
+ return out
411
+ }, [libraryTracks, favoriteSongIds, recentlyPlayedTracks])
412
+
413
+ const durationSec = useMemo(() => {
414
+ const fromTrack = current?.durationSec ?? 0
415
+ const fromMedia = Number.isFinite(mediaDuration) && mediaDuration > 0 ? mediaDuration : 0
416
+ return Math.max(fromTrack, fromMedia)
417
+ }, [current?.durationSec, mediaDuration])
418
+
419
+ const selectIndex = useCallback(
420
+ (index: number) => {
421
+ shuffleHistoryRef.current = []
422
+ setActiveQueueId(queue[index]?.queueId ?? null)
423
+ setPositionSec(0)
424
+ setLoadError(null)
425
+ setIsPlaying(true)
426
+ },
427
+ [queue],
428
+ )
429
+
430
+ const goNext = useCallback((): void => {
431
+ if (queue.length === 0) return
432
+ const idx = activeIndex >= 0 ? activeIndex : 0
433
+
434
+ if (shuffle && queue.length > 1) {
435
+ shuffleHistoryRef.current.push(idx)
436
+ let j = idx
437
+ for (let n = 0; n < 48 && j === idx; n++) {
438
+ j = Math.floor(Math.random() * queue.length)
439
+ }
440
+ setActiveQueueId(queue[j]?.queueId ?? null)
441
+ setPositionSec(0)
442
+ setLoadError(null)
443
+ setIsPlaying(true)
444
+ return
445
+ }
446
+
447
+ if (idx < queue.length - 1) {
448
+ setActiveQueueId(queue[idx + 1]?.queueId ?? null)
449
+ setPositionSec(0)
450
+ setLoadError(null)
451
+ setIsPlaying(true)
452
+ return
453
+ }
454
+ if (repeatMode === 'all') {
455
+ setActiveQueueId(queue[0]?.queueId ?? null)
456
+ setPositionSec(0)
457
+ setLoadError(null)
458
+ setIsPlaying(true)
459
+ return
460
+ }
461
+ setIsPlaying(false)
462
+ }, [activeIndex, queue, repeatMode, shuffle])
463
+
464
+ const goPrev = useCallback((): void => {
465
+ if (queue.length === 0) return
466
+ const idx = activeIndex >= 0 ? activeIndex : 0
467
+
468
+ if (shuffle && shuffleHistoryRef.current.length > 0) {
469
+ const prevIdx = shuffleHistoryRef.current.pop()
470
+ if (prevIdx !== undefined && prevIdx >= 0 && prevIdx < queue.length) {
471
+ setActiveQueueId(queue[prevIdx]?.queueId ?? null)
472
+ setPositionSec(0)
473
+ setLoadError(null)
474
+ setIsPlaying(true)
475
+ return
476
+ }
477
+ }
478
+
479
+ if (idx > 0) {
480
+ setActiveQueueId(queue[idx - 1]?.queueId ?? null)
481
+ setPositionSec(0)
482
+ setLoadError(null)
483
+ setIsPlaying(true)
484
+ return
485
+ }
486
+ if (repeatMode === 'all') {
487
+ setActiveQueueId(queue[queue.length - 1]?.queueId ?? null)
488
+ setPositionSec(0)
489
+ setLoadError(null)
490
+ setIsPlaying(true)
491
+ }
492
+ }, [activeIndex, queue, repeatMode, shuffle])
493
+
494
+ const cycleRepeatMode = useCallback((): void => {
495
+ setRepeatMode((m) => {
496
+ const next: RepeatMode = m === 'off' ? 'all' : m === 'all' ? 'one' : 'off'
497
+ persistRepeatMode(next)
498
+ return next
499
+ })
500
+ }, [])
501
+
502
+ const toggleShuffle = useCallback((): void => {
503
+ setShuffle((s) => {
504
+ const next = !s
505
+ persistShuffle(next)
506
+ return next
507
+ })
508
+ }, [])
509
+
510
+ const clampPanelsToRow = useCallback((): void => {
511
+ const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
512
+ if (rowW <= 0) return
513
+ const { libraryPx, queuePx } = clampLibraryQueueWidths(
514
+ rowW,
515
+ libraryPanelPxRef.current,
516
+ queuePanelPxRef.current,
517
+ )
518
+ setLibraryPanelPx(libraryPx)
519
+ setQueuePanelPx(queuePx)
520
+ }, [])
521
+
522
+ const persistPanelWidths = useCallback((): void => {
523
+ try {
524
+ window.localStorage.setItem(STORAGE_LIBRARY_PANEL_PX, String(libraryPanelPxRef.current))
525
+ window.localStorage.setItem(STORAGE_QUEUE_PANEL_PX, String(queuePanelPxRef.current))
526
+ } catch {
527
+ /* ignore */
528
+ }
529
+ }, [])
530
+
531
+ const onLibraryQueueResizeStart = useCallback((): void => {
532
+ panelResizeSessionRef.current = {
533
+ kind: 'library-queue',
534
+ startLib: libraryPanelPxRef.current,
535
+ startQ: queuePanelPxRef.current,
536
+ }
537
+ }, [])
538
+
539
+ const onLibraryQueueResizeMove = useCallback((dx: number): void => {
540
+ const s = panelResizeSessionRef.current
541
+ if (!s || s.kind !== 'library-queue') return
542
+ const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
543
+ const next = clampLibraryQueueWidths(rowW, s.startLib + dx, s.startQ - dx)
544
+ setLibraryPanelPx(next.libraryPx)
545
+ setQueuePanelPx(next.queuePx)
546
+ }, [])
547
+
548
+ const onQueuePlayerResizeStart = useCallback((): void => {
549
+ panelResizeSessionRef.current = { kind: 'queue-player', startQ: queuePanelPxRef.current }
550
+ }, [])
551
+
552
+ const onQueuePlayerResizeMove = useCallback((dx: number): void => {
553
+ const s = panelResizeSessionRef.current
554
+ if (!s || s.kind !== 'queue-player') return
555
+ const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
556
+ const nextQ = clampQueuePanelWidth(rowW, libraryPanelPxRef.current, s.startQ + dx)
557
+ setQueuePanelPx(nextQ)
558
+ }, [])
559
+
560
+ const onPanelResizeEnd = useCallback((): void => {
561
+ panelResizeSessionRef.current = null
562
+ persistPanelWidths()
563
+ }, [persistPanelWidths])
564
+
565
+ useEffect(() => {
566
+ if (!layoutLg) return undefined
567
+ const onResize = (): void => {
568
+ clampPanelsToRow()
569
+ }
570
+ window.addEventListener('resize', onResize)
571
+ clampPanelsToRow()
572
+ return () => window.removeEventListener('resize', onResize)
573
+ }, [layoutLg, clampPanelsToRow])
574
+
575
+ useEffect(() => {
576
+ const el = audioRef.current
577
+ if (!el) return undefined
578
+ el.volume = volume
579
+ }, [volume])
580
+
581
+ useEffect(() => {
582
+ const el = audioRef.current
583
+ if (!el) return undefined
584
+ el.playbackRate = playbackRate
585
+ }, [playbackRate])
586
+
587
+ useEffect(() => {
588
+ if (coverObjectUrlRef.current) {
589
+ URL.revokeObjectURL(coverObjectUrlRef.current)
590
+ coverObjectUrlRef.current = null
591
+ }
592
+ void Promise.resolve().then(() => {
593
+ setCoverArtUrl(null)
594
+ })
595
+
596
+ if (!current || (!current.library && !current.youtubeQuery && !current.audioUrl)) {
597
+ const el = audioRef.current
598
+ if (el) {
599
+ el.pause()
600
+ el.removeAttribute('src')
601
+ el.load()
602
+ }
603
+ if (objectUrlRef.current) {
604
+ URL.revokeObjectURL(objectUrlRef.current)
605
+ objectUrlRef.current = null
606
+ }
607
+ return undefined
608
+ }
609
+ if (!current.library) {
610
+ const el = audioRef.current
611
+ if (el) {
612
+ el.pause()
613
+ el.removeAttribute('src')
614
+ el.load()
615
+ }
616
+ if (objectUrlRef.current) {
617
+ URL.revokeObjectURL(objectUrlRef.current)
618
+ objectUrlRef.current = null
619
+ }
620
+ return undefined
621
+ }
622
+ let cancelled = false
623
+ const pendingPos = pendingRestorePositionRef.current
624
+ const rid = requestAnimationFrame(() => {
625
+ setMediaDuration(0)
626
+ setPositionSec(pendingPos ?? 0)
627
+ })
628
+ void (async (): Promise<void> => {
629
+ setLoadError(null)
630
+ const file = await resolveFileForTrack(current)
631
+ if (cancelled) return
632
+ if (!file) {
633
+ setLoadError('Could not read this file from the library.')
634
+ return
635
+ }
636
+ const coverBytesPromise = getCoverBytesForTrack(current.id, file)
637
+ const url = URL.createObjectURL(file)
638
+ if (objectUrlRef.current) {
639
+ URL.revokeObjectURL(objectUrlRef.current)
640
+ }
641
+ objectUrlRef.current = url
642
+ if (cancelled) {
643
+ return
644
+ }
645
+ const coverBytes = await coverBytesPromise
646
+ if (cancelled) return
647
+ if (coverBytes) {
648
+ const coverUrl = URL.createObjectURL(new Blob([coverBytes.data], { type: coverBytes.mime }))
649
+ coverObjectUrlRef.current = coverUrl
650
+ setCoverArtUrl(coverUrl)
651
+ }
652
+ const el = audioRef.current
653
+ if (!el || cancelled) {
654
+ return
655
+ }
656
+ el.src = url
657
+ el.load()
658
+ el.playbackRate = playbackRateRef.current
659
+ if (isPlayingRef.current) {
660
+ void el.play().catch((e: unknown) => {
661
+ setLoadError(e instanceof Error ? e.message : 'Playback failed')
662
+ setIsPlaying(false)
663
+ })
664
+ }
665
+ })()
666
+ return (): void => {
667
+ cancelled = true
668
+ cancelAnimationFrame(rid)
669
+ if (coverObjectUrlRef.current) {
670
+ URL.revokeObjectURL(coverObjectUrlRef.current)
671
+ coverObjectUrlRef.current = null
672
+ }
673
+ }
674
+ }, [current, resolveFileForTrack])
675
+
676
+ const useSearchPlayback = shouldUseYoutubeSearchPlayback(
677
+ current?.youtubeQuery,
678
+ current?.youtubeVideoId,
679
+ readStoredYoutubeApiKey().length > 0,
680
+ forceSearchFallback,
681
+ )
682
+ const youtubeStreamActive = Boolean(
683
+ current?.youtubeQuery && (current?.youtubeVideoId || useSearchPlayback),
684
+ )
685
+
686
+ useEffect(() => {
687
+ setForceSearchFallback(false)
688
+ const query = current?.youtubeQuery?.trim()
689
+ if (!query || current?.youtubeVideoId) return undefined
690
+ if (readYoutubeDataApiBlocked() || !readStoredYoutubeApiKey()) return undefined
691
+ const t = window.setTimeout(() => setForceSearchFallback(true), 10000)
692
+ return (): void => {
693
+ window.clearTimeout(t)
694
+ }
695
+ }, [current?.id, current?.youtubeQuery, current?.youtubeVideoId])
696
+
697
+ useEffect(() => {
698
+ if (!current?.youtubeQuery) {
699
+ setStreamResolving(false)
700
+ return undefined
701
+ }
702
+ if (current.youtubeVideoId || useSearchPlayback) {
703
+ setStreamResolving(false)
704
+ return undefined
705
+ }
706
+ if (!readStoredYoutubeApiKey() || readYoutubeDataApiBlocked()) {
707
+ setStreamResolving(false)
708
+ return undefined
709
+ }
710
+ setStreamResolving(true)
711
+ return undefined
712
+ }, [current?.id, current?.youtubeQuery, current?.youtubeVideoId, useSearchPlayback])
713
+
714
+ useEffect(() => {
715
+ const videoId = current?.youtubeVideoId?.trim()
716
+ const query = current?.youtubeQuery?.trim()
717
+ if (!videoId && !query) {
718
+ if (youtubePlayerRef.current) {
719
+ try {
720
+ youtubePlayerRef.current.destroy()
721
+ } catch {
722
+ /* ignore */
723
+ }
724
+ youtubePlayerRef.current = null
725
+ }
726
+ return undefined
727
+ }
728
+
729
+ if (!videoId && !useSearchPlayback) {
730
+ return undefined
731
+ }
732
+
733
+ const basePlayerVars = {
734
+ autoplay: isPlaying ? 1 : 0,
735
+ controls: 0,
736
+ disablekb: 1,
737
+ fs: 0,
738
+ rel: 0,
739
+ modestbranding: 1,
740
+ iv_load_policy: 3,
741
+ origin: window.location.origin,
742
+ }
743
+
744
+ const ensurePlayer = (): void => {
745
+ if (!window.YT?.Player || !youtubeContainerRef.current) {
746
+ return
747
+ }
748
+ const existingPlayer = youtubePlayerRef.current
749
+ const createPlayer = (): void => {
750
+ if (!youtubeContainerRef.current) return
751
+ const playerVars = useSearchPlayback && query
752
+ ? { ...basePlayerVars, listType: 'search' as const, list: query }
753
+ : basePlayerVars
754
+ youtubePlayerRef.current = new window.YT.Player(youtubeContainerRef.current, {
755
+ height: '1',
756
+ width: '1',
757
+ ...(videoId && !useSearchPlayback ? { videoId } : {}),
758
+ playerVars,
759
+ events: {
760
+ onReady: (event: any) => {
761
+ try {
762
+ event.target.setVolume(Math.round(volume * 100))
763
+ const d = event.target.getDuration?.()
764
+ if (Number.isFinite(d) && d > 0) {
765
+ setMediaDuration(d)
766
+ bumpTrackDuration(current?.id ?? '', d)
767
+ }
768
+ } catch {
769
+ /* ignore */
770
+ }
771
+ setLoadError(null)
772
+ if (isPlaying) {
773
+ event.target.playVideo()
774
+ }
775
+ },
776
+ onStateChange: (event: any) => {
777
+ if (event.data === window.YT.PlayerState.ENDED) {
778
+ goNext()
779
+ }
780
+ },
781
+ },
782
+ })
783
+ }
784
+
785
+ if (existingPlayer) {
786
+ try {
787
+ if (useSearchPlayback && query) {
788
+ existingPlayer.loadPlaylist({ listType: 'search', list: query, index: 0 })
789
+ } else if (videoId) {
790
+ existingPlayer.loadVideoById(videoId)
791
+ }
792
+ if (isPlaying) existingPlayer.playVideo()
793
+ else existingPlayer.pauseVideo()
794
+ existingPlayer.setVolume(Math.round(volume * 100))
795
+ } catch {
796
+ existingPlayer.destroy()
797
+ createPlayer()
798
+ }
799
+ return
800
+ }
801
+
802
+ createPlayer()
803
+ }
804
+
805
+ if (window.YT?.Player) {
806
+ ensurePlayer()
807
+ } else {
808
+ if (!document.getElementById('youtube-iframe-api')) {
809
+ const script = document.createElement('script')
810
+ script.id = 'youtube-iframe-api'
811
+ script.src = 'https://www.youtube.com/iframe_api'
812
+ script.async = true
813
+ document.body.appendChild(script)
814
+ }
815
+ const previous = window.onYouTubeIframeAPIReady
816
+ window.onYouTubeIframeAPIReady = () => {
817
+ ensurePlayer()
818
+ if (typeof previous === 'function') {
819
+ previous()
820
+ }
821
+ }
822
+ }
823
+
824
+ return (): void => {
825
+ if (!youtubeStreamActive && youtubePlayerRef.current) {
826
+ try {
827
+ youtubePlayerRef.current.destroy()
828
+ } catch {
829
+ /* ignore */
830
+ }
831
+ youtubePlayerRef.current = null
832
+ }
833
+ }
834
+ }, [
835
+ current?.youtubeVideoId,
836
+ current?.youtubeQuery,
837
+ useSearchPlayback,
838
+ youtubeStreamActive,
839
+ isPlaying,
840
+ volume,
841
+ goNext,
842
+ bumpTrackDuration,
843
+ current?.id,
844
+ ])
845
+
846
+ useEffect(() => {
847
+ const player = youtubePlayerRef.current
848
+ if (!player || !youtubeStreamActive) return
849
+ try {
850
+ if (isPlaying) player.playVideo()
851
+ else player.pauseVideo()
852
+ } catch {
853
+ /* ignore */
854
+ }
855
+ }, [isPlaying, youtubeStreamActive])
856
+
857
+ useEffect(() => {
858
+ const player = youtubePlayerRef.current
859
+ if (!player || !youtubeStreamActive) return
860
+ try {
861
+ player.setVolume(Math.round(volume * 100))
862
+ } catch {
863
+ /* ignore */
864
+ }
865
+ }, [volume, youtubeStreamActive])
866
+
867
+ useEffect(() => {
868
+ if (!youtubeStreamActive) return undefined
869
+ const tick = (): void => {
870
+ const player = youtubePlayerRef.current
871
+ if (!player?.getCurrentTime) return
872
+ try {
873
+ const t = player.getCurrentTime()
874
+ if (Number.isFinite(t) && t >= 0) {
875
+ setPositionSec(t)
876
+ reportPlayback(activeQueueIdRef.current, t)
877
+ }
878
+ const d = player.getDuration?.()
879
+ if (Number.isFinite(d) && d > 0) setMediaDuration(d)
880
+ } catch {
881
+ /* ignore */
882
+ }
883
+ }
884
+ tick()
885
+ const id = window.setInterval(tick, 500)
886
+ return (): void => {
887
+ window.clearInterval(id)
888
+ }
889
+ }, [youtubeStreamActive, reportPlayback])
890
+
891
+ useEffect(() => {
892
+ const el = audioRef.current
893
+ if (!el) return undefined
894
+ if (!el.src) return undefined
895
+ if (isPlaying) {
896
+ void el.play().catch((e: unknown) => {
897
+ setLoadError(e instanceof Error ? e.message : 'Playback failed')
898
+ setIsPlaying(false)
899
+ })
900
+ } else {
901
+ el.pause()
902
+ }
903
+ }, [isPlaying])
904
+
905
+ useEffect(() => {
906
+ const el = audioRef.current
907
+ if (!el || !current) return undefined
908
+ const onTime = (): void => {
909
+ setPositionSec(el.currentTime)
910
+ const now = Date.now()
911
+ if (now - lastPlaybackReportMsRef.current >= 2000) {
912
+ lastPlaybackReportMsRef.current = now
913
+ reportPlayback(activeQueueIdRef.current, el.currentTime)
914
+ }
915
+ }
916
+ const onMeta = (): void => {
917
+ if (Number.isFinite(el.duration) && el.duration > 0) {
918
+ setMediaDuration(el.duration)
919
+ bumpTrackDuration(current.id, el.duration)
920
+ }
921
+ const pending = pendingRestorePositionRef.current
922
+ if (pending != null && pending > 0) {
923
+ const seekTo = Math.min(pending, el.duration > 0 ? el.duration : pending)
924
+ el.currentTime = seekTo
925
+ setPositionSec(seekTo)
926
+ pendingRestorePositionRef.current = null
927
+ reportPlayback(activeQueueIdRef.current, seekTo)
928
+ }
929
+ }
930
+ const onEnded = (): void => {
931
+ if (repeatMode === 'one') {
932
+ el.currentTime = 0
933
+ setPositionSec(0)
934
+ void el.play().catch((e: unknown) => {
935
+ setLoadError(e instanceof Error ? e.message : 'Playback failed')
936
+ setIsPlaying(false)
937
+ })
938
+ return
939
+ }
940
+ goNext()
941
+ }
942
+ el.addEventListener('timeupdate', onTime)
943
+ el.addEventListener('loadedmetadata', onMeta)
944
+ el.addEventListener('ended', onEnded)
945
+ return (): void => {
946
+ el.removeEventListener('timeupdate', onTime)
947
+ el.removeEventListener('loadedmetadata', onMeta)
948
+ el.removeEventListener('ended', onEnded)
949
+ }
950
+ }, [current, bumpTrackDuration, goNext, repeatMode, reportPlayback])
951
+
952
+ useEffect(() => {
953
+ return (): void => {
954
+ if (objectUrlRef.current) {
955
+ URL.revokeObjectURL(objectUrlRef.current)
956
+ objectUrlRef.current = null
957
+ }
958
+ if (coverObjectUrlRef.current) {
959
+ URL.revokeObjectURL(coverObjectUrlRef.current)
960
+ coverObjectUrlRef.current = null
961
+ }
962
+ }
963
+ }, [])
964
+
965
+ const onSeekBarPointer = useCallback(
966
+ (ratio: number) => {
967
+ const r = Math.min(1, Math.max(0, ratio))
968
+ const yt = youtubePlayerRef.current
969
+ if (youtubeStreamActive && yt?.seekTo) {
970
+ const total =
971
+ durationSec > 0
972
+ ? durationSec
973
+ : (() => {
974
+ try {
975
+ const d = yt.getDuration?.()
976
+ return Number.isFinite(d) && d > 0 ? d : 0
977
+ } catch {
978
+ return 0
979
+ }
980
+ })()
981
+ if (total > 0) {
982
+ const next = r * total
983
+ try {
984
+ yt.seekTo(next, true)
985
+ } catch {
986
+ /* ignore */
987
+ }
988
+ setPositionSec(next)
989
+ reportPlayback(activeQueueIdRef.current, next)
990
+ }
991
+ return
992
+ }
993
+ const el = audioRef.current
994
+ if (!el || !Number.isFinite(el.duration) || el.duration <= 0) {
995
+ if (durationSec > 0) {
996
+ setPositionSec(r * durationSec)
997
+ if (el) el.currentTime = r * durationSec
998
+ }
999
+ return
1000
+ }
1001
+ const next = Math.min(el.duration, Math.max(0, r * el.duration))
1002
+ el.currentTime = next
1003
+ setPositionSec(next)
1004
+ reportPlayback(activeQueueIdRef.current, next)
1005
+ },
1006
+ [youtubeStreamActive, durationSec, reportPlayback],
1007
+ )
1008
+
1009
+ const onSeekBarKeyDown = useCallback(
1010
+ (e: KeyboardEvent<HTMLDivElement>): void => {
1011
+ if (durationSec <= 0) return
1012
+
1013
+ const stepSmallSec = 5
1014
+ const stepLargeSec = 30
1015
+ let nextTime: number | null = null
1016
+
1017
+ if (e.key === 'ArrowLeft') nextTime = positionSec - stepSmallSec
1018
+ else if (e.key === 'ArrowRight') nextTime = positionSec + stepSmallSec
1019
+ else if (e.key === 'PageDown') nextTime = positionSec - stepLargeSec
1020
+ else if (e.key === 'PageUp') nextTime = positionSec + stepLargeSec
1021
+ else if (e.key === 'Home') nextTime = 0
1022
+ else if (e.key === 'End') nextTime = durationSec
1023
+ else return
1024
+
1025
+ e.preventDefault()
1026
+ const clamped = Math.min(durationSec, Math.max(0, nextTime))
1027
+ onSeekBarPointer(clamped / durationSec)
1028
+ },
1029
+ [durationSec, onSeekBarPointer, positionSec],
1030
+ )
1031
+
1032
+ const statusLabel = isScanning
1033
+ ? 'Scanning…'
1034
+ : `${libraryTracks.length} in library · ${queue.length} in queue`
1035
+
1036
+ const queueRowGapClass = compactLists ? 'gap-2' : 'gap-3'
1037
+ const queueRowPadClass = compactLists ? 'px-3 py-2' : 'px-4 py-2.5'
1038
+ const queueRemoveButtonPadClass = compactLists ? 'px-1.5 py-1.5' : 'px-2 py-2'
1039
+ const emptyQueueCardGapClass = compactLists ? 'gap-2' : 'gap-3'
1040
+ const emptyQueueCardPadClass = compactLists ? 'p-2' : 'p-3'
1041
+
1042
+ return (
1043
+ <div className="flex h-full min-h-0 flex-1 flex-col bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
1044
+ <YouTubeStreamNotification visible={streamResolving} trackTitle={current?.title} />
1045
+ <audio ref={audioRef} className="hidden" preload="metadata" />
1046
+ <div
1047
+ ref={youtubeContainerRef}
1048
+ className="pointer-events-none fixed h-px w-px overflow-hidden opacity-0"
1049
+ aria-hidden
1050
+ tabIndex={-1}
1051
+ />
1052
+
1053
+ <header className="flex shrink-0 items-center justify-between border-b border-zinc-200 bg-white/90 px-6 py-4 backdrop-blur-sm dark:border-zinc-800/80 dark:bg-zinc-950/90">
1054
+ <div className="flex items-center gap-3">
1055
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-700 ring-1 ring-amber-500/25 dark:text-amber-400 dark:ring-amber-500/30">
1056
+ <IconQueue className="h-5 w-5" />
1057
+ </div>
1058
+ <div>
1059
+ <h1 className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Muzical</h1>
1060
+ <p className="text-xs text-zinc-500">Local library · browser playback</p>
1061
+ </div>
1062
+ </div>
1063
+ <div className="flex items-center gap-2">
1064
+ <span className="hidden rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs text-zinc-500 shadow-sm sm:inline dark:border-zinc-700/80 dark:bg-zinc-900/80 dark:text-zinc-400 dark:shadow-none">
1065
+ {statusLabel}
1066
+ </span>
1067
+ <Link
1068
+ href="/settings"
1069
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:shadow-none dark:hover:border-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-50"
1070
+ aria-label="Library settings"
1071
+ >
1072
+ <IconSettings className="h-[18px] w-[18px]" />
1073
+ </Link>
1074
+ <ThemeToggle />
1075
+ </div>
1076
+ </header>
1077
+
1078
+ {loadError ? (
1079
+ <p
1080
+ className="shrink-0 border-b border-red-200 bg-red-50 px-6 py-2 text-sm text-red-800 dark:border-red-900/40 dark:bg-red-950/30 dark:text-red-200"
1081
+ role="alert"
1082
+ >
1083
+ {loadError}
1084
+ </p>
1085
+ ) : null}
1086
+
1087
+ <div
1088
+ ref={mainRowRef}
1089
+ className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row"
1090
+ >
1091
+ <div
1092
+ className="flex min-h-0 min-w-0 flex-col overflow-hidden max-lg:flex-2 max-lg:w-full lg:h-full lg:min-w-0 lg:shrink-0"
1093
+ style={layoutLg ? { width: libraryPanelPx, flex: '0 0 auto' } : undefined}
1094
+ >
1095
+ <BrowsePanel />
1096
+ </div>
1097
+ <PanelResizeHandle
1098
+ aria-label="Resize library and queue panels"
1099
+ onSessionStart={onLibraryQueueResizeStart}
1100
+ onSessionMove={onLibraryQueueResizeMove}
1101
+ onSessionEnd={onPanelResizeEnd}
1102
+ />
1103
+ <section
1104
+ className="flex min-h-0 min-w-0 flex-col overflow-hidden border-b border-zinc-200 bg-white dark:border-zinc-800/80 dark:bg-zinc-950/50 max-lg:flex-1 max-lg:w-full lg:h-full lg:shrink-0 lg:border-b-0 lg:border-r lg:border-zinc-200 lg:dark:border-zinc-800"
1105
+ style={layoutLg ? { width: queuePanelPx, flex: '0 0 auto' } : undefined}
1106
+ >
1107
+ {!isQueueReady ? (
1108
+ <QueueLoadingSpinner />
1109
+ ) : (
1110
+ <>
1111
+ <div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-zinc-200 bg-white/80 px-3 dark:border-zinc-800 dark:bg-zinc-950/80">
1112
+ <h2 className="text-xs font-medium uppercase leading-none tracking-wider text-zinc-500">Queue</h2>
1113
+ {queue.length > 0 ? (
1114
+ <button
1115
+ type="button"
1116
+ onClick={() => {
1117
+ clearQueue()
1118
+ setActiveQueueId(null)
1119
+ setIsPlaying(false)
1120
+ setPositionSec(0)
1121
+ }}
1122
+ className="text-xs font-medium text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:hover:text-zinc-300"
1123
+ >
1124
+ Clear
1125
+ </button>
1126
+ ) : null}
1127
+ </div>
1128
+ <div className="min-h-0 flex-1 overflow-auto pb-2">
1129
+ {queue.length === 0 ? (
1130
+ <div className="px-4 py-6">
1131
+ <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Queue is empty</p>
1132
+ <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
1133
+ Add something to start playback.
1134
+ <Link
1135
+ href="/settings"
1136
+ className="ml-2 font-medium text-amber-700 underline-offset-2 hover:underline dark:text-amber-400"
1137
+ >
1138
+ Library folders
1139
+ </Link>
1140
+ </p>
1141
+
1142
+ {recentlyPlayedTracks.length > 0 ? (
1143
+ <div className="mt-5">
1144
+ <p className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Recently played</p>
1145
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1146
+ {recentlyPlayedTracks.map((t) => (
1147
+ <div
1148
+ key={t.id}
1149
+ className={`flex min-w-0 items-center ${emptyQueueCardGapClass} rounded-xl border border-zinc-200 bg-white ${emptyQueueCardPadClass} shadow-sm dark:border-zinc-800 dark:bg-zinc-950/40 dark:shadow-none`}
1150
+ >
1151
+ <div className="min-w-0 flex-1">
1152
+ <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
1153
+ <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
1154
+ {t.artist} · {t.album}
1155
+ </p>
1156
+ </div>
1157
+ <div className="flex shrink-0 items-center gap-2">
1158
+ <span className="text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
1159
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
1160
+ </span>
1161
+ <FavoriteStarButton
1162
+ className="rounded-full"
1163
+ filled={isFavoriteSong(t.id)}
1164
+ onPress={() => toggleFavoriteTrack(t)}
1165
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
1166
+ />
1167
+ <button
1168
+ type="button"
1169
+ onClick={() => addToQueue(t)}
1170
+ className="shrink-0 rounded-full bg-amber-500/15 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-500/25 transition hover:bg-amber-500/25 dark:text-amber-300 dark:ring-amber-500/40"
1171
+ >
1172
+ Add
1173
+ </button>
1174
+ </div>
1175
+ </div>
1176
+ ))}
1177
+ </div>
1178
+ </div>
1179
+ ) : null}
1180
+
1181
+ {suggestedTracks.length > 0 ? (
1182
+ <div className="mt-6">
1183
+ <p className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Suggestions</p>
1184
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1185
+ {suggestedTracks.map((t) => (
1186
+ <div
1187
+ key={t.id}
1188
+ role="button"
1189
+ tabIndex={0}
1190
+ onClick={() => addToQueue(t)}
1191
+ onKeyDown={(e) => {
1192
+ if (e.key === 'Enter' || e.key === ' ') {
1193
+ e.preventDefault()
1194
+ addToQueue(t)
1195
+ }
1196
+ }}
1197
+ aria-label={`Add ${t.title} to queue`}
1198
+ className={`flex w-full min-w-0 cursor-pointer items-center text-left ${emptyQueueCardGapClass} rounded-xl border border-zinc-200 bg-white ${emptyQueueCardPadClass} shadow-sm transition hover:border-amber-400/50 hover:bg-amber-50/50 dark:border-zinc-800 dark:bg-zinc-950/40 dark:hover:border-amber-500/30 dark:hover:bg-amber-950/20 dark:shadow-none`}
1199
+ >
1200
+ <div className="min-w-0 flex-1">
1201
+ <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
1202
+ <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
1203
+ {t.artist} · {t.album}
1204
+ </p>
1205
+ </div>
1206
+ <div className="flex shrink-0 items-center gap-2">
1207
+ <span className="text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
1208
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
1209
+ </span>
1210
+ <FavoriteStarButton
1211
+ className="rounded-full"
1212
+ filled={isFavoriteSong(t.id)}
1213
+ onPress={() => toggleFavoriteTrack(t)}
1214
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
1215
+ />
1216
+ </div>
1217
+ </div>
1218
+ ))}
1219
+ </div>
1220
+ </div>
1221
+ ) : null}
1222
+ </div>
1223
+ ) : (
1224
+ <ul className="divide-y divide-zinc-200 dark:divide-zinc-800" role="listbox" aria-label="Track queue">
1225
+ {queue.map((row, index) => {
1226
+ const track = row.track
1227
+ const selected = index === activeIndex
1228
+ const isDropTarget = dragOverQueueId === row.queueId && draggingQueueId !== row.queueId
1229
+ return (
1230
+ <li
1231
+ key={row.queueId}
1232
+ className={[
1233
+ 'group/row flex items-center gap-0',
1234
+ selected ? 'bg-amber-50/90 dark:bg-white/6' : '',
1235
+ isDropTarget ? 'ring-1 ring-amber-400/30' : '',
1236
+ ].join(' ')}
1237
+ onDragOver={(e) => {
1238
+ if (!draggingQueueId) return
1239
+ e.preventDefault()
1240
+ if (dragOverQueueId !== row.queueId) setDragOverQueueId(row.queueId)
1241
+ }}
1242
+ onDrop={(e) => {
1243
+ e.preventDefault()
1244
+ const fromQueueId = draggingQueueId
1245
+ setDraggingQueueId(null)
1246
+ setDragOverQueueId(null)
1247
+ if (!fromQueueId) return
1248
+ if (fromQueueId === row.queueId) return
1249
+ const fromIndex = queue.findIndex((q) => q.queueId === fromQueueId)
1250
+ if (fromIndex < 0) return
1251
+
1252
+ const rect = (e.currentTarget as HTMLLIElement).getBoundingClientRect()
1253
+ const insertAfter = e.clientY > rect.top + rect.height / 2
1254
+ // Desired insertion index in the original list (before any splice shifting).
1255
+ const desiredInsertIndex = insertAfter ? index + 1 : index
1256
+ // Convert to insertion index after removal of the dragged item.
1257
+ const adjustedToIndex = fromIndex < desiredInsertIndex ? desiredInsertIndex - 1 : desiredInsertIndex
1258
+ reorderQueueItems(fromIndex, adjustedToIndex)
1259
+ }}
1260
+ >
1261
+ <button
1262
+ type="button"
1263
+ role="option"
1264
+ aria-selected={selected}
1265
+ onClick={() => selectIndex(index)}
1266
+ draggable
1267
+ onDragStart={(e) => {
1268
+ setDraggingQueueId(row.queueId)
1269
+ setDragOverQueueId(row.queueId)
1270
+ e.dataTransfer.effectAllowed = 'move'
1271
+ e.dataTransfer.setData('text/plain', row.queueId)
1272
+ }}
1273
+ onDragEnd={() => {
1274
+ setDraggingQueueId(null)
1275
+ setDragOverQueueId(null)
1276
+ }}
1277
+ className={[
1278
+ `flex min-w-0 flex-1 items-center ${queueRowGapClass} border-l-2 border-transparent ${queueRowPadClass} text-left transition-colors`,
1279
+ selected
1280
+ ? 'border-amber-500 dark:border-amber-400'
1281
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-900/60',
1282
+ 'cursor-grab active:cursor-grabbing',
1283
+ isDropTarget ? 'border-amber-500/20 dark:border-amber-400/20' : '',
1284
+ ].join(' ')}
1285
+ >
1286
+ <span className="w-5 shrink-0 text-right text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
1287
+ {index + 1}
1288
+ </span>
1289
+ <div className="min-w-0 flex-1">
1290
+ <p className="truncate text-sm font-medium leading-snug text-zinc-900 dark:text-zinc-100">
1291
+ {track.title}
1292
+ </p>
1293
+ <p className="truncate text-xs leading-snug text-zinc-500 dark:text-zinc-400">
1294
+ {track.artist} · {track.album}
1295
+ </p>
1296
+ </div>
1297
+ <span className="shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
1298
+ {track.durationSec > 0 ? formatDuration(track.durationSec) : '—'}
1299
+ </span>
1300
+ </button>
1301
+ <div
1302
+ className={[
1303
+ 'flex shrink-0 items-center border-l pr-1 pl-0.5',
1304
+ selected
1305
+ ? 'border-amber-200/80 dark:border-white/8'
1306
+ : 'border-zinc-200 bg-zinc-50/80 dark:border-zinc-800 dark:bg-zinc-900/40',
1307
+ ].join(' ')}
1308
+ >
1309
+ <FavoriteStarButton
1310
+ className="rounded-none"
1311
+ filled={isFavoriteSong(track.id)}
1312
+ onPress={() => toggleFavoriteTrack(track)}
1313
+ label={isFavoriteSong(track.id) ? 'Remove song from favorites' : 'Add song to favorites'}
1314
+ />
1315
+ <button
1316
+ type="button"
1317
+ aria-label={`Remove ${track.title} from queue`}
1318
+ onClick={() => {
1319
+ const isCurrent = activeQueueId === row.queueId
1320
+ const nextId = isCurrent
1321
+ ? (queue[index + 1]?.queueId ?? queue[index - 1]?.queueId ?? null)
1322
+ : activeQueueId
1323
+ removeFromQueue(row.queueId)
1324
+ setActiveQueueId(nextId)
1325
+ if (isCurrent && !nextId) {
1326
+ setIsPlaying(false)
1327
+ setPositionSec(0)
1328
+ }
1329
+ }}
1330
+ className={`shrink-0 ${queueRemoveButtonPadClass} text-[11px] text-zinc-500 opacity-80 transition hover:bg-zinc-200/80 hover:text-zinc-900 sm:opacity-0 sm:group-hover/row:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100`}
1331
+ >
1332
+ Remove
1333
+ </button>
1334
+ </div>
1335
+ </li>
1336
+ )
1337
+ })}
1338
+ </ul>
1339
+ )}
1340
+ </div>
1341
+ </>
1342
+ )}
1343
+ </section>
1344
+ <PanelResizeHandle
1345
+ aria-label="Resize queue and player panels"
1346
+ onSessionStart={onQueuePlayerResizeStart}
1347
+ onSessionMove={onQueuePlayerResizeMove}
1348
+ onSessionEnd={onPanelResizeEnd}
1349
+ />
1350
+ <aside className="flex min-h-0 min-w-0 flex-1 flex-col gap-6 overflow-y-auto overflow-x-hidden bg-zinc-50 p-6 dark:bg-transparent lg:h-full lg:min-w-0 lg:flex-1">
1351
+ <div className="mx-auto flex w-full max-w-[280px] flex-col gap-4">
1352
+ <div
1353
+ className="relative aspect-square w-full overflow-hidden rounded-2xl bg-linear-to-br from-amber-200/90 via-zinc-100 to-zinc-200 ring-1 ring-zinc-300/70 shadow-xl shadow-zinc-400/20 dark:from-amber-900/40 dark:via-zinc-800 dark:to-zinc-900 dark:ring-zinc-700/60 dark:shadow-2xl dark:shadow-black/40"
1354
+ aria-hidden
1355
+ >
1356
+ {coverArtUrl ? (
1357
+ // Blob object URLs are not supported by next/image without a custom loader.
1358
+ // eslint-disable-next-line @next/next/no-img-element -- local object URL from tags
1359
+ <img
1360
+ src={coverArtUrl}
1361
+ alt=""
1362
+ className="absolute inset-0 h-full w-full object-cover"
1363
+ decoding="async"
1364
+ />
1365
+ ) : (
1366
+ <div className="flex h-full w-full items-center justify-center p-8">
1367
+ <span className="select-none text-6xl font-bold tracking-tighter text-amber-800/20 dark:text-zinc-700/90">
1368
+ {current?.album ? current.album.charAt(0) : '♪'}
1369
+ </span>
1370
+ </div>
1371
+ )}
1372
+ </div>
1373
+ <div className="text-center">
1374
+ <p className="truncate text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
1375
+ {current?.title ?? '—'}
1376
+ </p>
1377
+ <p className="mt-1 truncate text-sm text-zinc-600 dark:text-zinc-400">{current?.artist ?? ''}</p>
1378
+ <p className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-600">{current?.album ?? ''}</p>
1379
+ </div>
1380
+ </div>
1381
+
1382
+ <div className="mt-auto space-y-3">
1383
+ <div className="flex items-center justify-between text-xs tabular-nums text-zinc-500">
1384
+ <span>{formatDuration(positionSec)}</span>
1385
+ <span>{durationSec > 0 ? formatDuration(durationSec) : '—'}</span>
1386
+ </div>
1387
+ <div
1388
+ className="group relative h-2 cursor-pointer rounded-full bg-zinc-200 dark:bg-zinc-800"
1389
+ onPointerDown={(e) => {
1390
+ const el = e.currentTarget
1391
+ const rect = el.getBoundingClientRect()
1392
+ const ratio = (e.clientX - rect.left) / Math.max(1, rect.width)
1393
+ onSeekBarPointer(ratio)
1394
+ const move = (ev: PointerEvent): void => {
1395
+ const r = (ev.clientX - rect.left) / Math.max(1, rect.width)
1396
+ onSeekBarPointer(r)
1397
+ }
1398
+ const up = (): void => {
1399
+ window.removeEventListener('pointermove', move)
1400
+ window.removeEventListener('pointerup', up)
1401
+ }
1402
+ window.addEventListener('pointermove', move)
1403
+ window.addEventListener('pointerup', up)
1404
+ }}
1405
+ role="slider"
1406
+ tabIndex={0}
1407
+ onKeyDown={onSeekBarKeyDown}
1408
+ aria-valuemin={0}
1409
+ aria-valuemax={Math.round(durationSec)}
1410
+ aria-valuenow={Math.round(positionSec)}
1411
+ aria-label="Seek"
1412
+ >
1413
+ <div
1414
+ className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-linear-to-r from-amber-600 to-amber-400"
1415
+ style={{
1416
+ width: `${
1417
+ durationSec > 0 ? (100 * positionSec) / durationSec : 0
1418
+ }%`,
1419
+ }}
1420
+ />
1421
+ </div>
1422
+
1423
+ <div className="flex items-center justify-between gap-4 pt-1">
1424
+ <div className="flex items-center gap-1">
1425
+ <button
1426
+ type="button"
1427
+ aria-label="Previous track"
1428
+ onClick={() => goPrev()}
1429
+ disabled={queue.length === 0}
1430
+ className="rounded-full p-2.5 text-zinc-600 transition hover:bg-zinc-200 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
1431
+ >
1432
+ <IconSkipBack className="h-6 w-6" />
1433
+ </button>
1434
+ <button
1435
+ type="button"
1436
+ aria-label={isPlaying ? 'Pause' : 'Play'}
1437
+ onClick={() => setIsPlaying((p) => !p)}
1438
+ disabled={queue.length === 0 || !current}
1439
+ className="mx-1 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500 text-zinc-950 shadow-lg shadow-amber-600/25 transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40 dark:shadow-amber-900/30"
1440
+ >
1441
+ {isPlaying ? <IconPause className="h-7 w-7" /> : <IconPlay className="h-7 w-7 pl-0.5" />}
1442
+ </button>
1443
+ <button
1444
+ type="button"
1445
+ aria-label="Next track"
1446
+ onClick={() => goNext()}
1447
+ disabled={queue.length === 0}
1448
+ className="rounded-full p-2.5 text-zinc-600 transition hover:bg-zinc-200 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
1449
+ >
1450
+ <IconSkipForward className="h-6 w-6" />
1451
+ </button>
1452
+ </div>
1453
+
1454
+ <div className="flex min-w-0 flex-1 items-center gap-2">
1455
+ <IconVolume className="h-5 w-5 shrink-0 text-zinc-500" />
1456
+ <input
1457
+ type="range"
1458
+ min={0}
1459
+ max={1}
1460
+ step={0.01}
1461
+ value={volume}
1462
+ onChange={(e) => setVolume(Number(e.target.value))}
1463
+ className="h-1 w-full min-w-0 cursor-pointer accent-amber-500"
1464
+ aria-label="Volume"
1465
+ />
1466
+ </div>
1467
+ </div>
1468
+
1469
+ <div className="flex flex-wrap items-center justify-center gap-3 border-t border-zinc-200 pt-3 dark:border-zinc-800">
1470
+ <button
1471
+ type="button"
1472
+ onClick={() => cycleRepeatMode()}
1473
+ className="relative flex h-9 w-9 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
1474
+ aria-label={
1475
+ repeatMode === 'off'
1476
+ ? 'Repeat off. Click for repeat all.'
1477
+ : repeatMode === 'all'
1478
+ ? 'Repeat all. Click for repeat one.'
1479
+ : 'Repeat one. Click for repeat off.'
1480
+ }
1481
+ >
1482
+ <IconRepeatLoop dimmed={repeatMode === 'off'} className="h-5 w-5" />
1483
+ {repeatMode === 'one' ? (
1484
+ <span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded bg-amber-500 px-0.5 text-[9px] font-bold leading-none text-zinc-950">
1485
+ 1
1486
+ </span>
1487
+ ) : null}
1488
+ </button>
1489
+ <button
1490
+ type="button"
1491
+ onClick={() => toggleShuffle()}
1492
+ aria-pressed={shuffle}
1493
+ aria-label={shuffle ? 'Shuffle on' : 'Shuffle off'}
1494
+ className={[
1495
+ 'flex h-9 w-9 items-center justify-center rounded-full border transition',
1496
+ shuffle
1497
+ ? 'border-amber-500/50 bg-amber-500/15 text-amber-800 dark:text-amber-300'
1498
+ : 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-100',
1499
+ ].join(' ')}
1500
+ >
1501
+ <IconShuffle className="h-5 w-5" />
1502
+ </button>
1503
+ <label className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
1504
+ <span className="sr-only">Playback speed</span>
1505
+ <span aria-hidden className="tabular-nums">
1506
+ Speed
1507
+ </span>
1508
+ <select
1509
+ value={playbackRate}
1510
+ onChange={(e) => {
1511
+ const v = Number(e.target.value)
1512
+ setPlaybackRate(v)
1513
+ persistPlaybackRate(v)
1514
+ }}
1515
+ className="cursor-pointer rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-800 outline-none ring-amber-500/0 transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
1516
+ aria-label="Playback speed"
1517
+ >
1518
+ {PLAYBACK_RATES.map((r) => (
1519
+ <option key={r} value={r}>
1520
+ {r === 1 ? '1×' : `${r}×`}
1521
+ </option>
1522
+ ))}
1523
+ </select>
1524
+ </label>
1525
+ </div>
1526
+ </div>
1527
+ </aside>
1528
+ </div>
1529
+ </div>
1530
+ )
1531
+ }