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,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { LibraryScanProgress } from '@/types/library-scan-progress'
|
|
4
|
+
|
|
5
|
+
type LibraryScanNotificationProps = {
|
|
6
|
+
progress: LibraryScanProgress | null
|
|
7
|
+
onDismiss: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fixed toast with a determinate progress bar while the library is scanned.
|
|
12
|
+
*/
|
|
13
|
+
export default function LibraryScanNotification(props: LibraryScanNotificationProps) {
|
|
14
|
+
const { progress, onDismiss } = props
|
|
15
|
+
if (!progress) return null
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
role="status"
|
|
20
|
+
aria-live="polite"
|
|
21
|
+
aria-busy={progress.percent < 100}
|
|
22
|
+
className="pointer-events-auto fixed bottom-4 right-4 z-50 w-[min(100vw-2rem,22rem)] rounded-xl border border-zinc-200/90 bg-white/95 p-4 pr-10 shadow-lg shadow-zinc-900/10 backdrop-blur-sm dark:border-zinc-700/80 dark:bg-zinc-900/95 dark:shadow-black/40"
|
|
23
|
+
>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onDismiss}
|
|
27
|
+
className="absolute right-2 top-2 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
|
28
|
+
aria-label="Dismiss notification"
|
|
29
|
+
>
|
|
30
|
+
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
31
|
+
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
|
32
|
+
</svg>
|
|
33
|
+
</button>
|
|
34
|
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
35
|
+
{progress.percent >= 100 ? 'Library scan complete' : 'Scanning library'}
|
|
36
|
+
</p>
|
|
37
|
+
<p className="mt-1 truncate text-xs text-zinc-600 dark:text-zinc-400">{progress.label}</p>
|
|
38
|
+
<div
|
|
39
|
+
role="progressbar"
|
|
40
|
+
aria-valuemin={0}
|
|
41
|
+
aria-valuemax={100}
|
|
42
|
+
aria-valuenow={progress.percent}
|
|
43
|
+
aria-label="Library scan progress"
|
|
44
|
+
className="mt-3 h-1.5 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800"
|
|
45
|
+
>
|
|
46
|
+
<div
|
|
47
|
+
className="h-full rounded-full bg-amber-500 transition-[width] duration-200 ease-out dark:bg-amber-400"
|
|
48
|
+
style={{ width: `${progress.percent}%` }}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
<p className="mt-2 text-right text-[11px] tabular-nums text-zinc-500 dark:text-zinc-500">
|
|
52
|
+
{progress.percent}%
|
|
53
|
+
{progress.filesTotal > 0 ? (
|
|
54
|
+
<span className="text-zinc-400">
|
|
55
|
+
{' '}
|
|
56
|
+
· {progress.filesDone}/{progress.filesTotal} files
|
|
57
|
+
</span>
|
|
58
|
+
) : null}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useLibrary } from '@/components/LibraryProvider'
|
|
4
|
+
import { LIBRARY_AUDIO_EXTENSIONS } from '@/lib/library/constants'
|
|
5
|
+
import defaultLibraryScanPreferences from '@/lib/library/default-library-scan-preferences'
|
|
6
|
+
import SettingsSwitchRow from '@/components/SettingsSwitchRow'
|
|
7
|
+
import type { LibraryScanPreferences } from '@/types/library-scan-preferences'
|
|
8
|
+
|
|
9
|
+
const DEPTH_OPTIONS: readonly { value: number; label: string }[] = [
|
|
10
|
+
{ value: 0, label: 'Unlimited' },
|
|
11
|
+
{ value: 1, label: '1 level' },
|
|
12
|
+
{ value: 2, label: '2 levels' },
|
|
13
|
+
{ value: 3, label: '3 levels' },
|
|
14
|
+
{ value: 5, label: '5 levels' },
|
|
15
|
+
{ value: 10, label: '10 levels' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan depth, symlink follow, and per-extension include toggles.
|
|
20
|
+
*/
|
|
21
|
+
export default function LibraryScanOptionsSection() {
|
|
22
|
+
const { scanPreferences, setScanPreferences, isScanning } = useLibrary()
|
|
23
|
+
|
|
24
|
+
const patch = (partial: Partial<LibraryScanPreferences>): void => {
|
|
25
|
+
setScanPreferences({ ...scanPreferences, ...partial })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const toggleExtension = (ext: string): void => {
|
|
29
|
+
const enabled = new Set(scanPreferences.enabledExtensions)
|
|
30
|
+
if (enabled.has(ext)) {
|
|
31
|
+
if (enabled.size <= 1) return
|
|
32
|
+
enabled.delete(ext)
|
|
33
|
+
} else {
|
|
34
|
+
enabled.add(ext)
|
|
35
|
+
}
|
|
36
|
+
patch({ enabledExtensions: [...enabled].sort() })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const resetExtensions = (): void => {
|
|
40
|
+
patch({ enabledExtensions: defaultLibraryScanPreferences().enabledExtensions })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
45
|
+
<h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan options</h3>
|
|
46
|
+
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
47
|
+
Applied on the next rescan. Symlink handling depends on your browser; when enabled, Muzical skips
|
|
48
|
+
directory cycles it can detect.
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<div className="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
|
52
|
+
<label className="block min-w-0 flex-1">
|
|
53
|
+
<span className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan depth</span>
|
|
54
|
+
<select
|
|
55
|
+
value={scanPreferences.maxScanDepth}
|
|
56
|
+
disabled={isScanning}
|
|
57
|
+
onChange={(e) => patch({ maxScanDepth: Number.parseInt(e.target.value, 10) })}
|
|
58
|
+
className="mt-2 block w-full max-w-xs rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
|
|
59
|
+
aria-label="Maximum subdirectory scan depth"
|
|
60
|
+
>
|
|
61
|
+
{DEPTH_OPTIONS.map((opt) => (
|
|
62
|
+
<option key={opt.value} value={opt.value}>
|
|
63
|
+
{opt.label}
|
|
64
|
+
</option>
|
|
65
|
+
))}
|
|
66
|
+
</select>
|
|
67
|
+
</label>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
|
71
|
+
<SettingsSwitchRow
|
|
72
|
+
title="Follow symlinks"
|
|
73
|
+
description="Follow directory links when the browser exposes them as folders. May increase scan time."
|
|
74
|
+
checked={scanPreferences.followSymlinks}
|
|
75
|
+
onChange={(followSymlinks) => patch({ followSymlinks })}
|
|
76
|
+
ariaLabel="Follow directory symlinks when scanning"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
|
81
|
+
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
|
82
|
+
<div>
|
|
83
|
+
<h4 className="text-xs font-medium uppercase tracking-wider text-zinc-500">File types</h4>
|
|
84
|
+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
85
|
+
Only checked extensions are included in library scans.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
disabled={isScanning}
|
|
91
|
+
onClick={resetExtensions}
|
|
92
|
+
className="shrink-0 text-xs font-medium text-amber-700 hover:text-amber-600 disabled:opacity-50 dark:text-amber-400 dark:hover:text-amber-300"
|
|
93
|
+
>
|
|
94
|
+
Enable all
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<ul
|
|
98
|
+
className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4"
|
|
99
|
+
role="group"
|
|
100
|
+
aria-label="Audio file extensions to scan"
|
|
101
|
+
>
|
|
102
|
+
{LIBRARY_AUDIO_EXTENSIONS.map((ext) => {
|
|
103
|
+
const on = scanPreferences.enabledExtensions.includes(ext)
|
|
104
|
+
return (
|
|
105
|
+
<li key={ext}>
|
|
106
|
+
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 text-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800/60">
|
|
107
|
+
<input
|
|
108
|
+
type="checkbox"
|
|
109
|
+
checked={on}
|
|
110
|
+
disabled={isScanning}
|
|
111
|
+
onChange={() => toggleExtension(ext)}
|
|
112
|
+
className="h-4 w-4 rounded border-zinc-300 text-amber-500 focus:ring-2 focus:ring-amber-500/20 dark:border-zinc-700 dark:bg-zinc-900"
|
|
113
|
+
/>
|
|
114
|
+
<span className="font-mono text-xs text-zinc-800 dark:text-zinc-200">{ext}</span>
|
|
115
|
+
</label>
|
|
116
|
+
</li>
|
|
117
|
+
)
|
|
118
|
+
})}
|
|
119
|
+
</ul>
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useLibrary } from '@/components/LibraryProvider'
|
|
4
|
+
import formatLibraryRootAdded from '@/components/format-library-root-added'
|
|
5
|
+
import LibraryScanOptionsSection from '@/components/LibraryScanOptionsSection'
|
|
6
|
+
import LibraryStatistics from '@/components/LibraryStatistics'
|
|
7
|
+
import SettingsSwitchRow from '@/components/SettingsSwitchRow'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Library settings: folders, rescan controls, and startup scan preference.
|
|
11
|
+
*/
|
|
12
|
+
export default function LibrarySettingsPanel() {
|
|
13
|
+
const {
|
|
14
|
+
roots,
|
|
15
|
+
libraryTracks,
|
|
16
|
+
isScanning,
|
|
17
|
+
scanError,
|
|
18
|
+
hasDirectoryPicker,
|
|
19
|
+
addLibraryFolder,
|
|
20
|
+
removeLibraryFolder,
|
|
21
|
+
rescanAll,
|
|
22
|
+
autoRescanOnStartup,
|
|
23
|
+
setAutoRescanOnStartup,
|
|
24
|
+
} = useLibrary()
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex flex-col gap-8">
|
|
28
|
+
<div>
|
|
29
|
+
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Library</h2>
|
|
30
|
+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
31
|
+
Folders are read in the browser via the File System Access API.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<LibraryStatistics roots={roots} libraryTracks={libraryTracks} />
|
|
36
|
+
|
|
37
|
+
{scanError ? (
|
|
38
|
+
<p
|
|
39
|
+
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-200"
|
|
40
|
+
role="alert"
|
|
41
|
+
>
|
|
42
|
+
{scanError}
|
|
43
|
+
</p>
|
|
44
|
+
) : null}
|
|
45
|
+
|
|
46
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
47
|
+
<h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan directories</h3>
|
|
48
|
+
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
49
|
+
Pick one or more folders that contain your audio files. Subfolders are scanned. Handles are stored in
|
|
50
|
+
IndexedDB on this device so you do not have to pick them again on return visits.
|
|
51
|
+
</p>
|
|
52
|
+
{!hasDirectoryPicker ? (
|
|
53
|
+
<p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
|
|
54
|
+
Folder selection is not available in this environment. Use Chrome or Edge on desktop.
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={() => addLibraryFolder()}
|
|
61
|
+
disabled={!hasDirectoryPicker || isScanning}
|
|
62
|
+
className="rounded-full bg-amber-500 px-4 py-2 text-sm font-medium text-zinc-950 shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
63
|
+
>
|
|
64
|
+
Add folder…
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={() => void rescanAll()}
|
|
69
|
+
disabled={roots.length === 0 || isScanning}
|
|
70
|
+
className="rounded-full border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
|
71
|
+
>
|
|
72
|
+
{isScanning ? 'Scanning…' : 'Rescan all'}
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<LibraryScanOptionsSection />
|
|
78
|
+
|
|
79
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
80
|
+
<h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Configured folders</h3>
|
|
81
|
+
{roots.length === 0 ? (
|
|
82
|
+
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">No folders yet. Add a library folder above.</p>
|
|
83
|
+
) : (
|
|
84
|
+
<ul className="mt-4 divide-y divide-zinc-200 dark:divide-zinc-800" role="list">
|
|
85
|
+
{roots.map((r) => (
|
|
86
|
+
<li key={r.id} className="flex flex-wrap items-center justify-between gap-3 py-4 first:pt-0">
|
|
87
|
+
<div className="min-w-0">
|
|
88
|
+
<p className="truncate font-medium text-zinc-900 dark:text-zinc-100">{r.name}</p>
|
|
89
|
+
<p className="truncate text-xs text-zinc-500">Added {formatLibraryRootAdded(r.addedAt)}</p>
|
|
90
|
+
</div>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => void removeLibraryFolder(r.id)}
|
|
94
|
+
disabled={isScanning}
|
|
95
|
+
className="shrink-0 rounded-full border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
96
|
+
>
|
|
97
|
+
Remove
|
|
98
|
+
</button>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
)}
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
106
|
+
<SettingsSwitchRow
|
|
107
|
+
title="Rescan on startup"
|
|
108
|
+
description="When enabled, Muzical rescans all configured folders each time you open the app (if the browser already has folder access). When disabled, the last saved catalog loads immediately."
|
|
109
|
+
checked={autoRescanOnStartup}
|
|
110
|
+
onChange={setAutoRescanOnStartup}
|
|
111
|
+
ariaLabel="Rescan library on startup"
|
|
112
|
+
/>
|
|
113
|
+
</section>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import computeLibraryStats from '@/lib/library/compute-library-stats'
|
|
5
|
+
import formatTotalLibraryDuration from '@/lib/format-total-library-duration'
|
|
6
|
+
import type { LibraryRootMeta } from '@/types/library-root-meta'
|
|
7
|
+
import type { Track } from '@/types/track'
|
|
8
|
+
|
|
9
|
+
type LibraryStatisticsProps = {
|
|
10
|
+
roots: readonly LibraryRootMeta[]
|
|
11
|
+
libraryTracks: readonly Track[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type StatCard = {
|
|
15
|
+
label: string
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Summary counts for the library settings page header area.
|
|
21
|
+
*/
|
|
22
|
+
export default function LibraryStatistics(props: LibraryStatisticsProps) {
|
|
23
|
+
const stats = useMemo(
|
|
24
|
+
() => computeLibraryStats(props.libraryTracks, props.roots.length),
|
|
25
|
+
[props.libraryTracks, props.roots.length],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const cards: StatCard[] = [
|
|
29
|
+
{ label: 'Tracks', value: stats.trackCount.toLocaleString() },
|
|
30
|
+
{ label: 'Folders', value: stats.folderCount.toLocaleString() },
|
|
31
|
+
{ label: 'Artists', value: stats.artistCount.toLocaleString() },
|
|
32
|
+
{ label: 'Albums', value: stats.albumCount.toLocaleString() },
|
|
33
|
+
{ label: 'Playtime', value: formatTotalLibraryDuration(stats.totalDurationSec) },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section
|
|
38
|
+
aria-label="Library statistics"
|
|
39
|
+
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
|
40
|
+
>
|
|
41
|
+
{cards.map((card) => (
|
|
42
|
+
<div
|
|
43
|
+
key={card.label}
|
|
44
|
+
className="rounded-2xl border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40"
|
|
45
|
+
>
|
|
46
|
+
<p className="text-xs font-medium uppercase tracking-wider text-zinc-500">{card.label}</p>
|
|
47
|
+
<p className="mt-1 text-lg font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-100">
|
|
48
|
+
{card.value}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</section>
|
|
53
|
+
)
|
|
54
|
+
}
|