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