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,1180 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useMemo, useState } from 'react'
4
+ import { useLibrary } from '@/components/LibraryProvider'
5
+ import type { LibraryRootMeta } from '@/components/LibraryProvider'
6
+ import type { Track } from '@/types/track'
7
+ import AlbumCoverThumb from '@/components/AlbumCoverThumb'
8
+ import FavoriteStarButton from '@/components/FavoriteStarButton'
9
+ import { albumCompositeKey, artistDisplayName } from '@/lib/library/favorite-keys'
10
+ import { formatDuration } from '@/lib/format-duration'
11
+
12
+ type BrowseMode = 'artist' | 'album' | 'folder' | 'favorites'
13
+
14
+ function filterTracksByQuery(tracks: readonly Track[], query: string): Track[] {
15
+ const s = query.trim().toLowerCase()
16
+ if (!s) return [...tracks]
17
+ return tracks.filter((t) => {
18
+ const rel = t.library?.relativePath ?? ''
19
+ return (
20
+ t.title.toLowerCase().includes(s) ||
21
+ t.artist.toLowerCase().includes(s) ||
22
+ t.album.toLowerCase().includes(s) ||
23
+ rel.toLowerCase().includes(s)
24
+ )
25
+ })
26
+ }
27
+
28
+ function normQ(query: string): string {
29
+ return query.trim().toLowerCase()
30
+ }
31
+
32
+ function folderPathMatchesQuery(folderPath: string, q: string): boolean {
33
+ if (!q) return false
34
+ const p = folderPath.toLowerCase()
35
+ if (p.includes(q)) return true
36
+ return folderPath
37
+ .split('/')
38
+ .filter(Boolean)
39
+ .some((seg) => seg.toLowerCase().includes(q))
40
+ }
41
+
42
+ /** Parent folder paths for `a/b/track.mp3` → `['a','a/b']`. */
43
+ function ancestorFolderPathsFromRelativePath(relativePath: string): string[] {
44
+ const parts = relativePath.split('/').filter(Boolean)
45
+ if (parts.length <= 1) return []
46
+ const out: string[] = []
47
+ for (let i = 0; i < parts.length - 1; i++) {
48
+ out.push(parts.slice(0, i + 1).join('/'))
49
+ }
50
+ return out
51
+ }
52
+
53
+ type SearchArtistHit = { name: string; tracks: Track[] }
54
+ type SearchAlbumHit = { key: string; album: string; artist: string; tracks: Track[] }
55
+ type SearchFolderHit = { rootId: string; rootName: string; path: string; tracks: Track[] }
56
+
57
+ type SearchResults = {
58
+ artists: SearchArtistHit[]
59
+ albums: SearchAlbumHit[]
60
+ folders: SearchFolderHit[]
61
+ songs: Track[]
62
+ }
63
+
64
+ function computeSearchResults(
65
+ tracks: readonly Track[],
66
+ roots: readonly LibraryRootMeta[],
67
+ query: string,
68
+ ): SearchResults {
69
+ const q = normQ(query)
70
+ if (!q) {
71
+ return { artists: [], albums: [], folders: [], songs: [] }
72
+ }
73
+
74
+ const rootLabel = (rootId: string): string => roots.find((r) => r.id === rootId)?.name ?? rootId
75
+
76
+ const artistMapFull = groupByArtist(tracks)
77
+ const artists: SearchArtistHit[] = []
78
+ for (const [name, list] of artistMapFull) {
79
+ if (name.toLowerCase().includes(q)) {
80
+ artists.push({ name, tracks: list })
81
+ }
82
+ }
83
+ artists.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
84
+
85
+ const albumMapFull = groupByAlbum(tracks)
86
+ const albums: SearchAlbumHit[] = []
87
+ for (const [key, list] of albumMapFull) {
88
+ const [album, artist] = key.split('\u0000')
89
+ if (album.toLowerCase().includes(q) || artist.toLowerCase().includes(q)) {
90
+ albums.push({ key, album, artist, tracks: list })
91
+ }
92
+ }
93
+ albums.sort((a, b) => {
94
+ const c = a.album.localeCompare(b.album, undefined, { sensitivity: 'base' })
95
+ return c !== 0 ? c : a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' })
96
+ })
97
+
98
+ const folderBuckets = new Map<string, SearchFolderHit>()
99
+ const addToFolder = (rootId: string, path: string, t: Track): void => {
100
+ const key = `${rootId}\u0000${path}`
101
+ let hit = folderBuckets.get(key)
102
+ if (!hit) {
103
+ hit = { rootId, rootName: rootLabel(rootId), path, tracks: [] }
104
+ folderBuckets.set(key, hit)
105
+ }
106
+ if (!hit.tracks.some((x) => x.id === t.id)) {
107
+ hit.tracks.push(t)
108
+ }
109
+ }
110
+
111
+ for (const r of roots) {
112
+ if (r.name.toLowerCase().includes(q)) {
113
+ for (const t of tracks) {
114
+ if (t.library?.rootId === r.id) {
115
+ addToFolder(r.id, '', t)
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ for (const t of tracks) {
122
+ const lib = t.library
123
+ if (!lib) continue
124
+ const rel = lib.relativePath
125
+ for (const folderPath of ancestorFolderPathsFromRelativePath(rel)) {
126
+ if (folderPathMatchesQuery(folderPath, q)) {
127
+ addToFolder(lib.rootId, folderPath, t)
128
+ }
129
+ }
130
+ }
131
+
132
+ const folders = [...folderBuckets.values()].sort((a, b) => {
133
+ const c = a.rootName.localeCompare(b.rootName, undefined, { sensitivity: 'base' })
134
+ if (c !== 0) return c
135
+ return a.path.localeCompare(b.path, undefined, { sensitivity: 'base' })
136
+ })
137
+
138
+ const songs = tracks
139
+ .filter((t) => t.title.toLowerCase().includes(q))
140
+ .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
141
+
142
+ return { artists, albums, folders, songs }
143
+ }
144
+
145
+ function unionTracksById(lists: readonly (readonly Track[])[]): Track[] {
146
+ const seen = new Set<string>()
147
+ const out: Track[] = []
148
+ for (const list of lists) {
149
+ for (const t of list) {
150
+ if (seen.has(t.id)) continue
151
+ seen.add(t.id)
152
+ out.push(t)
153
+ }
154
+ }
155
+ return out
156
+ }
157
+
158
+ function folderSearchLabel(hit: SearchFolderHit): string {
159
+ if (!hit.path) return `${hit.rootName} (library)`
160
+ return `${hit.rootName} / ${hit.path.replace(/\//g, ' / ')}`
161
+ }
162
+
163
+ function groupByArtist(tracks: readonly Track[]): Map<string, Track[]> {
164
+ const m = new Map<string, Track[]>()
165
+ for (const t of tracks) {
166
+ const a = artistDisplayName(t.artist)
167
+ const arr = m.get(a) ?? []
168
+ arr.push(t)
169
+ m.set(a, arr)
170
+ }
171
+ for (const arr of m.values()) {
172
+ arr.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
173
+ }
174
+ return m
175
+ }
176
+
177
+ function groupByAlbum(tracks: readonly Track[]): Map<string, Track[]> {
178
+ const m = new Map<string, Track[]>()
179
+ for (const t of tracks) {
180
+ const key = albumCompositeKey(t.album, t.artist)
181
+ const arr = m.get(key) ?? []
182
+ arr.push(t)
183
+ m.set(key, arr)
184
+ }
185
+ for (const arr of m.values()) {
186
+ arr.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
187
+ }
188
+ return m
189
+ }
190
+
191
+ function tracksForRoot(tracks: readonly Track[], rootId: string): Track[] {
192
+ return tracks.filter((t) => t.library?.rootId === rootId)
193
+ }
194
+
195
+ function listFolderChildren(tracksAtRoot: readonly Track[], pathPrefix: string): { folders: string[]; files: Track[] } {
196
+ const prefixSlash = pathPrefix === '' ? '' : `${pathPrefix}/`
197
+ const folderSet = new Set<string>()
198
+ const files: Track[] = []
199
+ for (const t of tracksAtRoot) {
200
+ const rel = t.library?.relativePath ?? ''
201
+ if (pathPrefix === '') {
202
+ if (!rel.includes('/')) {
203
+ files.push(t)
204
+ } else {
205
+ folderSet.add(rel.slice(0, rel.indexOf('/')))
206
+ }
207
+ } else {
208
+ if (!rel.startsWith(prefixSlash)) continue
209
+ const rest = rel.slice(prefixSlash.length)
210
+ if (!rest) continue
211
+ const idx = rest.indexOf('/')
212
+ if (idx === -1) {
213
+ files.push(t)
214
+ } else {
215
+ folderSet.add(rest.slice(0, idx))
216
+ }
217
+ }
218
+ }
219
+ files.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
220
+ return {
221
+ folders: [...folderSet].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })),
222
+ files,
223
+ }
224
+ }
225
+
226
+ function rootNameById(roots: readonly LibraryRootMeta[], id: string): string {
227
+ return roots.find((r) => r.id === id)?.name ?? id
228
+ }
229
+
230
+ /** All tracks under a folder path within one library root (recursive). */
231
+ function tracksUnderFolderPath(tracksAtRoot: readonly Track[], folderPath: string): Track[] {
232
+ const fp = folderPath.trim()
233
+ if (!fp) return [...tracksAtRoot]
234
+ const prefix = `${fp}/`
235
+ return tracksAtRoot.filter((t) => {
236
+ const rel = t.library?.relativePath ?? ''
237
+ return rel === fp || rel.startsWith(prefix)
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Browse the scanned catalog by artist, album, or folder; search and add tracks to the queue.
243
+ */
244
+ export default function LibraryBrowser() {
245
+ const {
246
+ roots,
247
+ libraryTracks,
248
+ addToQueue,
249
+ compactLists,
250
+ favoriteSongIds,
251
+ favoriteArtistNames,
252
+ favoriteAlbumKeys,
253
+ isFavoriteSong,
254
+ isFavoriteArtist,
255
+ isFavoriteAlbum,
256
+ toggleFavoriteArtist,
257
+ toggleFavoriteAlbum,
258
+ toggleFavoriteTrack,
259
+ } = useLibrary()
260
+ const [mode, setMode] = useState<BrowseMode>('artist')
261
+ const [query, setQuery] = useState('')
262
+ const [artistPick, setArtistPick] = useState<string | null>(null)
263
+ const [albumPick, setAlbumPick] = useState<string | null>(null)
264
+ const [folderRootId, setFolderRootId] = useState<string | null>(null)
265
+ const [folderPath, setFolderPath] = useState('')
266
+ const filtered = useMemo(() => filterTracksByQuery(libraryTracks, query), [libraryTracks, query])
267
+ const searchActive = query.trim().length > 0
268
+ const compact = compactLists
269
+ const ulSpaceYClass = compact ? 'space-y-0.25' : 'space-y-0.5'
270
+ const rowPadLgClass = compact ? 'px-2 py-2' : 'px-3 py-2.5'
271
+ const rowPadSmClass = compact ? 'px-1.5 py-1.5' : 'px-2 py-2'
272
+ const rowGapSmClass = compact ? 'gap-1.5' : 'gap-2'
273
+ const rowGapLgClass = compact ? 'gap-2' : 'gap-3'
274
+ const folderRowPadClass = compact ? 'py-2 pr-2 pl-5' : 'py-2.5 pr-3 pl-6'
275
+
276
+ const goMode = useCallback((m: BrowseMode) => {
277
+ setMode(m)
278
+ setArtistPick(null)
279
+ setAlbumPick(null)
280
+ setFolderRootId(null)
281
+ setFolderPath('')
282
+ }, [])
283
+
284
+ const artistMap = useMemo(() => groupByArtist(filtered), [filtered])
285
+ const albumMap = useMemo(() => groupByAlbum(filtered), [filtered])
286
+
287
+ const artistNames = useMemo(
288
+ () => [...artistMap.keys()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })),
289
+ [artistMap],
290
+ )
291
+
292
+ const albumKeys = useMemo(
293
+ () => [...albumMap.keys()].sort((a, b) => {
294
+ const [albumA, artistA] = a.split('\u0000')
295
+ const [albumB, artistB] = b.split('\u0000')
296
+ const c = albumA.localeCompare(albumB, undefined, { sensitivity: 'base' })
297
+ return c !== 0 ? c : artistA.localeCompare(artistB, undefined, { sensitivity: 'base' })
298
+ }),
299
+ [albumMap],
300
+ )
301
+
302
+ const selectedAlbumDetail = useMemo(() => {
303
+ if (albumPick === null) return null
304
+ const tracks = albumMap.get(albumPick) ?? []
305
+ const parts = albumPick.split('\u0000')
306
+ const title = parts[0] ?? ''
307
+ const artist = parts[1] ?? ''
308
+ let totalSec = 0
309
+ let withDuration = 0
310
+ for (const t of tracks) {
311
+ if (t.durationSec > 0) {
312
+ totalSec += t.durationSec
313
+ withDuration += 1
314
+ }
315
+ }
316
+ const rootIdSet = new Set<string>()
317
+ for (const t of tracks) {
318
+ const id = t.library?.rootId
319
+ if (id) rootIdSet.add(id)
320
+ }
321
+ const rootLabels = [...rootIdSet]
322
+ .map((id) => roots.find((r) => r.id === id)?.name ?? id)
323
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
324
+ const uniqueArtists = new Set(tracks.map((t) => artistDisplayName(t.artist)))
325
+ return {
326
+ title,
327
+ artist,
328
+ trackCount: tracks.length,
329
+ totalSec,
330
+ withDurationCount: withDuration,
331
+ sample: tracks[0],
332
+ rootLabels,
333
+ multipleTrackArtists: uniqueArtists.size > 1,
334
+ }
335
+ }, [albumPick, albumMap, roots])
336
+
337
+ const folderTracks = useMemo(() => {
338
+ if (!folderRootId) return []
339
+ return tracksForRoot(filtered, folderRootId)
340
+ }, [filtered, folderRootId])
341
+
342
+ const folderChildren = useMemo(() => {
343
+ if (!folderRootId) return { folders: [] as string[], files: [] as Track[] }
344
+ return listFolderChildren(folderTracks, folderPath)
345
+ }, [folderRootId, folderTracks, folderPath])
346
+
347
+ const folderSubtreeTracks = useMemo(
348
+ () => tracksUnderFolderPath(folderTracks, folderPath),
349
+ [folderTracks, folderPath],
350
+ )
351
+
352
+ const rootsFiltered = useMemo(() => {
353
+ const s = query.trim().toLowerCase()
354
+ if (!s) return roots
355
+ return roots.filter((r) => r.name.toLowerCase().includes(s))
356
+ }, [roots, query])
357
+
358
+ const onAdd = useCallback(
359
+ (t: Track) => {
360
+ addToQueue(t)
361
+ },
362
+ [addToQueue],
363
+ )
364
+
365
+ const onAddMany = useCallback(
366
+ (list: readonly Track[]) => {
367
+ if (list.length === 0) return
368
+ addToQueue(list)
369
+ },
370
+ [addToQueue],
371
+ )
372
+
373
+ const searchResults = useMemo(
374
+ () => computeSearchResults(libraryTracks, roots, query),
375
+ [libraryTracks, roots, query],
376
+ )
377
+
378
+ const searchUnionTracks = useMemo(
379
+ () =>
380
+ unionTracksById([
381
+ searchResults.artists.flatMap((a) => a.tracks),
382
+ searchResults.albums.flatMap((a) => a.tracks),
383
+ searchResults.folders.flatMap((f) => f.tracks),
384
+ searchResults.songs,
385
+ ]),
386
+ [searchResults],
387
+ )
388
+
389
+ const libraryArtistMap = useMemo(() => groupByArtist(libraryTracks), [libraryTracks])
390
+ const libraryAlbumMap = useMemo(() => groupByAlbum(libraryTracks), [libraryTracks])
391
+
392
+ const favoritedArtistsList = useMemo(() => {
393
+ return favoriteArtistNames
394
+ .filter((n) => libraryArtistMap.has(n))
395
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
396
+ }, [favoriteArtistNames, libraryArtistMap])
397
+
398
+ const favoritedAlbumsList = useMemo(() => {
399
+ return favoriteAlbumKeys
400
+ .filter((k) => libraryAlbumMap.has(k))
401
+ .sort((a, b) => {
402
+ const [albumA, artistA] = a.split('\u0000')
403
+ const [albumB, artistB] = b.split('\u0000')
404
+ const c = albumA.localeCompare(albumB, undefined, { sensitivity: 'base' })
405
+ return c !== 0 ? c : artistA.localeCompare(artistB, undefined, { sensitivity: 'base' })
406
+ })
407
+ }, [favoriteAlbumKeys, libraryAlbumMap])
408
+
409
+ const favoritedTracks = useMemo(() => {
410
+ const set = new Set(favoriteSongIds)
411
+ return libraryTracks
412
+ .filter((t) => set.has(t.id))
413
+ .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
414
+ }, [libraryTracks, favoriteSongIds])
415
+
416
+ const allFavoriteTracksUnion = useMemo(() => {
417
+ const seen = new Set<string>()
418
+ const out: Track[] = []
419
+ const push = (t: Track): void => {
420
+ if (seen.has(t.id)) return
421
+ seen.add(t.id)
422
+ out.push(t)
423
+ }
424
+ for (const id of favoriteSongIds) {
425
+ const t = libraryTracks.find((x) => x.id === id)
426
+ if (t) push(t)
427
+ }
428
+ for (const name of favoriteArtistNames) {
429
+ const list = libraryArtistMap.get(name)
430
+ if (list) for (const t of list) push(t)
431
+ }
432
+ for (const k of favoriteAlbumKeys) {
433
+ const list = libraryAlbumMap.get(k)
434
+ if (list) for (const t of list) push(t)
435
+ }
436
+ out.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
437
+ return out
438
+ }, [favoriteSongIds, favoriteArtistNames, favoriteAlbumKeys, libraryTracks, libraryArtistMap, libraryAlbumMap])
439
+
440
+ const searchSummaryBits = useMemo((): string[] => {
441
+ const bits: string[] = []
442
+ const { artists, albums, folders, songs } = searchResults
443
+ if (artists.length) bits.push(`${artists.length} artist${artists.length === 1 ? '' : 's'}`)
444
+ if (albums.length) bits.push(`${albums.length} album${albums.length === 1 ? '' : 's'}`)
445
+ if (folders.length) bits.push(`${folders.length} folder${folders.length === 1 ? '' : 's'}`)
446
+ if (songs.length) bits.push(`${songs.length} song${songs.length === 1 ? '' : 's'}`)
447
+ return bits
448
+ }, [searchResults])
449
+
450
+ const navigateToArtistFromSearch = useCallback((name: string) => {
451
+ setQuery('')
452
+ setAlbumPick(null)
453
+ setFolderRootId(null)
454
+ setFolderPath('')
455
+ setMode('artist')
456
+ setArtistPick(name)
457
+ }, [])
458
+
459
+ const navigateToAlbumFromSearch = useCallback((key: string) => {
460
+ setQuery('')
461
+ setArtistPick(null)
462
+ setFolderRootId(null)
463
+ setFolderPath('')
464
+ setMode('album')
465
+ setAlbumPick(key)
466
+ }, [])
467
+
468
+ const navigateToFolderFromSearch = useCallback((rootId: string, path: string) => {
469
+ setQuery('')
470
+ setArtistPick(null)
471
+ setAlbumPick(null)
472
+ setMode('folder')
473
+ setFolderRootId(rootId)
474
+ setFolderPath(path)
475
+ }, [])
476
+
477
+ return (
478
+ <section className="flex h-full min-h-0 flex-1 flex-col overflow-hidden border-b border-zinc-200 bg-zinc-50/80 dark:border-zinc-800 dark:bg-zinc-900/30 lg:border-b-0 lg:border-r">
479
+ <div className="shrink-0 space-y-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
480
+ <h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Library</h2>
481
+ <input
482
+ type="search"
483
+ value={query}
484
+ onChange={(e) => setQuery(e.target.value)}
485
+ placeholder="Artists, albums, folders, or song titles…"
486
+ className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm 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-950 dark:text-zinc-100 dark:focus:border-amber-500/60"
487
+ aria-label="Search library"
488
+ />
489
+ <div className="flex flex-wrap gap-1">
490
+ {(['artist', 'album', 'folder', 'favorites'] as const).map((m) => (
491
+ <button
492
+ key={m}
493
+ type="button"
494
+ onClick={() => goMode(m)}
495
+ className={[
496
+ 'cursor-pointer rounded-full px-3 py-1 text-xs font-medium capitalize transition',
497
+ mode === m
498
+ ? 'bg-amber-500 text-zinc-950'
499
+ : 'bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700',
500
+ ].join(' ')}
501
+ >
502
+ {m}
503
+ </button>
504
+ ))}
505
+ </div>
506
+ </div>
507
+
508
+ <div className="min-h-0 flex-1 overflow-auto px-2 py-2">
509
+ {libraryTracks.length === 0 ? (
510
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">No library tracks yet. Configure folders in settings.</p>
511
+ ) : searchActive ? (
512
+ <div className="space-y-1">
513
+ <p className="px-2 pb-2 text-xs text-zinc-500">
514
+ {searchSummaryBits.length > 0 ? (
515
+ <>
516
+ {searchSummaryBits.join(' · ')}
517
+ {searchUnionTracks.length > 0 ? (
518
+ <span className="text-zinc-400">
519
+ {' '}
520
+ · {searchUnionTracks.length} unique track{searchUnionTracks.length === 1 ? '' : 's'} (add all)
521
+ </span>
522
+ ) : null}
523
+ </>
524
+ ) : (
525
+ 'No matches.'
526
+ )}
527
+ </p>
528
+ {searchSummaryBits.length > 0 ? (
529
+ <>
530
+ <button
531
+ type="button"
532
+ onClick={() => onAddMany(searchUnionTracks)}
533
+ disabled={searchUnionTracks.length === 0}
534
+ className="mb-2 w-full rounded-lg border border-zinc-200 bg-white py-2 text-xs font-medium text-zinc-800 transition hover:bg-zinc-50 disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
535
+ >
536
+ Add all search results to queue
537
+ </button>
538
+ {searchResults.artists.length > 0 ? (
539
+ <>
540
+ <p className="px-2 pt-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Artists</p>
541
+ <ul className={ulSpaceYClass}>
542
+ {searchResults.artists.map((hit) => (
543
+ <li key={hit.name} className="group/row flex items-center gap-1">
544
+ <button
545
+ type="button"
546
+ onClick={() => navigateToArtistFromSearch(hit.name)}
547
+ className={`flex min-w-0 flex-1 items-center justify-between rounded-lg ${rowPadLgClass} text-left text-sm text-zinc-800 transition hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800/80`}
548
+ >
549
+ <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">{hit.name}</span>
550
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">{hit.tracks.length}</span>
551
+ </button>
552
+ <FavoriteStarButton
553
+ filled={isFavoriteArtist(hit.name)}
554
+ onPress={() => toggleFavoriteArtist(hit.name)}
555
+ label={isFavoriteArtist(hit.name) ? 'Remove artist from favorites' : 'Add artist to favorites'}
556
+ />
557
+ <button
558
+ type="button"
559
+ onClick={() => onAddMany(hit.tracks)}
560
+ disabled={hit.tracks.length === 0}
561
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
562
+ >
563
+ Add all
564
+ </button>
565
+ </li>
566
+ ))}
567
+ </ul>
568
+ </>
569
+ ) : null}
570
+ {searchResults.albums.length > 0 ? (
571
+ <>
572
+ <p className="px-2 pt-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Albums</p>
573
+ <ul className={ulSpaceYClass}>
574
+ {searchResults.albums.map((hit) => {
575
+ const sample = hit.tracks[0]
576
+ return (
577
+ <li key={hit.key} className="group/row flex items-center gap-1">
578
+ <button
579
+ type="button"
580
+ onClick={() => navigateToAlbumFromSearch(hit.key)}
581
+ className={`flex min-w-0 flex-1 items-center ${rowGapLgClass} rounded-lg ${rowPadLgClass} text-left transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
582
+ >
583
+ <AlbumCoverThumb track={sample} />
584
+ <div className="flex min-w-0 flex-1 flex-col items-start">
585
+ <span className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
586
+ {hit.album}
587
+ </span>
588
+ <span className="truncate text-xs text-zinc-500">{hit.artist}</span>
589
+ <span className="mt-0.5 text-xs text-zinc-400">
590
+ {hit.tracks.length} track{hit.tracks.length === 1 ? '' : 's'}
591
+ </span>
592
+ </div>
593
+ </button>
594
+ <FavoriteStarButton
595
+ filled={isFavoriteAlbum(hit.key)}
596
+ onPress={() => toggleFavoriteAlbum(hit.key)}
597
+ label={
598
+ isFavoriteAlbum(hit.key) ? 'Remove album from favorites' : 'Add album to favorites'
599
+ }
600
+ />
601
+ <button
602
+ type="button"
603
+ onClick={() => onAddMany(hit.tracks)}
604
+ disabled={hit.tracks.length === 0}
605
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
606
+ >
607
+ Add all
608
+ </button>
609
+ </li>
610
+ )
611
+ })}
612
+ </ul>
613
+ </>
614
+ ) : null}
615
+ {searchResults.folders.length > 0 ? (
616
+ <>
617
+ <p className="px-2 pt-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Folders</p>
618
+ <ul className={ulSpaceYClass}>
619
+ {searchResults.folders.map((hit) => (
620
+ <li
621
+ key={`${hit.rootId}\u0000${hit.path}`}
622
+ className="group/row flex items-center gap-1"
623
+ >
624
+ <button
625
+ type="button"
626
+ onClick={() => navigateToFolderFromSearch(hit.rootId, hit.path)}
627
+ className={`flex min-w-0 flex-1 flex-col items-start rounded-lg ${rowPadLgClass} text-left text-sm transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
628
+ >
629
+ <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">
630
+ {folderSearchLabel(hit)}
631
+ </span>
632
+ <span className="mt-0.5 text-xs tabular-nums text-zinc-500">{hit.tracks.length} tracks</span>
633
+ </button>
634
+ <button
635
+ type="button"
636
+ onClick={() => onAddMany(hit.tracks)}
637
+ disabled={hit.tracks.length === 0}
638
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
639
+ >
640
+ Add all
641
+ </button>
642
+ </li>
643
+ ))}
644
+ </ul>
645
+ </>
646
+ ) : null}
647
+ {searchResults.songs.length > 0 ? (
648
+ <>
649
+ <p className="px-2 pt-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Songs</p>
650
+ <ul className={ulSpaceYClass}>
651
+ {searchResults.songs.map((t) => (
652
+ <li key={t.id}>
653
+ <div
654
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
655
+ >
656
+ <div className="min-w-0 flex-1">
657
+ <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
658
+ <p className="truncate text-xs text-zinc-500">
659
+ {t.artist} · {t.album}
660
+ {t.library?.relativePath ? ` · ${t.library.relativePath}` : ''}
661
+ </p>
662
+ </div>
663
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
664
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
665
+ </span>
666
+ <FavoriteStarButton
667
+ filled={isFavoriteSong(t.id)}
668
+ onPress={() => toggleFavoriteTrack(t)}
669
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
670
+ />
671
+ <button
672
+ type="button"
673
+ onClick={() => onAdd(t)}
674
+ 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"
675
+ >
676
+ Add
677
+ </button>
678
+ </div>
679
+ </li>
680
+ ))}
681
+ </ul>
682
+ </>
683
+ ) : null}
684
+ </>
685
+ ) : null}
686
+ </div>
687
+ ) : mode === 'artist' ? (
688
+ artistPick === null ? (
689
+ <ul className={ulSpaceYClass}>
690
+ {artistNames.map((name) => (
691
+ <li key={name} className="group/row flex items-center gap-1">
692
+ <button
693
+ type="button"
694
+ onClick={() => setArtistPick(name)}
695
+ className={`flex min-w-0 flex-1 items-center justify-between rounded-lg ${rowPadLgClass} text-left text-sm text-zinc-800 transition hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800/80`}
696
+ >
697
+ <span className="truncate font-medium">{name}</span>
698
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">{artistMap.get(name)?.length ?? 0}</span>
699
+ </button>
700
+ <FavoriteStarButton
701
+ filled={isFavoriteArtist(name)}
702
+ onPress={() => toggleFavoriteArtist(name)}
703
+ label={isFavoriteArtist(name) ? 'Remove artist from favorites' : 'Add artist to favorites'}
704
+ />
705
+ <button
706
+ type="button"
707
+ onClick={() => onAddMany(artistMap.get(name) ?? [])}
708
+ disabled={(artistMap.get(name)?.length ?? 0) === 0}
709
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
710
+ >
711
+ Add all
712
+ </button>
713
+ </li>
714
+ ))}
715
+ </ul>
716
+ ) : (
717
+ <div>
718
+ <button
719
+ type="button"
720
+ onClick={() => setArtistPick(null)}
721
+ className="mb-2 px-2 text-xs font-medium text-amber-700 hover:underline dark:text-amber-400"
722
+ >
723
+ ← Artists
724
+ </button>
725
+ <div className="mb-2 flex flex-wrap items-center gap-2 px-2">
726
+ <button
727
+ type="button"
728
+ onClick={() => onAddMany(artistMap.get(artistPick) ?? [])}
729
+ className="rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium dark:border-zinc-700 dark:bg-zinc-900"
730
+ >
731
+ Add all
732
+ </button>
733
+ {artistPick ? (
734
+ <FavoriteStarButton
735
+ filled={isFavoriteArtist(artistPick)}
736
+ onPress={() => toggleFavoriteArtist(artistPick)}
737
+ label={
738
+ isFavoriteArtist(artistPick) ? 'Remove artist from favorites' : 'Add artist to favorites'
739
+ }
740
+ />
741
+ ) : null}
742
+ </div>
743
+ <ul className={ulSpaceYClass}>
744
+ {(artistMap.get(artistPick) ?? []).map((t) => (
745
+ <li key={t.id}>
746
+ <div
747
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
748
+ >
749
+ <div className="min-w-0 flex-1">
750
+ <p className="truncate text-sm font-medium">{t.title}</p>
751
+ <p className="truncate text-xs text-zinc-500">{t.album}</p>
752
+ </div>
753
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
754
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
755
+ </span>
756
+ <FavoriteStarButton
757
+ filled={isFavoriteSong(t.id)}
758
+ onPress={() => toggleFavoriteTrack(t)}
759
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
760
+ />
761
+ <button
762
+ type="button"
763
+ onClick={() => onAdd(t)}
764
+ 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 dark:text-amber-300"
765
+ >
766
+ Add
767
+ </button>
768
+ </div>
769
+ </li>
770
+ ))}
771
+ </ul>
772
+ </div>
773
+ )
774
+ ) : mode === 'album' ? (
775
+ albumPick === null ? (
776
+ <ul className={ulSpaceYClass}>
777
+ {albumKeys.map((key) => {
778
+ const [album, artist] = key.split('\u0000')
779
+ const n = albumMap.get(key)?.length ?? 0
780
+ const sample = albumMap.get(key)?.[0]
781
+ return (
782
+ <li key={key} className="group/row flex items-center gap-1">
783
+ <button
784
+ type="button"
785
+ onClick={() => setAlbumPick(key)}
786
+ className={`flex min-w-0 flex-1 items-center ${rowGapLgClass} rounded-lg ${rowPadLgClass} text-left transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
787
+ >
788
+ <AlbumCoverThumb track={sample} />
789
+ <div className="flex min-w-0 flex-1 flex-col items-start">
790
+ <span className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{album}</span>
791
+ <span className="truncate text-xs text-zinc-500">{artist}</span>
792
+ <span className="mt-0.5 text-xs text-zinc-400">{n} track{n === 1 ? '' : 's'}</span>
793
+ </div>
794
+ </button>
795
+ <FavoriteStarButton
796
+ filled={isFavoriteAlbum(key)}
797
+ onPress={() => toggleFavoriteAlbum(key)}
798
+ label={isFavoriteAlbum(key) ? 'Remove album from favorites' : 'Add album to favorites'}
799
+ />
800
+ <button
801
+ type="button"
802
+ onClick={() => onAddMany(albumMap.get(key) ?? [])}
803
+ disabled={n === 0}
804
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
805
+ >
806
+ Add all
807
+ </button>
808
+ </li>
809
+ )
810
+ })}
811
+ </ul>
812
+ ) : (
813
+ <div>
814
+ <button
815
+ type="button"
816
+ onClick={() => setAlbumPick(null)}
817
+ className="mb-2 px-2 text-xs font-medium text-amber-700 hover:underline dark:text-amber-400"
818
+ >
819
+ ← Albums
820
+ </button>
821
+ {selectedAlbumDetail ? (
822
+ <div className="mb-4 border-b border-zinc-200 px-2 pb-4 dark:border-zinc-800">
823
+ <div className="flex gap-4">
824
+ <AlbumCoverThumb
825
+ track={selectedAlbumDetail.sample}
826
+ className="h-22 w-22 shrink-0 overflow-hidden rounded-lg ring-1 ring-zinc-200/80 dark:ring-zinc-700/80"
827
+ />
828
+ <div className="min-w-0 flex-1 py-0.5">
829
+ <h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-50">
830
+ {selectedAlbumDetail.title}
831
+ </h3>
832
+ <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{selectedAlbumDetail.artist}</p>
833
+ {selectedAlbumDetail.multipleTrackArtists ? (
834
+ <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
835
+ Track-level artists differ on this album
836
+ </p>
837
+ ) : null}
838
+ <p className="mt-2 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
839
+ {selectedAlbumDetail.trackCount} track{selectedAlbumDetail.trackCount === 1 ? '' : 's'}
840
+ {selectedAlbumDetail.totalSec > 0
841
+ ? ` · ${formatDuration(selectedAlbumDetail.totalSec)} total`
842
+ : selectedAlbumDetail.trackCount > 0
843
+ ? ' · unknown total length'
844
+ : ''}
845
+ {selectedAlbumDetail.withDurationCount > 0 &&
846
+ selectedAlbumDetail.withDurationCount < selectedAlbumDetail.trackCount ? (
847
+ <span className="text-zinc-400"> ({selectedAlbumDetail.withDurationCount} timed)</span>
848
+ ) : null}
849
+ </p>
850
+ {selectedAlbumDetail.rootLabels.length > 1 ? (
851
+ <p className="mt-1.5 text-xs leading-snug text-zinc-500 dark:text-zinc-500">
852
+ Libraries: {selectedAlbumDetail.rootLabels.join(' · ')}
853
+ </p>
854
+ ) : selectedAlbumDetail.rootLabels.length === 1 ? (
855
+ <p className="mt-1.5 text-xs text-zinc-500 dark:text-zinc-500">
856
+ Library: {selectedAlbumDetail.rootLabels[0]}
857
+ </p>
858
+ ) : null}
859
+ </div>
860
+ </div>
861
+ </div>
862
+ ) : null}
863
+ <div className="mb-2 flex flex-wrap items-center gap-2 px-2">
864
+ <button
865
+ type="button"
866
+ onClick={() => onAddMany(albumMap.get(albumPick) ?? [])}
867
+ className="rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium dark:border-zinc-700 dark:bg-zinc-900"
868
+ >
869
+ Add all
870
+ </button>
871
+ {albumPick ? (
872
+ <FavoriteStarButton
873
+ filled={isFavoriteAlbum(albumPick)}
874
+ onPress={() => toggleFavoriteAlbum(albumPick)}
875
+ label={isFavoriteAlbum(albumPick) ? 'Remove album from favorites' : 'Add album to favorites'}
876
+ />
877
+ ) : null}
878
+ </div>
879
+ <ul className={ulSpaceYClass}>
880
+ {(albumMap.get(albumPick) ?? []).map((t) => (
881
+ <li key={t.id}>
882
+ <div
883
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
884
+ >
885
+ <div className="min-w-0 flex-1">
886
+ <p className="truncate text-sm font-medium">{t.title}</p>
887
+ <p className="truncate text-xs text-zinc-500">{t.artist}</p>
888
+ </div>
889
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
890
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
891
+ </span>
892
+ <FavoriteStarButton
893
+ filled={isFavoriteSong(t.id)}
894
+ onPress={() => toggleFavoriteTrack(t)}
895
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
896
+ />
897
+ <button
898
+ type="button"
899
+ onClick={() => onAdd(t)}
900
+ 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 dark:text-amber-300"
901
+ >
902
+ Add
903
+ </button>
904
+ </div>
905
+ </li>
906
+ ))}
907
+ </ul>
908
+ </div>
909
+ )
910
+ ) : mode === 'favorites' ? (
911
+ <div className="space-y-4 px-1">
912
+ <div className="flex flex-wrap gap-2 px-2">
913
+ <button
914
+ type="button"
915
+ onClick={() => onAddMany(allFavoriteTracksUnion)}
916
+ disabled={allFavoriteTracksUnion.length === 0}
917
+ className="rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900"
918
+ >
919
+ Add all favorite tracks ({allFavoriteTracksUnion.length})
920
+ </button>
921
+ </div>
922
+ {favoritedArtistsList.length === 0 && favoritedAlbumsList.length === 0 && favoritedTracks.length === 0 ? (
923
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">
924
+ No favorites yet. Use the star on artists, albums, or songs while browsing or searching.
925
+ </p>
926
+ ) : (
927
+ <>
928
+ {favoritedArtistsList.length > 0 ? (
929
+ <div>
930
+ <p className="px-2 pb-1 text-xs font-medium uppercase tracking-wider text-zinc-500">Artists</p>
931
+ <ul className={ulSpaceYClass}>
932
+ {favoritedArtistsList.map((name) => (
933
+ <li key={name} className="group/row flex items-center gap-1">
934
+ <button
935
+ type="button"
936
+ onClick={() => {
937
+ goMode('artist')
938
+ setArtistPick(name)
939
+ }}
940
+ className={`flex min-w-0 flex-1 items-center justify-between rounded-lg ${rowPadLgClass} text-left text-sm text-zinc-800 transition hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800/80`}
941
+ >
942
+ <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">{name}</span>
943
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
944
+ {libraryArtistMap.get(name)?.length ?? 0}
945
+ </span>
946
+ </button>
947
+ <FavoriteStarButton
948
+ filled
949
+ onPress={() => toggleFavoriteArtist(name)}
950
+ label="Remove artist from favorites"
951
+ />
952
+ <button
953
+ type="button"
954
+ onClick={() => onAddMany(libraryArtistMap.get(name) ?? [])}
955
+ disabled={(libraryArtistMap.get(name)?.length ?? 0) === 0}
956
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
957
+ >
958
+ Add all
959
+ </button>
960
+ </li>
961
+ ))}
962
+ </ul>
963
+ </div>
964
+ ) : null}
965
+ {favoritedAlbumsList.length > 0 ? (
966
+ <div>
967
+ <p className="px-2 pb-1 pt-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Albums</p>
968
+ <ul className={ulSpaceYClass}>
969
+ {favoritedAlbumsList.map((key) => {
970
+ const [album, artist] = key.split('\u0000')
971
+ const list = libraryAlbumMap.get(key) ?? []
972
+ const sample = list[0]
973
+ return (
974
+ <li key={key} className="group/row flex items-center gap-1">
975
+ <button
976
+ type="button"
977
+ onClick={() => {
978
+ goMode('album')
979
+ setAlbumPick(key)
980
+ }}
981
+ className={`flex min-w-0 flex-1 items-center ${rowGapLgClass} rounded-lg ${rowPadLgClass} text-left transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
982
+ >
983
+ <AlbumCoverThumb track={sample} />
984
+ <div className="flex min-w-0 flex-1 flex-col items-start">
985
+ <span className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
986
+ {album}
987
+ </span>
988
+ <span className="truncate text-xs text-zinc-500">{artist}</span>
989
+ <span className="mt-0.5 text-xs text-zinc-400">
990
+ {list.length} track{list.length === 1 ? '' : 's'}
991
+ </span>
992
+ </div>
993
+ </button>
994
+ <FavoriteStarButton
995
+ filled
996
+ onPress={() => toggleFavoriteAlbum(key)}
997
+ label="Remove album from favorites"
998
+ />
999
+ <button
1000
+ type="button"
1001
+ onClick={() => onAddMany(list)}
1002
+ disabled={list.length === 0}
1003
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
1004
+ >
1005
+ Add all
1006
+ </button>
1007
+ </li>
1008
+ )
1009
+ })}
1010
+ </ul>
1011
+ </div>
1012
+ ) : null}
1013
+ {favoritedTracks.length > 0 ? (
1014
+ <div>
1015
+ <p className="px-2 pb-1 pt-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Songs</p>
1016
+ <ul className={ulSpaceYClass}>
1017
+ {favoritedTracks.map((t) => (
1018
+ <li key={t.id}>
1019
+ <div
1020
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
1021
+ >
1022
+ <div className="min-w-0 flex-1">
1023
+ <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
1024
+ <p className="truncate text-xs text-zinc-500">
1025
+ {t.artist} · {t.album}
1026
+ {t.library?.relativePath ? ` · ${t.library.relativePath}` : ''}
1027
+ </p>
1028
+ </div>
1029
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
1030
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
1031
+ </span>
1032
+ <FavoriteStarButton
1033
+ filled
1034
+ onPress={() => toggleFavoriteTrack(t)}
1035
+ label="Remove song from favorites"
1036
+ />
1037
+ <button
1038
+ type="button"
1039
+ onClick={() => onAdd(t)}
1040
+ 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"
1041
+ >
1042
+ Add
1043
+ </button>
1044
+ </div>
1045
+ </li>
1046
+ ))}
1047
+ </ul>
1048
+ </div>
1049
+ ) : null}
1050
+ </>
1051
+ )}
1052
+ </div>
1053
+ ) : folderRootId === null ? (
1054
+ <ul className={ulSpaceYClass}>
1055
+ {rootsFiltered.map((r) => {
1056
+ const rootTracks = tracksForRoot(filtered, r.id)
1057
+ return (
1058
+ <li key={r.id} className="group/row flex items-center gap-1">
1059
+ <button
1060
+ type="button"
1061
+ onClick={() => {
1062
+ setFolderRootId(r.id)
1063
+ setFolderPath('')
1064
+ }}
1065
+ className={`flex min-w-0 flex-1 items-center justify-between rounded-lg ${rowPadLgClass} text-left text-sm transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
1066
+ >
1067
+ <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">{r.name}</span>
1068
+ <span className="shrink-0 text-xs text-zinc-500">{rootTracks.length}</span>
1069
+ </button>
1070
+ <button
1071
+ type="button"
1072
+ onClick={() => onAddMany(rootTracks)}
1073
+ disabled={rootTracks.length === 0}
1074
+ className="shrink-0 self-center 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 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
1075
+ >
1076
+ Add all
1077
+ </button>
1078
+ </li>
1079
+ )
1080
+ })}
1081
+ </ul>
1082
+ ) : (
1083
+ <div>
1084
+ <button
1085
+ type="button"
1086
+ onClick={() => {
1087
+ if (folderPath === '') {
1088
+ setFolderRootId(null)
1089
+ } else {
1090
+ const parts = folderPath.split('/').filter(Boolean)
1091
+ parts.pop()
1092
+ setFolderPath(parts.join('/'))
1093
+ }
1094
+ }}
1095
+ className="mb-2 px-2 text-xs font-medium text-amber-700 hover:underline dark:text-amber-400"
1096
+ >
1097
+ ← {folderPath === '' ? 'Libraries' : 'Up'}
1098
+ </button>
1099
+ <p className="mb-2 truncate px-2 text-xs text-zinc-500" title={folderPath || '/'}>
1100
+ {rootNameById(roots, folderRootId)}
1101
+ {folderPath ? ` / ${folderPath.replace(/\//g, ' / ')}` : ''}
1102
+ </p>
1103
+ <div className="mb-2 flex flex-wrap gap-2 px-2">
1104
+ <button
1105
+ type="button"
1106
+ onClick={() => onAddMany(folderSubtreeTracks)}
1107
+ disabled={folderSubtreeTracks.length === 0}
1108
+ className="rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900"
1109
+ >
1110
+ Add entire folder
1111
+ </button>
1112
+ <button
1113
+ type="button"
1114
+ onClick={() => onAddMany(folderChildren.files)}
1115
+ disabled={folderChildren.files.length === 0}
1116
+ className="rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900"
1117
+ >
1118
+ Add files in this level only
1119
+ </button>
1120
+ </div>
1121
+ <ul className={ulSpaceYClass}>
1122
+ {folderChildren.folders.map((name) => {
1123
+ const childPath = folderPath === '' ? name : `${folderPath}/${name}`
1124
+ const subtree = tracksUnderFolderPath(folderTracks, childPath)
1125
+ return (
1126
+ <li key={name} className="group/row flex items-center gap-1">
1127
+ <button
1128
+ type="button"
1129
+ onClick={() => setFolderPath(childPath)}
1130
+ className={`flex min-w-0 flex-1 items-center rounded-lg ${folderRowPadClass} text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
1131
+ >
1132
+ <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">{name}</span>
1133
+ <span className="ml-2 shrink-0 text-xs tabular-nums text-zinc-500">{subtree.length}</span>
1134
+ </button>
1135
+ <button
1136
+ type="button"
1137
+ onClick={() => onAddMany(subtree)}
1138
+ disabled={subtree.length === 0}
1139
+ className="shrink-0 self-center rounded-full bg-amber-500/15 px-2 py-1 text-[11px] font-medium text-amber-800 ring-1 ring-amber-500/25 transition hover:bg-amber-500/25 disabled:opacity-40 dark:text-amber-300 dark:ring-amber-500/40"
1140
+ >
1141
+ Add all
1142
+ </button>
1143
+ </li>
1144
+ )
1145
+ })}
1146
+ {folderChildren.files.map((t) => (
1147
+ <li key={t.id}>
1148
+ <div
1149
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} ${
1150
+ compact ? 'pl-3' : 'pl-4'
1151
+ } hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
1152
+ >
1153
+ <div className="min-w-0 flex-1">
1154
+ <p className="truncate text-sm font-medium">{t.title}</p>
1155
+ </div>
1156
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
1157
+ {t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
1158
+ </span>
1159
+ <FavoriteStarButton
1160
+ filled={isFavoriteSong(t.id)}
1161
+ onPress={() => toggleFavoriteTrack(t)}
1162
+ label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
1163
+ />
1164
+ <button
1165
+ type="button"
1166
+ onClick={() => onAdd(t)}
1167
+ 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 dark:text-amber-300"
1168
+ >
1169
+ Add
1170
+ </button>
1171
+ </div>
1172
+ </li>
1173
+ ))}
1174
+ </ul>
1175
+ </div>
1176
+ )}
1177
+ </div>
1178
+ </section>
1179
+ )
1180
+ }