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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +30 -0
- package/CLAUDE.md +1 -0
- package/LICENSE.md +21 -0
- package/README.md +36 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +67 -0
- package/app/layout.tsx +49 -0
- package/app/musicbrainz/page.tsx +6 -0
- package/app/page.tsx +12 -0
- package/app/settings/display/page.tsx +11 -0
- package/app/settings/layout.tsx +19 -0
- package/app/settings/library/page.tsx +11 -0
- package/app/settings/page.tsx +5 -0
- package/app/settings/playback/page.tsx +11 -0
- package/app/settings/youtube/page.tsx +11 -0
- package/bin/stt-ui.js +25 -0
- package/components/AlbumCoverThumb.tsx +82 -0
- package/components/BrowsePanel.tsx +64 -0
- package/components/DisplaySettingsPanel.tsx +30 -0
- package/components/FavoriteStarButton.tsx +41 -0
- package/components/LibraryBrowser.tsx +1180 -0
- package/components/LibraryProvider.tsx +1023 -0
- package/components/LibraryScanNotification.tsx +62 -0
- package/components/LibraryScanOptionsSection.tsx +123 -0
- package/components/LibrarySettingsPanel.tsx +116 -0
- package/components/LibraryStatistics.tsx +54 -0
- package/components/MusicBrainzBrowser.tsx +395 -0
- package/components/MusicBrainzTrackRow.tsx +52 -0
- package/components/MusicPlayer.tsx +1531 -0
- package/components/PanelResizeHandle.tsx +65 -0
- package/components/PlaybackSettingsPanel.tsx +32 -0
- package/components/QueueLoadingSpinner.tsx +19 -0
- package/components/SettingsNav.tsx +37 -0
- package/components/SettingsOverview.tsx +34 -0
- package/components/SettingsShell.tsx +47 -0
- package/components/SettingsSwitchRow.tsx +38 -0
- package/components/ThemeProvider.tsx +75 -0
- package/components/ThemeToggle.tsx +38 -0
- package/components/YouTubeSettingsPanel.tsx +79 -0
- package/components/YouTubeStreamNotification.tsx +30 -0
- package/components/format-library-root-added.ts +13 -0
- package/components/settings-nav-items.ts +40 -0
- package/eslint.config.mjs +18 -0
- package/lib/format-duration.ts +9 -0
- package/lib/format-total-library-duration.ts +14 -0
- package/lib/library/audio-filename.ts +31 -0
- package/lib/library/collect-tracks-for-meta.ts +91 -0
- package/lib/library/compute-library-stats.ts +37 -0
- package/lib/library/constants.ts +27 -0
- package/lib/library/cover-bytes-cache.ts +59 -0
- package/lib/library/default-library-scan-preferences.ts +13 -0
- package/lib/library/extract-cover-bytes-from-audio-file.ts +41 -0
- package/lib/library/extract-cover-object-url-from-audio-file.ts +31 -0
- package/lib/library/favorite-keys.ts +14 -0
- package/lib/library/format-fs-access-error.ts +29 -0
- package/lib/library/idb.ts +270 -0
- package/lib/library/read-audio-metadata.ts +34 -0
- package/lib/library/read-stored-library-scan-preferences.ts +43 -0
- package/lib/library/resolve-track-file.ts +26 -0
- package/lib/library/scan-preferences-to-tree-options.ts +15 -0
- package/lib/library/scan-progress-label.ts +18 -0
- package/lib/library/scan-progress-percent.ts +19 -0
- package/lib/library/scan-progress-tick.ts +9 -0
- package/lib/library/scan-tree.ts +191 -0
- package/lib/library/write-stored-library-scan-preferences.ts +19 -0
- package/lib/mock-playlist.ts +47 -0
- package/lib/musicbrainz/build-musicbrainz-lucene-queries.ts +46 -0
- package/lib/musicbrainz/escape-lucene-term.ts +6 -0
- package/lib/musicbrainz/fetch-musicbrainz-json.ts +55 -0
- package/lib/musicbrainz/fetch-release-tracks.ts +53 -0
- package/lib/musicbrainz/group-tracks-by-album.ts +26 -0
- package/lib/musicbrainz/group-tracks-by-artist.ts +23 -0
- package/lib/musicbrainz/merge-tracks-by-id.ts +16 -0
- package/lib/musicbrainz/musicbrainz-recording-to-track.ts +42 -0
- package/lib/musicbrainz/pick-preferred-release.ts +32 -0
- package/lib/musicbrainz/pick-release-group-release-id.ts +12 -0
- package/lib/musicbrainz/release-group-artist-name.ts +13 -0
- package/lib/musicbrainz/search-musicbrainz-recordings.ts +33 -0
- package/lib/musicbrainz/search-musicbrainz-release-groups.ts +24 -0
- package/lib/musicbrainz/search-musicbrainz.ts +65 -0
- package/lib/musicbrainz/types.ts +43 -0
- package/lib/musicbrainz.ts +3 -0
- package/lib/playback/build-queue-from-snapshot.ts +49 -0
- package/lib/playback/parse-persisted-track.ts +45 -0
- package/lib/playback/read-stored-playback-snapshot.ts +45 -0
- package/lib/playback/write-stored-playback-snapshot.ts +19 -0
- package/lib/theme-constants.ts +4 -0
- package/lib/theme-init-script.ts +9 -0
- package/lib/youtube/clear-youtube-data-api-blocked.ts +8 -0
- package/lib/youtube/collect-youtube-prefetch-targets.ts +20 -0
- package/lib/youtube/is-youtube-quota-error-message.ts +7 -0
- package/lib/youtube/mark-youtube-data-api-blocked.ts +8 -0
- package/lib/youtube/prefetch-youtube-video-ids.ts +55 -0
- package/lib/youtube/read-stored-youtube-api-key.ts +16 -0
- package/lib/youtube/read-youtube-data-api-blocked.ts +12 -0
- package/lib/youtube/search-youtube-video-id.ts +60 -0
- package/lib/youtube/should-use-youtube-search-playback.ts +19 -0
- package/lib/youtube/write-stored-youtube-api-key.ts +18 -0
- package/next.config.ts +7 -0
- package/package.json +94 -0
- package/pnpm-workspace.yaml +6 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/file-system-access.d.ts +22 -0
- package/types/library-root-meta.ts +5 -0
- package/types/library-scan-preferences.ts +9 -0
- package/types/library-scan-progress.ts +8 -0
- package/types/persisted-playback-snapshot.ts +11 -0
- package/types/queue.ts +7 -0
- package/types/scan-tree-options.ts +6 -0
- 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
|
+
}
|