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,1023 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from 'react'
13
+ import type { Track } from '@/types/track'
14
+ import type { QueuedTrack } from '@/types/queue'
15
+ import {
16
+ idbDeleteRoot,
17
+ idbGetAllRoots,
18
+ idbGetCatalog,
19
+ idbGetFavorites,
20
+ idbPutCatalog,
21
+ idbPutFavorites,
22
+ idbPutRoot,
23
+ openLibraryDb,
24
+ type StoredLibraryRoot,
25
+ } from '@/lib/library/idb'
26
+ import { formatFsAccessErrorMessage } from '@/lib/library/format-fs-access-error'
27
+ import { collectTracksForMeta } from '@/lib/library/collect-tracks-for-meta'
28
+ import { scanProgressLabel } from '@/lib/library/scan-progress-label'
29
+ import { scanProgressPercent } from '@/lib/library/scan-progress-percent'
30
+ import type { ScanProgressTick } from '@/lib/library/scan-progress-tick'
31
+ import { resolveTrackToFile } from '@/lib/library/resolve-track-file'
32
+ import LibraryScanNotification from '@/components/LibraryScanNotification'
33
+ import type { LibraryRootMeta } from '@/types/library-root-meta'
34
+ import type { LibraryScanProgress } from '@/types/library-scan-progress'
35
+ import type { LibraryScanPreferences } from '@/types/library-scan-preferences'
36
+ import readStoredLibraryScanPreferences from '@/lib/library/read-stored-library-scan-preferences'
37
+ import writeStoredLibraryScanPreferences from '@/lib/library/write-stored-library-scan-preferences'
38
+ import scanPreferencesToTreeOptions from '@/lib/library/scan-preferences-to-tree-options'
39
+ import readStoredPlaybackSnapshot from '@/lib/playback/read-stored-playback-snapshot'
40
+ import writeStoredPlaybackSnapshot from '@/lib/playback/write-stored-playback-snapshot'
41
+ import buildQueueFromSnapshot from '@/lib/playback/build-queue-from-snapshot'
42
+ import collectYoutubePrefetchTargets from '@/lib/youtube/collect-youtube-prefetch-targets'
43
+ import prefetchYoutubeVideoIds from '@/lib/youtube/prefetch-youtube-video-ids'
44
+ import readStoredYoutubeApiKey from '@/lib/youtube/read-stored-youtube-api-key'
45
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
46
+
47
+ export type { LibraryRootMeta } from '@/types/library-root-meta'
48
+ export type { LibraryScanPreferences } from '@/types/library-scan-preferences'
49
+
50
+ export type PlaybackRestore = {
51
+ activeQueueId: string | null
52
+ positionSec: number
53
+ }
54
+
55
+ type LibraryContextValue = {
56
+ roots: LibraryRootMeta[]
57
+ /** Full catalog from scans — not the playback queue */
58
+ libraryTracks: Track[]
59
+ queue: QueuedTrack[]
60
+ recentlyPlayedTrackIds: readonly string[]
61
+ compactLists: boolean
62
+ autoRescanOnStartup: boolean
63
+ scanPreferences: LibraryScanPreferences
64
+ rememberLastQueue: boolean
65
+ playbackRestore: PlaybackRestore | null
66
+ isQueueReady: boolean
67
+ isScanning: boolean
68
+ scanError: string | null
69
+ hasDirectoryPicker: boolean
70
+ addLibraryFolder: () => void
71
+ removeLibraryFolder: (id: string) => Promise<void>
72
+ rescanAll: () => Promise<void>
73
+ addToQueue: (items: Track | readonly Track[]) => void
74
+ addToLibrary: (track: Track) => void
75
+ removeFromQueue: (queueId: string) => void
76
+ clearQueue: () => void
77
+ recordRecentlyPlayedTrack: (trackId: string) => void
78
+ setCompactLists: (next: boolean) => void
79
+ setAutoRescanOnStartup: (next: boolean) => void
80
+ setScanPreferences: (next: LibraryScanPreferences) => void
81
+ setRememberLastQueue: (next: boolean) => void
82
+ consumePlaybackRestore: () => void
83
+ reportPlayback: (activeQueueId: string | null, positionSec: number) => void
84
+ reorderQueueItems: (fromIndex: number, toIndex: number) => void
85
+ resolveFileForTrack: (track: Track) => Promise<File | null>
86
+ bumpTrackDuration: (trackId: string, durationSec: number) => void
87
+ patchTrackById: (trackId: string, patch: (track: Track) => Track) => void
88
+ favoriteSongIds: readonly string[]
89
+ favoriteArtistNames: readonly string[]
90
+ favoriteAlbumKeys: readonly string[]
91
+ isFavoriteSong: (trackId: string) => boolean
92
+ isFavoriteArtist: (name: string) => boolean
93
+ isFavoriteAlbum: (albumKey: string) => boolean
94
+ toggleFavoriteSong: (trackId: string) => void
95
+ toggleFavoriteArtist: (name: string) => void
96
+ toggleFavoriteAlbum: (albumKey: string) => void
97
+ toggleFavoriteTrack: (track: Track) => void
98
+ }
99
+
100
+ const LibraryContext = createContext<LibraryContextValue | null>(null)
101
+
102
+ const STORAGE_RECENTLY_PLAYED_TRACK_IDS = 'muzical.recentlyPlayedTrackIds'
103
+ const RECENTLY_PLAYED_LIMIT = 24
104
+ const STORAGE_COMPACT_LISTS = 'muzical.compactLists'
105
+ const STORAGE_AUTO_RESCAN_ON_STARTUP = 'muzical.autoRescanOnStartup'
106
+ const STORAGE_REMEMBER_LAST_QUEUE = 'muzical.rememberLastQueue'
107
+
108
+ function safeReadStoredBoolean(key: string): boolean {
109
+ if (typeof window === 'undefined') return false
110
+ try {
111
+ const raw = window.localStorage.getItem(key)
112
+ if (!raw) return false
113
+ const parsed: unknown = JSON.parse(raw)
114
+ return parsed === true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ function safeReadStoredBooleanOrDefault(key: string, defaultValue: boolean): boolean {
121
+ if (typeof window === 'undefined') return defaultValue
122
+ try {
123
+ const raw = window.localStorage.getItem(key)
124
+ if (raw === null) return defaultValue
125
+ const parsed: unknown = JSON.parse(raw)
126
+ return parsed === true
127
+ } catch {
128
+ return defaultValue
129
+ }
130
+ }
131
+
132
+ function safeWriteStoredBoolean(key: string, value: boolean): void {
133
+ if (typeof window === 'undefined') return
134
+ try {
135
+ window.localStorage.setItem(key, JSON.stringify(value))
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ }
140
+
141
+ function toggleSortedStringId(prev: readonly string[], id: string): string[] {
142
+ const next = new Set(prev)
143
+ if (next.has(id)) next.delete(id)
144
+ else next.add(id)
145
+ return [...next].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
146
+ }
147
+
148
+ function safeReadStoredStringArray(key: string): string[] {
149
+ if (typeof window === 'undefined') return []
150
+ try {
151
+ const raw = window.localStorage.getItem(key)
152
+ if (!raw) return []
153
+ const parsed: unknown = JSON.parse(raw)
154
+ if (!Array.isArray(parsed)) return []
155
+ return parsed.filter((x): x is string => typeof x === 'string')
156
+ } catch {
157
+ return []
158
+ }
159
+ }
160
+
161
+ function safeWriteStoredStringArray(key: string, list: readonly string[]): void {
162
+ if (typeof window === 'undefined') return
163
+ try {
164
+ window.localStorage.setItem(key, JSON.stringify(list))
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+
170
+ function catalogMatchesRoots(
171
+ meta: readonly { id: string }[],
172
+ cachedRootIds: readonly string[],
173
+ ): boolean {
174
+ if (meta.length !== cachedRootIds.length) return false
175
+ const sorted = [...meta]
176
+ .map((m) => m.id)
177
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
178
+ return sorted.every((id, i) => id === cachedRootIds[i])
179
+ }
180
+
181
+ const DISK_ACCESS_HINT =
182
+ 'Use Rescan all in Settings → Library, or remove and add the folders again so the browser can access your music.'
183
+
184
+ const SCAN_NOTIFICATION_DISMISS_MS = 4000
185
+
186
+ /**
187
+ * True when every configured root already has persisted read access (no prompt on scan).
188
+ */
189
+ async function everyHandleHasGrantedReadAccess(
190
+ handles: ReadonlyMap<string, FileSystemDirectoryHandle>,
191
+ ): Promise<boolean> {
192
+ if (handles.size === 0) return false
193
+ const opts = { mode: 'read' as const }
194
+ for (const h of handles.values()) {
195
+ try {
196
+ const q = await h.queryPermission?.(opts)
197
+ if (q !== 'granted') return false
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+ return true
203
+ }
204
+
205
+ /**
206
+ * Checks read permission on handles. Only passes `mayRequestPrompt: true` from a user gesture
207
+ * (Rescan, first pointer retry); cold load uses `false` so Chrome does not re-show the allow
208
+ * dialog on every refresh when access is already persisted.
209
+ */
210
+ async function reconfirmReadAccessForHandles(
211
+ handles: ReadonlyMap<string, FileSystemDirectoryHandle>,
212
+ mayRequestPrompt: boolean,
213
+ ): Promise<void> {
214
+ const opts = { mode: 'read' as const }
215
+ for (const h of handles.values()) {
216
+ try {
217
+ const q = await h.queryPermission?.(opts)
218
+ if (q === 'granted') continue
219
+ if (!mayRequestPrompt) continue
220
+ await h.requestPermission?.(opts)
221
+ } catch {
222
+ /* ignore — scan will surface real failure */
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Persists library folder handles, scans audio files, and exposes tracks for the player.
229
+ */
230
+ export function LibraryProvider(props: { children: ReactNode }) {
231
+ const dbRef = useRef<IDBDatabase | null>(null)
232
+ const scanLockRef = useRef(false)
233
+ const rootHandlesRef = useRef<Map<string, FileSystemDirectoryHandle>>(new Map())
234
+ const rootsMetaRef = useRef<LibraryRootMeta[]>([])
235
+ const libraryTracksRef = useRef<Track[]>([])
236
+ const persistCatalogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
237
+ const scanDismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
238
+ const favoritesReadyRef = useRef(false)
239
+ const scanPrefsRef = useRef<LibraryScanPreferences>(readStoredLibraryScanPreferences())
240
+ const rememberLastQueueRef = useRef(false)
241
+ const queueHydratedRef = useRef(false)
242
+ const playbackReportRef = useRef({ activeQueueId: null as string | null, positionSec: 0 })
243
+ const persistPlaybackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
244
+ const [favoriteSongIds, setFavoriteSongIds] = useState<string[]>([])
245
+ const [favoriteArtistNames, setFavoriteArtistNames] = useState<string[]>([])
246
+ const [favoriteAlbumKeys, setFavoriteAlbumKeys] = useState<string[]>([])
247
+ const [roots, setRoots] = useState<LibraryRootMeta[]>([])
248
+ const [libraryTracks, setLibraryTracks] = useState<Track[]>([])
249
+ const [queue, setQueue] = useState<QueuedTrack[]>([])
250
+ const [recentlyPlayedTrackIds, setRecentlyPlayedTrackIds] = useState<string[]>([])
251
+ const [compactLists, setCompactListsState] = useState(false)
252
+ const [autoRescanOnStartup, setAutoRescanOnStartupState] = useState(true)
253
+ const [scanPreferences, setScanPreferencesState] = useState<LibraryScanPreferences>(
254
+ readStoredLibraryScanPreferences,
255
+ )
256
+ const [rememberLastQueue, setRememberLastQueueState] = useState(false)
257
+ const [playbackRestore, setPlaybackRestore] = useState<PlaybackRestore | null>(null)
258
+ const [catalogInitDone, setCatalogInitDone] = useState(false)
259
+ const [isQueueReady, setIsQueueReady] = useState(false)
260
+ const [isScanning, setIsScanning] = useState(false)
261
+ const [scanProgress, setScanProgress] = useState<LibraryScanProgress | null>(null)
262
+ const [scanError, setScanError] = useState<string | null>(null)
263
+ const [hasDirectoryPicker, setHasDirectoryPicker] = useState(false)
264
+
265
+ useEffect(() => {
266
+ void Promise.resolve().then(() => {
267
+ setHasDirectoryPicker(typeof window !== 'undefined' && 'showDirectoryPicker' in window)
268
+ })
269
+ }, [])
270
+
271
+ useEffect(() => {
272
+ void Promise.resolve().then(() => {
273
+ setRecentlyPlayedTrackIds(safeReadStoredStringArray(STORAGE_RECENTLY_PLAYED_TRACK_IDS).slice(0, RECENTLY_PLAYED_LIMIT))
274
+ })
275
+ }, [])
276
+
277
+ useEffect(() => {
278
+ void Promise.resolve().then(() => {
279
+ setCompactListsState(safeReadStoredBoolean(STORAGE_COMPACT_LISTS))
280
+ })
281
+ }, [])
282
+
283
+ useEffect(() => {
284
+ void Promise.resolve().then(() => {
285
+ setAutoRescanOnStartupState(
286
+ safeReadStoredBooleanOrDefault(STORAGE_AUTO_RESCAN_ON_STARTUP, true),
287
+ )
288
+ })
289
+ }, [])
290
+
291
+ useEffect(() => {
292
+ void Promise.resolve().then(() => {
293
+ const prefs = readStoredLibraryScanPreferences()
294
+ scanPrefsRef.current = prefs
295
+ setScanPreferencesState(prefs)
296
+ })
297
+ }, [])
298
+
299
+ useEffect(() => {
300
+ void Promise.resolve().then(() => {
301
+ const on = safeReadStoredBoolean(STORAGE_REMEMBER_LAST_QUEUE)
302
+ rememberLastQueueRef.current = on
303
+ setRememberLastQueueState(on)
304
+ })
305
+ }, [])
306
+
307
+ useEffect(() => {
308
+ safeWriteStoredStringArray(STORAGE_RECENTLY_PLAYED_TRACK_IDS, recentlyPlayedTrackIds.slice(0, RECENTLY_PLAYED_LIMIT))
309
+ }, [recentlyPlayedTrackIds])
310
+
311
+ useEffect(() => {
312
+ libraryTracksRef.current = libraryTracks
313
+ }, [libraryTracks])
314
+
315
+ useEffect(() => {
316
+ return (): void => {
317
+ if (persistCatalogTimerRef.current) {
318
+ clearTimeout(persistCatalogTimerRef.current)
319
+ persistCatalogTimerRef.current = null
320
+ }
321
+ if (scanDismissTimerRef.current) {
322
+ clearTimeout(scanDismissTimerRef.current)
323
+ scanDismissTimerRef.current = null
324
+ }
325
+ if (persistPlaybackTimerRef.current) {
326
+ clearTimeout(persistPlaybackTimerRef.current)
327
+ persistPlaybackTimerRef.current = null
328
+ }
329
+ }
330
+ }, [])
331
+
332
+ const flushPersistPlaybackNow = useCallback((): void => {
333
+ if (!rememberLastQueueRef.current) return
334
+ if (persistPlaybackTimerRef.current) {
335
+ clearTimeout(persistPlaybackTimerRef.current)
336
+ persistPlaybackTimerRef.current = null
337
+ }
338
+ const q = queue
339
+ if (q.length === 0) return
340
+ const activeId = playbackReportRef.current.activeQueueId
341
+ const activeRow = activeId ? q.find((row) => row.queueId === activeId) : q[0]
342
+ writeStoredPlaybackSnapshot({
343
+ trackIds: q.map((row) => row.track.id),
344
+ tracks: q.map((row) => row.track),
345
+ activeTrackId: activeRow?.track.id ?? null,
346
+ positionSec: playbackReportRef.current.positionSec,
347
+ })
348
+ }, [queue])
349
+
350
+ const persistPlaybackDebounced = useCallback((): void => {
351
+ if (!rememberLastQueueRef.current) return
352
+ if (!queueHydratedRef.current) return
353
+ if (persistPlaybackTimerRef.current) clearTimeout(persistPlaybackTimerRef.current)
354
+ persistPlaybackTimerRef.current = setTimeout(() => {
355
+ persistPlaybackTimerRef.current = null
356
+ flushPersistPlaybackNow()
357
+ }, 400)
358
+ }, [flushPersistPlaybackNow])
359
+
360
+ const hydrateQueueFromStorage = useCallback((tracks: readonly Track[]): void => {
361
+ if (queueHydratedRef.current) return
362
+ queueHydratedRef.current = true
363
+ if (rememberLastQueueRef.current) {
364
+ const snap = readStoredPlaybackSnapshot()
365
+ if (snap && snap.trackIds.length > 0) {
366
+ const restored = buildQueueFromSnapshot(tracks, snap)
367
+ if (restored.queue.length > 0) {
368
+ let applied = false
369
+ setQueue((prev) => {
370
+ if (prev.length > 0) return prev
371
+ applied = true
372
+ return restored.queue
373
+ })
374
+ if (applied) {
375
+ playbackReportRef.current = {
376
+ activeQueueId: restored.activeQueueId,
377
+ positionSec: restored.positionSec,
378
+ }
379
+ setPlaybackRestore({
380
+ activeQueueId: restored.activeQueueId,
381
+ positionSec: restored.positionSec,
382
+ })
383
+ }
384
+ }
385
+ }
386
+ }
387
+ setIsQueueReady(true)
388
+ }, [])
389
+
390
+ useEffect(() => {
391
+ if (!queueHydratedRef.current) return
392
+ const byId = new Map(libraryTracks.map((t) => [t.id, t]))
393
+ setQueue((prev) =>
394
+ prev.map((row) => {
395
+ const catalog = byId.get(row.track.id)
396
+ return catalog ? { ...row, track: catalog } : row
397
+ }),
398
+ )
399
+ }, [libraryTracks])
400
+
401
+ useEffect(() => {
402
+ persistPlaybackDebounced()
403
+ }, [queue, persistPlaybackDebounced])
404
+
405
+ useEffect(() => {
406
+ if (!catalogInitDone) return
407
+ if (queueHydratedRef.current) return
408
+ hydrateQueueFromStorage(libraryTracks)
409
+ }, [catalogInitDone, libraryTracks, hydrateQueueFromStorage])
410
+
411
+ useEffect(() => {
412
+ const onPageHide = (): void => {
413
+ flushPersistPlaybackNow()
414
+ }
415
+ window.addEventListener('pagehide', onPageHide)
416
+ return (): void => {
417
+ window.removeEventListener('pagehide', onPageHide)
418
+ }
419
+ }, [flushPersistPlaybackNow])
420
+
421
+ const flushPersistCatalogNow = useCallback((): void => {
422
+ if (persistCatalogTimerRef.current) {
423
+ clearTimeout(persistCatalogTimerRef.current)
424
+ persistCatalogTimerRef.current = null
425
+ }
426
+ const db = dbRef.current
427
+ if (!db) return
428
+ const meta = rootsMetaRef.current
429
+ void idbPutCatalog(db, meta.map((r) => r.id), libraryTracksRef.current).catch(() => {
430
+ /* ignore */
431
+ })
432
+ }, [])
433
+
434
+ const persistCatalogDebounced = useCallback((): void => {
435
+ if (persistCatalogTimerRef.current) {
436
+ clearTimeout(persistCatalogTimerRef.current)
437
+ }
438
+ persistCatalogTimerRef.current = setTimeout(() => {
439
+ persistCatalogTimerRef.current = null
440
+ flushPersistCatalogNow()
441
+ }, 450)
442
+ }, [flushPersistCatalogNow])
443
+
444
+ useEffect(() => {
445
+ const onPageHide = (): void => {
446
+ flushPersistCatalogNow()
447
+ }
448
+ window.addEventListener('pagehide', onPageHide)
449
+ return (): void => {
450
+ window.removeEventListener('pagehide', onPageHide)
451
+ }
452
+ }, [flushPersistCatalogNow])
453
+
454
+ const clearScanDismissTimer = useCallback((): void => {
455
+ if (scanDismissTimerRef.current) {
456
+ clearTimeout(scanDismissTimerRef.current)
457
+ scanDismissTimerRef.current = null
458
+ }
459
+ }, [])
460
+
461
+ const scheduleScanDismiss = useCallback((): void => {
462
+ clearScanDismissTimer()
463
+ scanDismissTimerRef.current = setTimeout(() => {
464
+ scanDismissTimerRef.current = null
465
+ setScanProgress(null)
466
+ }, SCAN_NOTIFICATION_DISMISS_MS)
467
+ }, [clearScanDismissTimer])
468
+
469
+ const dismissScanNotification = useCallback((): void => {
470
+ clearScanDismissTimer()
471
+ setScanProgress(null)
472
+ }, [clearScanDismissTimer])
473
+
474
+ const applyScanProgressTick = useCallback((tick: ScanProgressTick): void => {
475
+ setScanProgress({
476
+ percent: scanProgressPercent(tick),
477
+ label: scanProgressLabel(tick),
478
+ rootName: tick.rootName,
479
+ filesDone: tick.filesDone ?? 0,
480
+ filesTotal: tick.filesTotal ?? 0,
481
+ })
482
+ }, [])
483
+
484
+ const performScan = useCallback(
485
+ async (withUserActivation: boolean) => {
486
+ if (scanLockRef.current) return null
487
+ scanLockRef.current = true
488
+ const meta = rootsMetaRef.current
489
+ const map = rootHandlesRef.current
490
+ clearScanDismissTimer()
491
+ setIsScanning(true)
492
+ setScanError(null)
493
+ setScanProgress({
494
+ percent: 0,
495
+ label: 'Starting library scan…',
496
+ rootName: null,
497
+ filesDone: 0,
498
+ filesTotal: 0,
499
+ })
500
+ try {
501
+ if (meta.length > 0) {
502
+ await reconfirmReadAccessForHandles(map, withUserActivation)
503
+ }
504
+ const treeOpts = scanPreferencesToTreeOptions(scanPrefsRef.current)
505
+ const result = await collectTracksForMeta(meta, map, treeOpts, applyScanProgressTick)
506
+ setScanProgress({
507
+ percent: 100,
508
+ label:
509
+ result.tracks.length > 0
510
+ ? `Found ${result.tracks.length} track${result.tracks.length === 1 ? '' : 's'}`
511
+ : 'Scan complete',
512
+ rootName: null,
513
+ filesDone: result.tracks.length,
514
+ filesTotal: result.tracks.length,
515
+ })
516
+ scheduleScanDismiss()
517
+ return result
518
+ } catch (e) {
519
+ setScanProgress(null)
520
+ setScanError(e instanceof Error ? e.message : 'Scan failed')
521
+ return null
522
+ } finally {
523
+ scanLockRef.current = false
524
+ setIsScanning(false)
525
+ }
526
+ },
527
+ [applyScanProgressTick, clearScanDismissTimer, scheduleScanDismiss],
528
+ )
529
+
530
+ const runScan = useCallback(
531
+ async (withUserActivation = false): Promise<void> => {
532
+ const result = await performScan(withUserActivation)
533
+ if (!result) return
534
+ const meta = rootsMetaRef.current
535
+ const { tracks: next, failedRootCount, firstError } = result
536
+ setLibraryTracks(next)
537
+ const db = dbRef.current
538
+ if (db) {
539
+ try {
540
+ await idbPutCatalog(db, meta.map((r) => r.id), next)
541
+ } catch {
542
+ /* quota or transient IDB errors — in-memory catalog still updated */
543
+ }
544
+ }
545
+ if (withUserActivation && failedRootCount > 0 && next.length === 0 && meta.length > 0) {
546
+ setScanError(firstError ? `${firstError} ${DISK_ACCESS_HINT}` : DISK_ACCESS_HINT)
547
+ }
548
+ },
549
+ [performScan],
550
+ )
551
+
552
+ const bumpTrackDuration = useCallback((trackId: string, durationSec: number) => {
553
+ if (!Number.isFinite(durationSec) || durationSec <= 0) return
554
+ const patch = (t: Track): Track =>
555
+ t.id === trackId && t.durationSec <= 0 ? { ...t, durationSec } : t
556
+ setLibraryTracks((prev) => prev.map(patch))
557
+ setQueue((prev) => prev.map((q) => ({ ...q, track: patch(q.track) })))
558
+ persistCatalogDebounced()
559
+ }, [persistCatalogDebounced])
560
+
561
+ const patchTrackById = useCallback((trackId: string, patch: (track: Track) => Track) => {
562
+ const id = trackId.trim()
563
+ if (!id) return
564
+ const apply = (t: Track): Track => (t.id === id ? patch(t) : t)
565
+ setLibraryTracks((prev) => prev.map(apply))
566
+ setQueue((prev) => prev.map((q) => ({ ...q, track: apply(q.track) })))
567
+ }, [])
568
+
569
+ useEffect(() => {
570
+ const apiKey = readStoredYoutubeApiKey()
571
+ if (!apiKey || readYoutubeDataApiBlocked()) return undefined
572
+ const targets = collectYoutubePrefetchTargets(queue.map((row) => row.track)).slice(0, 8)
573
+ if (targets.length === 0) return undefined
574
+ const controller = new AbortController()
575
+ void prefetchYoutubeVideoIds(
576
+ targets,
577
+ apiKey,
578
+ (trackId, videoId) => {
579
+ patchTrackById(trackId, (t) => ({ ...t, youtubeVideoId: videoId }))
580
+ },
581
+ { signal: controller.signal },
582
+ )
583
+ return (): void => {
584
+ controller.abort()
585
+ }
586
+ }, [queue, patchTrackById])
587
+
588
+ const addToQueue = useCallback((items: Track | readonly Track[]) => {
589
+ const list = Array.isArray(items) ? items : [items]
590
+ const rows: QueuedTrack[] = list.map((track) => ({
591
+ queueId: crypto.randomUUID(),
592
+ track,
593
+ }))
594
+ setQueue((prev) => [...prev, ...rows])
595
+ }, [])
596
+
597
+ const addToLibrary = useCallback((track: Track) => {
598
+ setLibraryTracks((prev) => {
599
+ if (prev.some((item) => item.id === track.id)) return prev
600
+ return [...prev, track]
601
+ })
602
+ persistCatalogDebounced()
603
+ }, [persistCatalogDebounced])
604
+
605
+ const removeFromQueue = useCallback((queueId: string) => {
606
+ setQueue((prev) => prev.filter((q) => q.queueId !== queueId))
607
+ }, [])
608
+
609
+ const clearQueue = useCallback(() => {
610
+ setQueue([])
611
+ }, [])
612
+
613
+ const reorderQueueItems = useCallback((fromIndex: number, toIndex: number) => {
614
+ if (!Number.isFinite(fromIndex) || !Number.isFinite(toIndex)) return
615
+ const fi = Math.trunc(fromIndex)
616
+ const ti = Math.trunc(toIndex)
617
+ setQueue((prev) => {
618
+ if (fi < 0 || ti < 0 || fi >= prev.length || ti >= prev.length) return prev
619
+ if (fi === ti) return prev
620
+ const next = [...prev]
621
+ const [moved] = next.splice(fi, 1)
622
+ next.splice(ti, 0, moved)
623
+ return next
624
+ })
625
+ }, [])
626
+
627
+ const recordRecentlyPlayedTrack = useCallback((trackId: string) => {
628
+ const id = trackId.trim()
629
+ if (!id) return
630
+ setRecentlyPlayedTrackIds((prev) => {
631
+ const next = [id, ...prev.filter((x) => x !== id)]
632
+ return next.slice(0, RECENTLY_PLAYED_LIMIT)
633
+ })
634
+ }, [])
635
+
636
+ const setCompactLists = useCallback((next: boolean) => {
637
+ setCompactListsState(next)
638
+ safeWriteStoredBoolean(STORAGE_COMPACT_LISTS, next)
639
+ }, [])
640
+
641
+ const setAutoRescanOnStartup = useCallback((next: boolean) => {
642
+ setAutoRescanOnStartupState(next)
643
+ safeWriteStoredBoolean(STORAGE_AUTO_RESCAN_ON_STARTUP, next)
644
+ }, [])
645
+
646
+ const setScanPreferences = useCallback((next: LibraryScanPreferences) => {
647
+ scanPrefsRef.current = next
648
+ setScanPreferencesState(next)
649
+ writeStoredLibraryScanPreferences(next)
650
+ }, [])
651
+
652
+ const setRememberLastQueue = useCallback((next: boolean) => {
653
+ rememberLastQueueRef.current = next
654
+ setRememberLastQueueState(next)
655
+ safeWriteStoredBoolean(STORAGE_REMEMBER_LAST_QUEUE, next)
656
+ if (!next) {
657
+ writeStoredPlaybackSnapshot({ trackIds: [], activeTrackId: null, positionSec: 0 })
658
+ } else {
659
+ flushPersistPlaybackNow()
660
+ }
661
+ }, [flushPersistPlaybackNow])
662
+
663
+ const consumePlaybackRestore = useCallback((): void => {
664
+ setPlaybackRestore(null)
665
+ }, [])
666
+
667
+ const reportPlayback = useCallback(
668
+ (activeQueueId: string | null, positionSec: number): void => {
669
+ playbackReportRef.current = { activeQueueId, positionSec }
670
+ persistPlaybackDebounced()
671
+ },
672
+ [persistPlaybackDebounced],
673
+ )
674
+
675
+ const resolveFileForTrack = useCallback((track: Track) => {
676
+ return resolveTrackToFile(track, rootHandlesRef.current)
677
+ }, [])
678
+
679
+ const favoriteSongSet = useMemo(() => new Set(favoriteSongIds), [favoriteSongIds])
680
+ const favoriteArtistSet = useMemo(() => new Set(favoriteArtistNames), [favoriteArtistNames])
681
+ const favoriteAlbumSet = useMemo(() => new Set(favoriteAlbumKeys), [favoriteAlbumKeys])
682
+
683
+ const isFavoriteSong = useCallback(
684
+ (trackId: string) => favoriteSongSet.has(trackId),
685
+ [favoriteSongSet],
686
+ )
687
+ const isFavoriteArtist = useCallback(
688
+ (name: string) => favoriteArtistSet.has(name),
689
+ [favoriteArtistSet],
690
+ )
691
+ const isFavoriteAlbum = useCallback(
692
+ (albumKey: string) => favoriteAlbumSet.has(albumKey),
693
+ [favoriteAlbumSet],
694
+ )
695
+
696
+ const toggleFavoriteSong = useCallback((trackId: string) => {
697
+ setFavoriteSongIds((prev) => toggleSortedStringId(prev, trackId))
698
+ }, [])
699
+
700
+ const toggleFavoriteArtist = useCallback((name: string) => {
701
+ setFavoriteArtistNames((prev) => toggleSortedStringId(prev, name))
702
+ }, [])
703
+
704
+ const toggleFavoriteAlbum = useCallback((albumKey: string) => {
705
+ setFavoriteAlbumKeys((prev) => toggleSortedStringId(prev, albumKey))
706
+ }, [])
707
+
708
+ const toggleFavoriteTrack = useCallback((track: Track) => {
709
+ toggleFavoriteSong(track.id)
710
+ }, [toggleFavoriteSong])
711
+
712
+ useEffect(() => {
713
+ if (!favoritesReadyRef.current) return
714
+ const db = dbRef.current
715
+ if (!db) return
716
+ void idbPutFavorites(db, {
717
+ songIds: favoriteSongIds,
718
+ artistNames: favoriteArtistNames,
719
+ albumKeys: favoriteAlbumKeys,
720
+ }).catch(() => {
721
+ /* ignore */
722
+ })
723
+ }, [favoriteSongIds, favoriteArtistNames, favoriteAlbumKeys])
724
+
725
+ const ingestPickedFolder = useCallback(
726
+ async (handle: FileSystemDirectoryHandle): Promise<void> => {
727
+ try {
728
+ const id = crypto.randomUUID()
729
+ const addedAt = Date.now()
730
+ const row: StoredLibraryRoot = { id, name: handle.name, addedAt, handle }
731
+ const db = dbRef.current
732
+ if (!db) {
733
+ setScanError('Library database is not ready yet.')
734
+ return
735
+ }
736
+ await idbPutRoot(db, row)
737
+ rootHandlesRef.current.set(id, handle)
738
+ const meta: LibraryRootMeta[] = [...rootsMetaRef.current, { id, name: handle.name, addedAt }]
739
+ rootsMetaRef.current = meta
740
+ setRoots(meta)
741
+ await runScan(true)
742
+ } catch (e) {
743
+ setScanError(formatFsAccessErrorMessage(e))
744
+ }
745
+ },
746
+ [runScan],
747
+ )
748
+
749
+ /** Call only from a direct click handler so the directory picker keeps user activation. */
750
+ const addLibraryFolder = useCallback((): void => {
751
+ if (typeof window === 'undefined') return
752
+ if (!window.isSecureContext) {
753
+ setScanError(
754
+ 'Folder access needs a secure context. Use https:// or open the app at http://localhost.',
755
+ )
756
+ return
757
+ }
758
+ if (!hasDirectoryPicker) {
759
+ setScanError(
760
+ 'This browser does not support folder selection. Use a Chromium-based desktop browser.',
761
+ )
762
+ return
763
+ }
764
+ const db = dbRef.current
765
+ if (!db) {
766
+ setScanError('Library database is not ready yet.')
767
+ return
768
+ }
769
+ setScanError(null)
770
+ let pickerPromise: Promise<FileSystemDirectoryHandle>
771
+ try {
772
+ pickerPromise = window.showDirectoryPicker({ mode: 'read' })
773
+ } catch (e) {
774
+ const msg = formatFsAccessErrorMessage(e)
775
+ if (msg) setScanError(msg)
776
+ return
777
+ }
778
+ void pickerPromise
779
+ .then((handle) => ingestPickedFolder(handle))
780
+ .catch((e) => {
781
+ if (e instanceof DOMException && e.name === 'AbortError') return
782
+ const msg = formatFsAccessErrorMessage(e)
783
+ if (msg) setScanError(msg)
784
+ })
785
+ }, [hasDirectoryPicker, ingestPickedFolder])
786
+
787
+ const removeLibraryFolder = useCallback(
788
+ async (id: string): Promise<void> => {
789
+ const db = dbRef.current
790
+ if (!db) return
791
+ try {
792
+ await idbDeleteRoot(db, id)
793
+ rootHandlesRef.current.delete(id)
794
+ const meta = rootsMetaRef.current.filter((r) => r.id !== id)
795
+ rootsMetaRef.current = meta
796
+ setRoots(meta)
797
+ await runScan(true)
798
+ } catch (e) {
799
+ setScanError(e instanceof Error ? e.message : 'Could not remove library folder')
800
+ }
801
+ },
802
+ [runScan],
803
+ )
804
+
805
+ const rescanAll = useCallback(async (): Promise<void> => {
806
+ await runScan(true)
807
+ }, [runScan])
808
+
809
+ useEffect(() => {
810
+ let disposed = false
811
+ void (async (): Promise<void> => {
812
+ let catalogTracks: Track[] = []
813
+ try {
814
+ const db = await openLibraryDb()
815
+ if (disposed) {
816
+ db.close()
817
+ return
818
+ }
819
+ dbRef.current = db
820
+ try {
821
+ const fav = await idbGetFavorites(db)
822
+ if (!disposed) {
823
+ setFavoriteSongIds(fav.songIds)
824
+ setFavoriteArtistNames(fav.artistNames)
825
+ setFavoriteAlbumKeys(fav.albumKeys)
826
+ }
827
+ } catch {
828
+ if (!disposed) {
829
+ setFavoriteSongIds([])
830
+ setFavoriteArtistNames([])
831
+ setFavoriteAlbumKeys([])
832
+ }
833
+ }
834
+ if (!disposed) {
835
+ favoritesReadyRef.current = true
836
+ }
837
+ void navigator.storage?.persist?.()
838
+ const rows = await idbGetAllRoots(db)
839
+ if (disposed) return
840
+ const map = new Map<string, FileSystemDirectoryHandle>()
841
+ const meta: LibraryRootMeta[] = []
842
+ for (const r of rows) {
843
+ map.set(r.id, r.handle)
844
+ meta.push({ id: r.id, name: r.name, addedAt: r.addedAt })
845
+ }
846
+ if (disposed) return
847
+ rootHandlesRef.current = map
848
+ rootsMetaRef.current = meta
849
+ setRoots(meta)
850
+ catalogTracks = []
851
+ let cachedApplied = false
852
+ try {
853
+ const cached = await idbGetCatalog(db)
854
+ if (
855
+ !disposed &&
856
+ cached &&
857
+ meta.length > 0 &&
858
+ catalogMatchesRoots(meta, cached.rootIds)
859
+ ) {
860
+ catalogTracks = cached.tracks
861
+ setLibraryTracks(catalogTracks)
862
+ cachedApplied = true
863
+ }
864
+ } catch {
865
+ /* ignore missing or corrupt catalog */
866
+ }
867
+ if (disposed) return
868
+ const mayRescanOnLoad = meta.length > 0 && (await everyHandleHasGrantedReadAccess(map))
869
+ const shouldAutoRescan = safeReadStoredBooleanOrDefault(STORAGE_AUTO_RESCAN_ON_STARTUP, true)
870
+ if (disposed) return
871
+ if (mayRescanOnLoad && shouldAutoRescan && !disposed) {
872
+ const result = await performScan(false)
873
+ if (disposed || !result) return
874
+ const { tracks: next, firstError, failedRootCount } = result
875
+ const needsGesture = next.length === 0 && meta.length > 0 && failedRootCount > 0
876
+ if (needsGesture) {
877
+ if (cachedApplied) {
878
+ setScanError(null)
879
+ } else {
880
+ catalogTracks = next
881
+ setLibraryTracks(catalogTracks)
882
+ try {
883
+ await idbPutCatalog(db, meta.map((r) => r.id), next)
884
+ } catch {
885
+ /* ignore */
886
+ }
887
+ setScanError(firstError ? `${firstError} ${DISK_ACCESS_HINT}` : DISK_ACCESS_HINT)
888
+ }
889
+ } else {
890
+ catalogTracks = next
891
+ setLibraryTracks(catalogTracks)
892
+ try {
893
+ await idbPutCatalog(db, meta.map((r) => r.id), next)
894
+ } catch {
895
+ /* ignore */
896
+ }
897
+ }
898
+ }
899
+ } catch (e) {
900
+ if (!disposed) {
901
+ setScanError(e instanceof Error ? e.message : 'Could not load library')
902
+ }
903
+ } finally {
904
+ if (!disposed) {
905
+ if (!queueHydratedRef.current) {
906
+ hydrateQueueFromStorage(catalogTracks)
907
+ }
908
+ setCatalogInitDone(true)
909
+ }
910
+ }
911
+ })()
912
+ return (): void => {
913
+ disposed = true
914
+ }
915
+ }, [performScan, hydrateQueueFromStorage])
916
+
917
+ const value = useMemo(
918
+ () => ({
919
+ roots,
920
+ libraryTracks,
921
+ queue,
922
+ recentlyPlayedTrackIds,
923
+ compactLists,
924
+ autoRescanOnStartup,
925
+ scanPreferences,
926
+ rememberLastQueue,
927
+ playbackRestore,
928
+ isQueueReady,
929
+ isScanning,
930
+ scanError,
931
+ hasDirectoryPicker,
932
+ addLibraryFolder,
933
+ removeLibraryFolder,
934
+ rescanAll,
935
+ addToQueue,
936
+ addToLibrary,
937
+ removeFromQueue,
938
+ clearQueue,
939
+ recordRecentlyPlayedTrack,
940
+ setCompactLists,
941
+ setAutoRescanOnStartup,
942
+ setScanPreferences,
943
+ setRememberLastQueue,
944
+ consumePlaybackRestore,
945
+ reportPlayback,
946
+ reorderQueueItems,
947
+ resolveFileForTrack,
948
+ bumpTrackDuration,
949
+ patchTrackById,
950
+ favoriteSongIds,
951
+ favoriteArtistNames,
952
+ favoriteAlbumKeys,
953
+ isFavoriteSong,
954
+ isFavoriteArtist,
955
+ isFavoriteAlbum,
956
+ toggleFavoriteSong,
957
+ toggleFavoriteArtist,
958
+ toggleFavoriteAlbum,
959
+ toggleFavoriteTrack,
960
+ }),
961
+ [
962
+ roots,
963
+ libraryTracks,
964
+ queue,
965
+ recentlyPlayedTrackIds,
966
+ compactLists,
967
+ autoRescanOnStartup,
968
+ scanPreferences,
969
+ rememberLastQueue,
970
+ playbackRestore,
971
+ isQueueReady,
972
+ isScanning,
973
+ scanError,
974
+ hasDirectoryPicker,
975
+ addLibraryFolder,
976
+ removeLibraryFolder,
977
+ rescanAll,
978
+ addToQueue,
979
+ addToLibrary,
980
+ removeFromQueue,
981
+ clearQueue,
982
+ recordRecentlyPlayedTrack,
983
+ setCompactLists,
984
+ setAutoRescanOnStartup,
985
+ setScanPreferences,
986
+ setRememberLastQueue,
987
+ consumePlaybackRestore,
988
+ reportPlayback,
989
+ reorderQueueItems,
990
+ resolveFileForTrack,
991
+ bumpTrackDuration,
992
+ patchTrackById,
993
+ favoriteSongIds,
994
+ favoriteArtistNames,
995
+ favoriteAlbumKeys,
996
+ isFavoriteSong,
997
+ isFavoriteArtist,
998
+ isFavoriteAlbum,
999
+ toggleFavoriteSong,
1000
+ toggleFavoriteArtist,
1001
+ toggleFavoriteAlbum,
1002
+ toggleFavoriteTrack,
1003
+ ],
1004
+ )
1005
+
1006
+ return (
1007
+ <LibraryContext.Provider value={value}>
1008
+ {props.children}
1009
+ <LibraryScanNotification progress={scanProgress} onDismiss={dismissScanNotification} />
1010
+ </LibraryContext.Provider>
1011
+ )
1012
+ }
1013
+
1014
+ /**
1015
+ * Access configured library folders and scanned tracks.
1016
+ */
1017
+ export function useLibrary(): LibraryContextValue {
1018
+ const ctx = useContext(LibraryContext)
1019
+ if (!ctx) {
1020
+ throw new Error('useLibrary must be used within LibraryProvider')
1021
+ }
1022
+ return ctx
1023
+ }