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,395 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { useLibrary } from '@/components/LibraryProvider'
5
+ import MusicBrainzTrackRow from '@/components/MusicBrainzTrackRow'
6
+ import { groupTracksByAlbum } from '@/lib/musicbrainz/group-tracks-by-album'
7
+ import { groupTracksByArtist } from '@/lib/musicbrainz/group-tracks-by-artist'
8
+ import { searchMusicBrainz } from '@/lib/musicbrainz'
9
+ import collectYoutubePrefetchTargets from '@/lib/youtube/collect-youtube-prefetch-targets'
10
+ import prefetchYoutubeVideoIds from '@/lib/youtube/prefetch-youtube-video-ids'
11
+ import readStoredYoutubeApiKey from '@/lib/youtube/read-stored-youtube-api-key'
12
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
13
+ import type { Track } from '@/types/track'
14
+
15
+ type MusicBrainzBrowseMode = 'artist' | 'album' | 'song'
16
+
17
+ const BROWSE_MODES: readonly MusicBrainzBrowseMode[] = ['artist', 'album', 'song']
18
+
19
+ /**
20
+ * Search MusicBrainz recordings and browse results by artist, album, or song.
21
+ */
22
+ export default function MusicBrainzBrowser() {
23
+ const { libraryTracks, addToLibrary, addToQueue, compactLists } = useLibrary()
24
+ const [query, setQuery] = useState('')
25
+ const [results, setResults] = useState<Track[]>([])
26
+ const [loading, setLoading] = useState(false)
27
+ const [error, setError] = useState<string | null>(null)
28
+ const [mode, setMode] = useState<MusicBrainzBrowseMode>('song')
29
+ const [artistPick, setArtistPick] = useState<string | null>(null)
30
+ const [albumPick, setAlbumPick] = useState<string | null>(null)
31
+ const abortRef = useRef<AbortController | null>(null)
32
+ const debounceRef = useRef<number | null>(null)
33
+
34
+ const compact = compactLists
35
+ const ulSpaceYClass = compact ? 'space-y-0.25' : 'space-y-0.5'
36
+ const rowPadLgClass = compact ? 'px-2 py-2' : 'px-3 py-2.5'
37
+ const rowGapLgClass = compact ? 'gap-2' : 'gap-3'
38
+
39
+ const isSaved = useCallback(
40
+ (trackId: string) => libraryTracks.some((x) => x.id === trackId),
41
+ [libraryTracks],
42
+ )
43
+
44
+ const resetSearch = useCallback(() => {
45
+ if (debounceRef.current) {
46
+ window.clearTimeout(debounceRef.current)
47
+ debounceRef.current = null
48
+ }
49
+ if (abortRef.current) {
50
+ abortRef.current.abort()
51
+ abortRef.current = null
52
+ }
53
+ setResults([])
54
+ setLoading(false)
55
+ setError(null)
56
+ setArtistPick(null)
57
+ setAlbumPick(null)
58
+ }, [])
59
+
60
+ const goMode = useCallback((m: MusicBrainzBrowseMode) => {
61
+ setMode(m)
62
+ setArtistPick(null)
63
+ setAlbumPick(null)
64
+ }, [])
65
+
66
+ const onQueryChange = useCallback(
67
+ (value: string) => {
68
+ setQuery(value)
69
+ setArtistPick(null)
70
+ setAlbumPick(null)
71
+ const q = value.trim()
72
+ if (q.length < 3) {
73
+ resetSearch()
74
+ return
75
+ }
76
+ setLoading(true)
77
+ setError(null)
78
+ },
79
+ [resetSearch],
80
+ )
81
+
82
+ useEffect(() => {
83
+ const q = query.trim()
84
+ if (q.length < 3) return undefined
85
+
86
+ if (debounceRef.current) {
87
+ window.clearTimeout(debounceRef.current)
88
+ debounceRef.current = null
89
+ }
90
+ if (abortRef.current) {
91
+ abortRef.current.abort()
92
+ abortRef.current = null
93
+ }
94
+
95
+ const controller = new AbortController()
96
+ abortRef.current = controller
97
+
98
+ debounceRef.current = window.setTimeout(() => {
99
+ void searchMusicBrainz(q, controller.signal)
100
+ .then((res) => {
101
+ setResults(res)
102
+ setError(null)
103
+ })
104
+ .catch((err: unknown) => {
105
+ if (err instanceof Error && err.name === 'AbortError') return
106
+ if (typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError') return
107
+ setError(err instanceof Error ? err.message : 'MusicBrainz search failed')
108
+ setResults([])
109
+ })
110
+ .finally(() => setLoading(false))
111
+ }, 300)
112
+
113
+ return () => {
114
+ if (debounceRef.current) {
115
+ window.clearTimeout(debounceRef.current)
116
+ debounceRef.current = null
117
+ }
118
+ if (abortRef.current) {
119
+ abortRef.current.abort()
120
+ abortRef.current = null
121
+ }
122
+ }
123
+ }, [query])
124
+
125
+ useEffect(() => {
126
+ const apiKey = readStoredYoutubeApiKey()
127
+ if (!apiKey || readYoutubeDataApiBlocked() || results.length === 0) return undefined
128
+ const targets = collectYoutubePrefetchTargets(results).slice(0, 12)
129
+ if (targets.length === 0) return undefined
130
+ const controller = new AbortController()
131
+ void prefetchYoutubeVideoIds(
132
+ targets,
133
+ apiKey,
134
+ (trackId, videoId) => {
135
+ setResults((prev) =>
136
+ prev.map((t) => (t.id === trackId ? { ...t, youtubeVideoId: videoId } : t)),
137
+ )
138
+ },
139
+ { signal: controller.signal },
140
+ )
141
+ return (): void => {
142
+ controller.abort()
143
+ }
144
+ }, [results])
145
+
146
+ const onQueue = useCallback((t: Track) => addToQueue(t), [addToQueue])
147
+ const onSave = useCallback((t: Track) => addToLibrary(t), [addToLibrary])
148
+ const onAddMany = useCallback(
149
+ (list: readonly Track[]) => {
150
+ if (list.length === 0) return
151
+ addToQueue(list)
152
+ },
153
+ [addToQueue],
154
+ )
155
+
156
+ const artistMap = useMemo(() => groupTracksByArtist(results), [results])
157
+ const albumMap = useMemo(() => groupTracksByAlbum(results), [results])
158
+
159
+ const artistNames = useMemo(
160
+ () => [...artistMap.keys()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })),
161
+ [artistMap],
162
+ )
163
+
164
+ const albumKeys = useMemo(
165
+ () =>
166
+ [...albumMap.keys()].sort((a, b) => {
167
+ const [albumA, artistA] = a.split('\u0000')
168
+ const [albumB, artistB] = b.split('\u0000')
169
+ const c = albumA.localeCompare(albumB, undefined, { sensitivity: 'base' })
170
+ return c !== 0 ? c : artistA.localeCompare(artistB, undefined, { sensitivity: 'base' })
171
+ }),
172
+ [albumMap],
173
+ )
174
+
175
+ const trimmed = query.trim()
176
+ const queryTooShort = trimmed.length > 0 && trimmed.length < 3
177
+ const hasResults = results.length > 0
178
+
179
+ const renderTrackList = (tracks: readonly Track[], subtitleFor?: (t: Track) => string) => (
180
+ <ul className={ulSpaceYClass}>
181
+ {tracks.map((t) => (
182
+ <li key={t.id}>
183
+ <MusicBrainzTrackRow
184
+ track={t}
185
+ alreadySaved={isSaved(t.id)}
186
+ compact={compact}
187
+ onQueue={onQueue}
188
+ onSave={onSave}
189
+ subtitle={subtitleFor?.(t)}
190
+ />
191
+ </li>
192
+ ))}
193
+ </ul>
194
+ )
195
+
196
+ const renderResultsBody = () => {
197
+ if (mode === 'song') {
198
+ return renderTrackList(results)
199
+ }
200
+
201
+ if (mode === 'artist') {
202
+ if (artistPick === null) {
203
+ return (
204
+ <ul className={ulSpaceYClass}>
205
+ {artistNames.map((name) => {
206
+ const n = artistMap.get(name)?.length ?? 0
207
+ return (
208
+ <li key={name} className="flex items-center gap-1">
209
+ <button
210
+ type="button"
211
+ onClick={() => setArtistPick(name)}
212
+ className={`flex min-w-0 flex-1 cursor-pointer 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`}
213
+ >
214
+ <span className="truncate font-medium">{name}</span>
215
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">{n}</span>
216
+ </button>
217
+ <button
218
+ type="button"
219
+ onClick={() => onAddMany(artistMap.get(name) ?? [])}
220
+ disabled={n === 0}
221
+ className="shrink-0 cursor-pointer 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"
222
+ >
223
+ Add all
224
+ </button>
225
+ </li>
226
+ )
227
+ })}
228
+ </ul>
229
+ )
230
+ }
231
+
232
+ const tracks = artistMap.get(artistPick) ?? []
233
+ return (
234
+ <div>
235
+ <button
236
+ type="button"
237
+ onClick={() => setArtistPick(null)}
238
+ className="mb-2 cursor-pointer px-2 text-xs font-medium text-amber-700 hover:underline dark:text-amber-400"
239
+ >
240
+ ← Artists
241
+ </button>
242
+ <div className="mb-2 px-2">
243
+ <button
244
+ type="button"
245
+ onClick={() => onAddMany(tracks)}
246
+ disabled={tracks.length === 0}
247
+ className="cursor-pointer rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium dark:border-zinc-700 dark:bg-zinc-900"
248
+ >
249
+ Add all
250
+ </button>
251
+ </div>
252
+ {renderTrackList(tracks, (t) => t.album)}
253
+ </div>
254
+ )
255
+ }
256
+
257
+ if (albumPick === null) {
258
+ return (
259
+ <ul className={ulSpaceYClass}>
260
+ {albumKeys.map((key) => {
261
+ const [album, artist] = key.split('\u0000')
262
+ const n = albumMap.get(key)?.length ?? 0
263
+ return (
264
+ <li key={key} className="flex items-center gap-1">
265
+ <button
266
+ type="button"
267
+ onClick={() => setAlbumPick(key)}
268
+ className={`flex min-w-0 flex-1 cursor-pointer items-center ${rowGapLgClass} rounded-lg ${rowPadLgClass} text-left transition hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
269
+ >
270
+ <div className="flex min-w-0 flex-1 flex-col items-start">
271
+ <span className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{album}</span>
272
+ <span className="truncate text-xs text-zinc-500">{artist}</span>
273
+ <span className="mt-0.5 text-xs text-zinc-400">
274
+ {n} track{n === 1 ? '' : 's'}
275
+ </span>
276
+ </div>
277
+ </button>
278
+ <button
279
+ type="button"
280
+ onClick={() => onAddMany(albumMap.get(key) ?? [])}
281
+ disabled={n === 0}
282
+ className="shrink-0 cursor-pointer 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"
283
+ >
284
+ Add all
285
+ </button>
286
+ </li>
287
+ )
288
+ })}
289
+ </ul>
290
+ )
291
+ }
292
+
293
+ const tracks = albumMap.get(albumPick) ?? []
294
+ return (
295
+ <div>
296
+ <button
297
+ type="button"
298
+ onClick={() => setAlbumPick(null)}
299
+ className="mb-2 cursor-pointer px-2 text-xs font-medium text-amber-700 hover:underline dark:text-amber-400"
300
+ >
301
+ ← Albums
302
+ </button>
303
+ <div className="mb-2 px-2">
304
+ <button
305
+ type="button"
306
+ onClick={() => onAddMany(tracks)}
307
+ disabled={tracks.length === 0}
308
+ className="cursor-pointer rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium dark:border-zinc-700 dark:bg-zinc-900"
309
+ >
310
+ Add all
311
+ </button>
312
+ </div>
313
+ {renderTrackList(tracks, (t) => t.artist)}
314
+ </div>
315
+ )
316
+ }
317
+
318
+ return (
319
+ <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">
320
+ <div className="shrink-0 space-y-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
321
+ <div>
322
+ <h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">MusicBrainz</h2>
323
+ <p className="mt-1 text-xs text-zinc-400">
324
+ Discover recordings via MusicBrainz; streams via YouTube in the background (Settings → YouTube).
325
+ </p>
326
+ </div>
327
+ <input
328
+ type="search"
329
+ value={query}
330
+ onChange={(e) => onQueryChange(e.target.value)}
331
+ placeholder="Songs, artists, or releases…"
332
+ 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"
333
+ aria-label="Search MusicBrainz"
334
+ />
335
+ <div className="flex flex-wrap gap-1">
336
+ {BROWSE_MODES.map((m) => (
337
+ <button
338
+ key={m}
339
+ type="button"
340
+ onClick={() => goMode(m)}
341
+ className={[
342
+ 'cursor-pointer rounded-full px-3 py-1 text-xs font-medium capitalize transition',
343
+ mode === m
344
+ ? 'bg-amber-500 text-zinc-950'
345
+ : 'bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700',
346
+ ].join(' ')}
347
+ >
348
+ {m}
349
+ </button>
350
+ ))}
351
+ </div>
352
+ </div>
353
+
354
+ <div className="min-h-0 flex-1 overflow-auto px-2 py-2">
355
+ {error ? (
356
+ <p className="px-2 py-4 text-sm text-red-600 dark:text-red-400" role="alert">
357
+ {error}
358
+ </p>
359
+ ) : loading ? (
360
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">Searching MusicBrainz…</p>
361
+ ) : queryTooShort ? (
362
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">Type at least 3 characters to search.</p>
363
+ ) : trimmed.length === 0 ? (
364
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">
365
+ Search MusicBrainz to find recordings to queue or save to your library.
366
+ </p>
367
+ ) : !hasResults ? (
368
+ <p className="px-2 py-6 text-center text-sm text-zinc-500">No MusicBrainz results for that query.</p>
369
+ ) : (
370
+ <div className="space-y-1">
371
+ <div className="flex flex-wrap items-center justify-between gap-2 px-2 pb-2">
372
+ <p className="text-xs text-zinc-500">
373
+ {results.length} recording{results.length === 1 ? '' : 's'}
374
+ {mode === 'artist' && artistNames.length > 0
375
+ ? ` · ${artistNames.length} artist${artistNames.length === 1 ? '' : 's'}`
376
+ : ''}
377
+ {mode === 'album' && albumKeys.length > 0
378
+ ? ` · ${albumKeys.length} album${albumKeys.length === 1 ? '' : 's'}`
379
+ : ''}
380
+ </p>
381
+ <button
382
+ type="button"
383
+ onClick={() => onAddMany(results)}
384
+ className="cursor-pointer rounded-full border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
385
+ >
386
+ Add all to queue
387
+ </button>
388
+ </div>
389
+ {renderResultsBody()}
390
+ </div>
391
+ )}
392
+ </div>
393
+ </section>
394
+ )
395
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { formatDuration } from '@/lib/format-duration'
4
+ import type { Track } from '@/types/track'
5
+
6
+ type MusicBrainzTrackRowProps = {
7
+ track: Track
8
+ alreadySaved: boolean
9
+ compact: boolean
10
+ onQueue: (track: Track) => void
11
+ onSave: (track: Track) => void
12
+ subtitle?: string
13
+ }
14
+
15
+ /**
16
+ * Single MusicBrainz search result row with queue and save actions.
17
+ */
18
+ export default function MusicBrainzTrackRow(props: MusicBrainzTrackRowProps) {
19
+ const rowPadSmClass = props.compact ? 'px-1.5 py-1.5' : 'px-2 py-2'
20
+ const rowGapSmClass = props.compact ? 'gap-1.5' : 'gap-2'
21
+ const subtitle =
22
+ props.subtitle ?? `${props.track.artist} · ${props.track.album}`
23
+
24
+ return (
25
+ <div
26
+ className={`flex items-center ${rowGapSmClass} rounded-lg ${rowPadSmClass} hover:bg-zinc-100 dark:hover:bg-zinc-800/80`}
27
+ >
28
+ <div className="min-w-0 flex-1">
29
+ <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{props.track.title}</p>
30
+ <p className="truncate text-xs text-zinc-500">{subtitle}</p>
31
+ </div>
32
+ <span className="shrink-0 text-xs tabular-nums text-zinc-500">
33
+ {props.track.durationSec > 0 ? formatDuration(props.track.durationSec) : '—'}
34
+ </span>
35
+ <button
36
+ type="button"
37
+ onClick={() => props.onSave(props.track)}
38
+ disabled={props.alreadySaved}
39
+ className="shrink-0 cursor-pointer rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50 disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
40
+ >
41
+ {props.alreadySaved ? 'Saved' : 'Save'}
42
+ </button>
43
+ <button
44
+ type="button"
45
+ onClick={() => props.onQueue(props.track)}
46
+ className="shrink-0 cursor-pointer 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"
47
+ >
48
+ Add
49
+ </button>
50
+ </div>
51
+ )
52
+ }