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,65 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
export type PanelResizeHandleProps = {
|
|
6
|
+
'aria-label': string
|
|
7
|
+
onSessionStart: () => void
|
|
8
|
+
onSessionMove: (deltaXFromStart: number) => void
|
|
9
|
+
onSessionEnd: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vertical drag handle for resizable flex columns (hide on small screens via parent).
|
|
14
|
+
*/
|
|
15
|
+
export default function PanelResizeHandle(props: PanelResizeHandleProps) {
|
|
16
|
+
const { 'aria-label': ariaLabel, onSessionStart, onSessionMove, onSessionEnd } = props
|
|
17
|
+
|
|
18
|
+
const onPointerDown = useCallback(
|
|
19
|
+
(e: { currentTarget: HTMLDivElement; clientX: number; pointerId: number; preventDefault: () => void }) => {
|
|
20
|
+
e.preventDefault()
|
|
21
|
+
const el = e.currentTarget
|
|
22
|
+
const startX = e.clientX
|
|
23
|
+
onSessionStart()
|
|
24
|
+
el.setPointerCapture(e.pointerId)
|
|
25
|
+
const move = (ev: PointerEvent): void => {
|
|
26
|
+
onSessionMove(ev.clientX - startX)
|
|
27
|
+
}
|
|
28
|
+
const up = (ev: PointerEvent): void => {
|
|
29
|
+
try {
|
|
30
|
+
el.releasePointerCapture(ev.pointerId)
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
window.removeEventListener('pointermove', move)
|
|
35
|
+
window.removeEventListener('pointerup', up)
|
|
36
|
+
window.removeEventListener('pointercancel', up)
|
|
37
|
+
onSessionEnd()
|
|
38
|
+
}
|
|
39
|
+
window.addEventListener('pointermove', move)
|
|
40
|
+
window.addEventListener('pointerup', up)
|
|
41
|
+
window.addEventListener('pointercancel', up)
|
|
42
|
+
},
|
|
43
|
+
[onSessionEnd, onSessionMove, onSessionStart],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
role="separator"
|
|
49
|
+
aria-orientation="vertical"
|
|
50
|
+
aria-label={ariaLabel}
|
|
51
|
+
tabIndex={0}
|
|
52
|
+
onPointerDown={onPointerDown}
|
|
53
|
+
className="group relative z-10 flex w-0 shrink-0 cursor-col-resize justify-center outline-none max-lg:hidden"
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
className="absolute top-0 bottom-0 left-1/2 w-3 -translate-x-1/2 bg-transparent transition-colors group-hover:bg-amber-500/15 group-active:bg-amber-500/25"
|
|
57
|
+
aria-hidden
|
|
58
|
+
/>
|
|
59
|
+
<div
|
|
60
|
+
className="pointer-events-none absolute top-0 bottom-0 left-1/2 w-px -translate-x-1/2 bg-zinc-200 dark:bg-zinc-700"
|
|
61
|
+
aria-hidden
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useLibrary } from '@/components/LibraryProvider'
|
|
4
|
+
import SettingsSwitchRow from '@/components/SettingsSwitchRow'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Playback persistence and related options.
|
|
8
|
+
*/
|
|
9
|
+
export default function PlaybackSettingsPanel() {
|
|
10
|
+
const { rememberLastQueue, setRememberLastQueue } = useLibrary()
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col gap-8">
|
|
14
|
+
<div>
|
|
15
|
+
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Playback</h2>
|
|
16
|
+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
17
|
+
Control how Muzical resumes listening between sessions on this device.
|
|
18
|
+
</p>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
22
|
+
<SettingsSwitchRow
|
|
23
|
+
title="Remember last track and queue"
|
|
24
|
+
description="When enabled, your queue order, current track, and playhead position are restored the next time you open Muzical (tracks must still exist in the library)."
|
|
25
|
+
checked={rememberLastQueue}
|
|
26
|
+
onChange={setRememberLastQueue}
|
|
27
|
+
ariaLabel="Remember last track and queue"
|
|
28
|
+
/>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centered spinner shown while the playback queue is restored on startup.
|
|
3
|
+
*/
|
|
4
|
+
export default function QueueLoadingSpinner() {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
className="flex h-full min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-12"
|
|
8
|
+
role="status"
|
|
9
|
+
aria-live="polite"
|
|
10
|
+
aria-label="Loading queue"
|
|
11
|
+
>
|
|
12
|
+
<span
|
|
13
|
+
className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-300 border-t-amber-500 dark:border-zinc-600 dark:border-t-amber-400"
|
|
14
|
+
aria-hidden
|
|
15
|
+
/>
|
|
16
|
+
<p className="text-sm text-zinc-500 dark:text-zinc-400">Loading queue…</p>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { usePathname } from 'next/navigation'
|
|
5
|
+
import { SETTINGS_NAV_ITEMS } from '@/components/settings-nav-items'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sidebar navigation between settings subviews.
|
|
9
|
+
*/
|
|
10
|
+
export default function SettingsNav() {
|
|
11
|
+
const pathname = usePathname()
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<nav aria-label="Settings sections" className="flex flex-col gap-1">
|
|
15
|
+
{SETTINGS_NAV_ITEMS.map((item) => {
|
|
16
|
+
const active =
|
|
17
|
+
item.href === '/settings'
|
|
18
|
+
? pathname === '/settings'
|
|
19
|
+
: pathname === item.href || pathname.startsWith(`${item.href}/`)
|
|
20
|
+
return (
|
|
21
|
+
<Link
|
|
22
|
+
key={item.href}
|
|
23
|
+
href={item.href}
|
|
24
|
+
aria-current={active ? 'page' : undefined}
|
|
25
|
+
className={`rounded-xl px-3 py-2.5 text-sm font-medium transition ${
|
|
26
|
+
active
|
|
27
|
+
? 'bg-amber-500/15 text-amber-900 dark:bg-amber-500/20 dark:text-amber-200'
|
|
28
|
+
: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100'
|
|
29
|
+
}`}
|
|
30
|
+
>
|
|
31
|
+
{item.label}
|
|
32
|
+
</Link>
|
|
33
|
+
)
|
|
34
|
+
})}
|
|
35
|
+
</nav>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { SETTINGS_SECTION_ITEMS } from '@/components/settings-nav-items'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Settings landing page with links into each subsection.
|
|
8
|
+
*/
|
|
9
|
+
export default function SettingsOverview() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col gap-8">
|
|
12
|
+
<div>
|
|
13
|
+
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Overview</h2>
|
|
14
|
+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
15
|
+
Choose a section to configure your library or how Muzical looks.
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<ul className="flex flex-col gap-3" role="list">
|
|
20
|
+
{SETTINGS_SECTION_ITEMS.map((item) => (
|
|
21
|
+
<li key={item.href}>
|
|
22
|
+
<Link
|
|
23
|
+
href={item.href}
|
|
24
|
+
className="block rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm transition hover:border-amber-500/40 hover:bg-amber-50/30 dark:border-zinc-800 dark:bg-zinc-900/40 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/5"
|
|
25
|
+
>
|
|
26
|
+
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{item.label}</span>
|
|
27
|
+
<p className="mt-1 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{item.description}</p>
|
|
28
|
+
</Link>
|
|
29
|
+
</li>
|
|
30
|
+
))}
|
|
31
|
+
</ul>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import SettingsNav from '@/components/SettingsNav'
|
|
6
|
+
import ThemeToggle from '@/components/ThemeToggle'
|
|
7
|
+
|
|
8
|
+
type SettingsShellProps = {
|
|
9
|
+
children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared settings chrome: header, back link, and section navigation.
|
|
14
|
+
*/
|
|
15
|
+
export default function SettingsShell(props: SettingsShellProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
|
18
|
+
<header className="flex shrink-0 items-center justify-between gap-4 border-b border-zinc-200 bg-white/90 px-6 py-4 backdrop-blur-sm dark:border-zinc-800/80 dark:bg-zinc-950/90">
|
|
19
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
20
|
+
<Link
|
|
21
|
+
href="/"
|
|
22
|
+
className="shrink-0 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
23
|
+
>
|
|
24
|
+
← Player
|
|
25
|
+
</Link>
|
|
26
|
+
<div className="min-w-0">
|
|
27
|
+
<h1 className="truncate text-sm font-semibold tracking-tight">Settings</h1>
|
|
28
|
+
<p className="truncate text-xs text-zinc-500">Configure Muzical on this device.</p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<ThemeToggle />
|
|
32
|
+
</header>
|
|
33
|
+
|
|
34
|
+
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
|
|
35
|
+
<aside className="hidden w-44 shrink-0 border-r border-zinc-200 px-4 py-8 dark:border-zinc-800/80 sm:block">
|
|
36
|
+
<SettingsNav />
|
|
37
|
+
</aside>
|
|
38
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overscroll-contain">
|
|
39
|
+
<div className="mb-6 px-6 pt-6 sm:hidden">
|
|
40
|
+
<SettingsNav />
|
|
41
|
+
</div>
|
|
42
|
+
<div className="px-6 pb-8 pt-2 sm:pt-8">{props.children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type SettingsSwitchRowProps = {
|
|
2
|
+
title: string
|
|
3
|
+
description: string
|
|
4
|
+
checked: boolean
|
|
5
|
+
onChange: (checked: boolean) => void
|
|
6
|
+
ariaLabel: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Labeled switch row for boolean settings.
|
|
11
|
+
*/
|
|
12
|
+
export default function SettingsSwitchRow(props: SettingsSwitchRowProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
15
|
+
<div className="min-w-0">
|
|
16
|
+
<h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">{props.title}</h2>
|
|
17
|
+
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{props.description}</p>
|
|
18
|
+
</div>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
role="switch"
|
|
22
|
+
aria-checked={props.checked}
|
|
23
|
+
aria-label={props.ariaLabel}
|
|
24
|
+
onClick={() => props.onChange(!props.checked)}
|
|
25
|
+
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent p-0.5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
|
|
26
|
+
props.checked ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-600'
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
<span
|
|
30
|
+
aria-hidden
|
|
31
|
+
className={`block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
|
32
|
+
props.checked ? 'translate-x-5' : 'translate-x-0'
|
|
33
|
+
}`}
|
|
34
|
+
/>
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import type { ColorScheme } from '@/lib/theme-constants'
|
|
13
|
+
import { THEME_STORAGE_KEY } from '@/lib/theme-constants'
|
|
14
|
+
|
|
15
|
+
type ThemeContextValue = {
|
|
16
|
+
scheme: ColorScheme
|
|
17
|
+
setScheme: (next: ColorScheme) => void
|
|
18
|
+
toggleScheme: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
22
|
+
|
|
23
|
+
function applySchemeToDocument(scheme: ColorScheme): void {
|
|
24
|
+
document.documentElement.classList.toggle('dark', scheme === 'dark')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Persists light/dark choice and keeps `document.documentElement` in sync with Tailwind `dark:` variants.
|
|
29
|
+
*/
|
|
30
|
+
export function ThemeProvider(props: { children: ReactNode }) {
|
|
31
|
+
const [scheme, setSchemeState] = useState<ColorScheme>('light')
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const id = requestAnimationFrame(() => {
|
|
35
|
+
const raw = localStorage.getItem(THEME_STORAGE_KEY)
|
|
36
|
+
const next: ColorScheme = raw === 'dark' ? 'dark' : 'light'
|
|
37
|
+
setSchemeState(next)
|
|
38
|
+
applySchemeToDocument(next)
|
|
39
|
+
})
|
|
40
|
+
return () => cancelAnimationFrame(id)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const setScheme = useCallback((next: ColorScheme) => {
|
|
44
|
+
setSchemeState(next)
|
|
45
|
+
localStorage.setItem(THEME_STORAGE_KEY, next)
|
|
46
|
+
applySchemeToDocument(next)
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
const toggleScheme = useCallback(() => {
|
|
50
|
+
setSchemeState((prev) => {
|
|
51
|
+
const next: ColorScheme = prev === 'light' ? 'dark' : 'light'
|
|
52
|
+
localStorage.setItem(THEME_STORAGE_KEY, next)
|
|
53
|
+
applySchemeToDocument(next)
|
|
54
|
+
return next
|
|
55
|
+
})
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
const value = useMemo(
|
|
59
|
+
() => ({ scheme, setScheme, toggleScheme }),
|
|
60
|
+
[scheme, setScheme, toggleScheme],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Access current theme and toggles from client components.
|
|
68
|
+
*/
|
|
69
|
+
export function useTheme(): ThemeContextValue {
|
|
70
|
+
const ctx = useContext(ThemeContext)
|
|
71
|
+
if (!ctx) {
|
|
72
|
+
throw new Error('useTheme must be used within ThemeProvider')
|
|
73
|
+
}
|
|
74
|
+
return ctx
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useTheme } from '@/components/ThemeProvider'
|
|
4
|
+
|
|
5
|
+
function IconSun(props: { className?: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
8
|
+
<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zM2 13h2v-2H2v2zm18 0h2v-2h-2v2zM11 2v2h2V2h-2zm0 18v2h2v-2h-2zM4.22 19.78l1.42-1.42-1.42-1.42-1.42 1.42 1.42 1.42zm12.72-1.42 1.42 1.42 1.42-1.42-1.42-1.42-1.42 1.42zM19.78 4.22l-1.42 1.42 1.42 1.42 1.42-1.42-1.42-1.42zM7.05 5.64 5.64 4.22 4.22 5.64l1.42 1.42 1.41-1.42z" />
|
|
9
|
+
</svg>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function IconMoon(props: { className?: string }) {
|
|
14
|
+
return (
|
|
15
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
16
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
17
|
+
</svg>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Cycles document color scheme between light and dark (persisted).
|
|
23
|
+
*/
|
|
24
|
+
export default function ThemeToggle() {
|
|
25
|
+
const { scheme, toggleScheme } = useTheme()
|
|
26
|
+
const isDark = scheme === 'dark'
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={toggleScheme}
|
|
32
|
+
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
33
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:shadow-none dark:hover:border-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-50"
|
|
34
|
+
>
|
|
35
|
+
{isDark ? <IconSun className="h-[18px] w-[18px]" /> : <IconMoon className="h-[18px] w-[18px]" />}
|
|
36
|
+
</button>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import readStoredYoutubeApiKey from '@/lib/youtube/read-stored-youtube-api-key'
|
|
5
|
+
import clearYoutubeDataApiBlocked from '@/lib/youtube/clear-youtube-data-api-blocked'
|
|
6
|
+
import writeStoredYoutubeApiKey from '@/lib/youtube/write-stored-youtube-api-key'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* YouTube Data API key configuration for MusicBrainz playback.
|
|
10
|
+
*/
|
|
11
|
+
export default function YouTubeSettingsPanel() {
|
|
12
|
+
const [apiKey, setApiKey] = useState('')
|
|
13
|
+
const [saved, setSaved] = useState(false)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setApiKey(readStoredYoutubeApiKey())
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
const onSave = useCallback(() => {
|
|
20
|
+
writeStoredYoutubeApiKey(apiKey)
|
|
21
|
+
clearYoutubeDataApiBlocked()
|
|
22
|
+
setSaved(true)
|
|
23
|
+
window.setTimeout(() => setSaved(false), 2000)
|
|
24
|
+
}, [apiKey])
|
|
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">YouTube</h2>
|
|
30
|
+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
|
31
|
+
MusicBrainz tracks stream via YouTube. An optional Data API key resolves videos reliably; without a key or if
|
|
32
|
+
quota is exceeded, Muzical falls back to in-player search.
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
|
|
37
|
+
<h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">API key</h3>
|
|
38
|
+
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
39
|
+
Create a key in the{' '}
|
|
40
|
+
<a
|
|
41
|
+
href="https://console.cloud.google.com/apis/credentials"
|
|
42
|
+
target="_blank"
|
|
43
|
+
rel="noopener noreferrer"
|
|
44
|
+
className="font-medium text-amber-700 underline decoration-amber-500/40 underline-offset-2 hover:text-amber-600 dark:text-amber-400"
|
|
45
|
+
>
|
|
46
|
+
Google Cloud Console
|
|
47
|
+
</a>{' '}
|
|
48
|
+
with the YouTube Data API v3 enabled. The key is stored only in this browser.
|
|
49
|
+
</p>
|
|
50
|
+
<label className="mt-4 block">
|
|
51
|
+
<span className="sr-only">YouTube Data API key</span>
|
|
52
|
+
<input
|
|
53
|
+
type="password"
|
|
54
|
+
autoComplete="off"
|
|
55
|
+
spellCheck={false}
|
|
56
|
+
value={apiKey}
|
|
57
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
58
|
+
placeholder="AIza…"
|
|
59
|
+
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-2.5 font-mono text-sm text-zinc-900 outline-none ring-amber-500/30 placeholder:text-zinc-400 focus:border-amber-500/50 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
|
60
|
+
/>
|
|
61
|
+
</label>
|
|
62
|
+
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onSave}
|
|
66
|
+
className="rounded-full bg-amber-500 px-4 py-2 text-sm font-medium text-zinc-950 shadow-sm transition hover:bg-amber-400"
|
|
67
|
+
>
|
|
68
|
+
Save key
|
|
69
|
+
</button>
|
|
70
|
+
{saved ? (
|
|
71
|
+
<span className="text-sm text-emerald-700 dark:text-emerald-400" role="status">
|
|
72
|
+
Saved
|
|
73
|
+
</span>
|
|
74
|
+
) : null}
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
type YouTubeStreamNotificationProps = {
|
|
4
|
+
visible: boolean
|
|
5
|
+
trackTitle?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Toast shown while a MusicBrainz track’s YouTube stream is being resolved.
|
|
10
|
+
*/
|
|
11
|
+
export default function YouTubeStreamNotification(props: YouTubeStreamNotificationProps) {
|
|
12
|
+
if (!props.visible) return null
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
role="status"
|
|
17
|
+
aria-live="polite"
|
|
18
|
+
aria-busy="true"
|
|
19
|
+
className="pointer-events-none fixed bottom-24 right-4 z-50 w-[min(100vw-2rem,22rem)] rounded-xl border border-zinc-200/90 bg-white/95 p-4 shadow-lg shadow-zinc-900/10 backdrop-blur-sm dark:border-zinc-700/80 dark:bg-zinc-900/95 dark:shadow-black/40"
|
|
20
|
+
>
|
|
21
|
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Resolving stream…</p>
|
|
22
|
+
{props.trackTitle ? (
|
|
23
|
+
<p className="mt-1 truncate text-xs text-zinc-600 dark:text-zinc-400">{props.trackTitle}</p>
|
|
24
|
+
) : null}
|
|
25
|
+
<div className="mt-3 h-1 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800">
|
|
26
|
+
<div className="h-full w-1/3 animate-pulse rounded-full bg-amber-500 dark:bg-amber-400" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats when a library root folder was added for display in settings.
|
|
3
|
+
*/
|
|
4
|
+
export default function formatLibraryRootAdded(ts: number): string {
|
|
5
|
+
try {
|
|
6
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
7
|
+
dateStyle: "medium",
|
|
8
|
+
timeStyle: "short",
|
|
9
|
+
}).format(new Date(ts));
|
|
10
|
+
} catch {
|
|
11
|
+
return new Date(ts).toLocaleString();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type SettingsNavItem = {
|
|
2
|
+
href: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** Routes shown in the settings sidebar. */
|
|
8
|
+
export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [
|
|
9
|
+
{
|
|
10
|
+
href: "/settings",
|
|
11
|
+
label: "Overview",
|
|
12
|
+
description: "Summary and links to all settings sections.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
href: "/settings/library",
|
|
16
|
+
label: "Library",
|
|
17
|
+
description:
|
|
18
|
+
"Scan folders, rescan on startup, and manage configured directories.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
href: "/settings/youtube",
|
|
22
|
+
label: "YouTube",
|
|
23
|
+
description:
|
|
24
|
+
"YouTube Data API key for resolving MusicBrainz tracks to embeddable videos.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
href: "/settings/playback",
|
|
28
|
+
label: "Playback",
|
|
29
|
+
description: "Restore your queue and playhead between sessions.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
href: "/settings/display",
|
|
33
|
+
label: "Display",
|
|
34
|
+
description: "List density and other visual preferences in the player.",
|
|
35
|
+
},
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** Subsections linked from the settings overview landing page. */
|
|
39
|
+
export const SETTINGS_SECTION_ITEMS: readonly SettingsNavItem[] =
|
|
40
|
+
SETTINGS_NAV_ITEMS.filter((item) => item.href !== "/settings");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// Override default ignores of eslint-config-next.
|
|
9
|
+
globalIgnores([
|
|
10
|
+
// Default ignores of eslint-config-next:
|
|
11
|
+
".next/**",
|
|
12
|
+
"out/**",
|
|
13
|
+
"build/**",
|
|
14
|
+
"next-env.d.ts",
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default eslintConfig;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats seconds as `m:ss` for UI labels.
|
|
3
|
+
*/
|
|
4
|
+
export function formatDuration(totalSec: number): string {
|
|
5
|
+
const safe = Math.max(0, Math.floor(totalSec));
|
|
6
|
+
const m = Math.floor(safe / 60);
|
|
7
|
+
const s = safe % 60;
|
|
8
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { formatDuration } from "@/lib/format-duration";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats total library playtime for settings summary (hours when long).
|
|
5
|
+
*/
|
|
6
|
+
export default function formatTotalLibraryDuration(totalSec: number): string {
|
|
7
|
+
if (totalSec <= 0) return "—";
|
|
8
|
+
const hours = Math.floor(totalSec / 3600);
|
|
9
|
+
if (hours > 0) {
|
|
10
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
11
|
+
return `${hours}h ${minutes}m`;
|
|
12
|
+
}
|
|
13
|
+
return formatDuration(totalSec);
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LIBRARY_AUDIO_EXTENSIONS } from "@/lib/library/constants";
|
|
2
|
+
|
|
3
|
+
const defaultExtSet = new Set(LIBRARY_AUDIO_EXTENSIONS);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true when the file name matches an enabled audio extension.
|
|
7
|
+
*/
|
|
8
|
+
export function isAudioFilename(
|
|
9
|
+
name: string,
|
|
10
|
+
enabledExtensions?: ReadonlySet<string>,
|
|
11
|
+
): boolean {
|
|
12
|
+
const dot = name.lastIndexOf(".");
|
|
13
|
+
if (dot < 0) return false;
|
|
14
|
+
const ext = name.slice(dot).toLowerCase();
|
|
15
|
+
const set = enabledExtensions ?? defaultExtSet;
|
|
16
|
+
return set.has(ext);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Strips a known audio extension for display as a title.
|
|
21
|
+
*/
|
|
22
|
+
export function stripAudioExtension(
|
|
23
|
+
name: string,
|
|
24
|
+
enabledExtensions?: ReadonlySet<string>,
|
|
25
|
+
): string {
|
|
26
|
+
const dot = name.lastIndexOf(".");
|
|
27
|
+
if (dot < 0) return name;
|
|
28
|
+
const ext = name.slice(dot).toLowerCase();
|
|
29
|
+
const set = enabledExtensions ?? defaultExtSet;
|
|
30
|
+
return set.has(ext) ? name.slice(0, dot) : name;
|
|
31
|
+
}
|