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,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
|
+
}
|