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,1531 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
5
|
+
import BrowsePanel from '@/components/BrowsePanel'
|
|
6
|
+
import { useLibrary } from '@/components/LibraryProvider'
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
onYouTubeIframeAPIReady?: () => void
|
|
11
|
+
YT?: any
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
import type { Track } from '@/types/track'
|
|
15
|
+
import { formatDuration } from '@/lib/format-duration'
|
|
16
|
+
import { getCoverBytesForTrack } from '@/lib/library/cover-bytes-cache'
|
|
17
|
+
import ThemeToggle from '@/components/ThemeToggle'
|
|
18
|
+
import FavoriteStarButton from '@/components/FavoriteStarButton'
|
|
19
|
+
import PanelResizeHandle from '@/components/PanelResizeHandle'
|
|
20
|
+
import QueueLoadingSpinner from '@/components/QueueLoadingSpinner'
|
|
21
|
+
import YouTubeStreamNotification from '@/components/YouTubeStreamNotification'
|
|
22
|
+
import readStoredYoutubeApiKey from '@/lib/youtube/read-stored-youtube-api-key'
|
|
23
|
+
import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
|
|
24
|
+
import shouldUseYoutubeSearchPlayback from '@/lib/youtube/should-use-youtube-search-playback'
|
|
25
|
+
|
|
26
|
+
const STORAGE_LIBRARY_PANEL_PX = 'muzical.panelWidth.library'
|
|
27
|
+
const STORAGE_QUEUE_PANEL_PX = 'muzical.panelWidth.queue'
|
|
28
|
+
const STORAGE_REPEAT_MODE = 'muzical.repeatMode'
|
|
29
|
+
const STORAGE_SHUFFLE = 'muzical.shuffle'
|
|
30
|
+
const STORAGE_PLAYBACK_RATE = 'muzical.playbackRate'
|
|
31
|
+
|
|
32
|
+
type RepeatMode = 'off' | 'all' | 'one'
|
|
33
|
+
|
|
34
|
+
const PLAYBACK_RATES: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 2]
|
|
35
|
+
|
|
36
|
+
function readStoredRepeatMode(): RepeatMode {
|
|
37
|
+
if (typeof window === 'undefined') return 'all'
|
|
38
|
+
const v = window.localStorage.getItem(STORAGE_REPEAT_MODE)
|
|
39
|
+
if (v === 'off' || v === 'all' || v === 'one') return v
|
|
40
|
+
return 'all'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readStoredShuffle(): boolean {
|
|
44
|
+
if (typeof window === 'undefined') return false
|
|
45
|
+
try {
|
|
46
|
+
return window.localStorage.getItem(STORAGE_SHUFFLE) === '1'
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStoredPlaybackRate(): number {
|
|
53
|
+
if (typeof window === 'undefined') return 1
|
|
54
|
+
const v = Number.parseFloat(window.localStorage.getItem(STORAGE_PLAYBACK_RATE) ?? '')
|
|
55
|
+
if (!Number.isFinite(v)) return 1
|
|
56
|
+
return PLAYBACK_RATES.includes(v) ? v : 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function persistRepeatMode(mode: RepeatMode): void {
|
|
60
|
+
try {
|
|
61
|
+
window.localStorage.setItem(STORAGE_REPEAT_MODE, mode)
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function persistShuffle(on: boolean): void {
|
|
68
|
+
try {
|
|
69
|
+
window.localStorage.setItem(STORAGE_SHUFFLE, on ? '1' : '0')
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function persistPlaybackRate(rate: number): void {
|
|
76
|
+
try {
|
|
77
|
+
window.localStorage.setItem(STORAGE_PLAYBACK_RATE, String(rate))
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const LIBRARY_PANEL_MIN = 300
|
|
83
|
+
const LIBRARY_PANEL_MAX = 960
|
|
84
|
+
const QUEUE_PANEL_MIN = 420
|
|
85
|
+
// Minimum width for the player panel (aside). Used to clamp resizes.
|
|
86
|
+
const PLAYER_PANEL_MIN = 350
|
|
87
|
+
|
|
88
|
+
function clampPanelPx(n: number, lo: number, hi: number): number {
|
|
89
|
+
return Math.min(hi, Math.max(lo, n))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStoredPanelPx(key: string, fallback: number): number {
|
|
93
|
+
if (typeof window === 'undefined') return fallback
|
|
94
|
+
const v = Number.parseInt(window.localStorage.getItem(key) ?? '', 10)
|
|
95
|
+
return Number.isFinite(v) ? v : fallback
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function clampLibraryQueueWidths(
|
|
99
|
+
rowWidthPx: number,
|
|
100
|
+
libraryPx: number,
|
|
101
|
+
queuePx: number,
|
|
102
|
+
): { libraryPx: number; queuePx: number } {
|
|
103
|
+
if (rowWidthPx <= 0) return { libraryPx, queuePx }
|
|
104
|
+
const maxSum = Math.max(
|
|
105
|
+
LIBRARY_PANEL_MIN + QUEUE_PANEL_MIN,
|
|
106
|
+
rowWidthPx - PLAYER_PANEL_MIN,
|
|
107
|
+
)
|
|
108
|
+
let L = clampPanelPx(libraryPx, LIBRARY_PANEL_MIN, LIBRARY_PANEL_MAX)
|
|
109
|
+
// Intentionally no hard `QUEUE_PANEL_MAX` cap: the player panel minimum width is
|
|
110
|
+
// enforced via `maxSum` below, and we don't want queue width to prevent it.
|
|
111
|
+
let Q = Math.max(QUEUE_PANEL_MIN, queuePx)
|
|
112
|
+
if (L + Q > maxSum) {
|
|
113
|
+
const over = L + Q - maxSum
|
|
114
|
+
const takeFromL = Math.min(over, L - LIBRARY_PANEL_MIN)
|
|
115
|
+
L -= takeFromL
|
|
116
|
+
let r = over - takeFromL
|
|
117
|
+
const takeFromQ = Math.min(r, Q - QUEUE_PANEL_MIN)
|
|
118
|
+
Q -= takeFromQ
|
|
119
|
+
r -= takeFromQ
|
|
120
|
+
if (r > 0) L = Math.max(LIBRARY_PANEL_MIN, L - r)
|
|
121
|
+
}
|
|
122
|
+
return { libraryPx: L, queuePx: Q }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clampQueuePanelWidth(rowWidthPx: number, libraryPx: number, queuePx: number): number {
|
|
126
|
+
const maxQ = rowWidthPx - libraryPx - PLAYER_PANEL_MIN
|
|
127
|
+
return clampPanelPx(queuePx, QUEUE_PANEL_MIN, Math.max(QUEUE_PANEL_MIN, maxQ))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function IconPlay(props: { className?: string }) {
|
|
131
|
+
return (
|
|
132
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
133
|
+
<path d="M8 5v14l11-7L8 5z" />
|
|
134
|
+
</svg>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function IconPause(props: { className?: string }) {
|
|
139
|
+
return (
|
|
140
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
141
|
+
<path d="M6 5h4v14H6V5zm8 0h4v14h-4V5z" />
|
|
142
|
+
</svg>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function IconSkipBack(props: { className?: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
149
|
+
<path d="M6 6h2v12H6V6zm3.5 6 8.5 6V6l-8.5 6z" />
|
|
150
|
+
</svg>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function IconSkipForward(props: { className?: string }) {
|
|
155
|
+
return (
|
|
156
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
157
|
+
<path d="M16 18h2V6h-2v12zM6 18l8.5-6L6 6v12z" />
|
|
158
|
+
</svg>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function IconVolume(props: { className?: string }) {
|
|
163
|
+
return (
|
|
164
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
165
|
+
<path d="M3 10v4h4l5 5V5L7 10H3zm13.5 2A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
|
166
|
+
</svg>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function IconQueue(props: { className?: string }) {
|
|
171
|
+
return (
|
|
172
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={props.className}>
|
|
173
|
+
<path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2zm12 1v6l5-3-5-3z" />
|
|
174
|
+
</svg>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function IconSettings(props: { className?: string }) {
|
|
179
|
+
return (
|
|
180
|
+
<svg
|
|
181
|
+
viewBox="0 0 24 24"
|
|
182
|
+
fill="none"
|
|
183
|
+
stroke="currentColor"
|
|
184
|
+
strokeWidth="2"
|
|
185
|
+
strokeLinecap="round"
|
|
186
|
+
strokeLinejoin="round"
|
|
187
|
+
aria-hidden
|
|
188
|
+
className={props.className}
|
|
189
|
+
>
|
|
190
|
+
<circle cx="12" cy="12" r="3" />
|
|
191
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
192
|
+
</svg>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function IconRepeatLoop(props: { className?: string; dimmed?: boolean }) {
|
|
197
|
+
return (
|
|
198
|
+
<svg
|
|
199
|
+
viewBox="0 0 24 24"
|
|
200
|
+
fill="none"
|
|
201
|
+
stroke="currentColor"
|
|
202
|
+
strokeWidth="2"
|
|
203
|
+
strokeLinecap="round"
|
|
204
|
+
strokeLinejoin="round"
|
|
205
|
+
aria-hidden
|
|
206
|
+
className={[props.className, props.dimmed ? 'opacity-40' : ''].filter(Boolean).join(' ')}
|
|
207
|
+
>
|
|
208
|
+
<path d="M17 1l4 4-4 4" />
|
|
209
|
+
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
210
|
+
<path d="M7 23l-4-4 4-4" />
|
|
211
|
+
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
212
|
+
</svg>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function IconShuffle(props: { className?: string }) {
|
|
217
|
+
return (
|
|
218
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden className={props.className}>
|
|
219
|
+
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
|
220
|
+
</svg>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Local-library player: queue from scanned folders, `<audio>` playback via object URLs.
|
|
226
|
+
*/
|
|
227
|
+
export default function MusicPlayer() {
|
|
228
|
+
const {
|
|
229
|
+
queue,
|
|
230
|
+
libraryTracks,
|
|
231
|
+
isScanning,
|
|
232
|
+
resolveFileForTrack,
|
|
233
|
+
bumpTrackDuration,
|
|
234
|
+
removeFromQueue,
|
|
235
|
+
clearQueue,
|
|
236
|
+
compactLists,
|
|
237
|
+
reorderQueueItems,
|
|
238
|
+
recentlyPlayedTrackIds,
|
|
239
|
+
recordRecentlyPlayedTrack,
|
|
240
|
+
isFavoriteSong,
|
|
241
|
+
toggleFavoriteTrack,
|
|
242
|
+
addToQueue,
|
|
243
|
+
favoriteSongIds,
|
|
244
|
+
playbackRestore,
|
|
245
|
+
consumePlaybackRestore,
|
|
246
|
+
reportPlayback,
|
|
247
|
+
isQueueReady,
|
|
248
|
+
} = useLibrary()
|
|
249
|
+
const [activeQueueId, setActiveQueueId] = useState<string | null>(null)
|
|
250
|
+
const [isPlaying, setIsPlaying] = useState(false)
|
|
251
|
+
const [positionSec, setPositionSec] = useState(0)
|
|
252
|
+
const [mediaDuration, setMediaDuration] = useState(0)
|
|
253
|
+
const [volume, setVolume] = useState(0.85)
|
|
254
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
255
|
+
const [streamResolving, setStreamResolving] = useState(false)
|
|
256
|
+
const [forceSearchFallback, setForceSearchFallback] = useState(false)
|
|
257
|
+
const [coverArtUrl, setCoverArtUrl] = useState<string | null>(null)
|
|
258
|
+
const [layoutLg, setLayoutLg] = useState(false)
|
|
259
|
+
const [libraryPanelPx, setLibraryPanelPx] = useState(440)
|
|
260
|
+
const [queuePanelPx, setQueuePanelPx] = useState(300)
|
|
261
|
+
const [repeatMode, setRepeatMode] = useState<RepeatMode>('all')
|
|
262
|
+
const [shuffle, setShuffle] = useState(false)
|
|
263
|
+
const [playbackRate, setPlaybackRate] = useState(1)
|
|
264
|
+
|
|
265
|
+
const mainRowRef = useRef<HTMLDivElement>(null)
|
|
266
|
+
const shuffleHistoryRef = useRef<number[]>([])
|
|
267
|
+
const pendingRestorePositionRef = useRef<number | null>(null)
|
|
268
|
+
const activeQueueIdRef = useRef<string | null>(null)
|
|
269
|
+
const lastPlaybackReportMsRef = useRef(0)
|
|
270
|
+
const libraryPanelPxRef = useRef(440)
|
|
271
|
+
const queuePanelPxRef = useRef(300)
|
|
272
|
+
const [dragOverQueueId, setDragOverQueueId] = useState<string | null>(null)
|
|
273
|
+
const [draggingQueueId, setDraggingQueueId] = useState<string | null>(null)
|
|
274
|
+
const panelResizeSessionRef = useRef<
|
|
275
|
+
| { kind: 'library-queue'; startLib: number; startQ: number }
|
|
276
|
+
| { kind: 'queue-player'; startQ: number }
|
|
277
|
+
| null
|
|
278
|
+
>(null)
|
|
279
|
+
|
|
280
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
281
|
+
const objectUrlRef = useRef<string | null>(null)
|
|
282
|
+
const coverObjectUrlRef = useRef<string | null>(null)
|
|
283
|
+
const youtubePlayerRef = useRef<any | null>(null)
|
|
284
|
+
const youtubeContainerRef = useRef<HTMLDivElement | null>(null)
|
|
285
|
+
const isPlayingRef = useRef(isPlaying)
|
|
286
|
+
const playbackRateRef = useRef(playbackRate)
|
|
287
|
+
|
|
288
|
+
useLayoutEffect(() => {
|
|
289
|
+
isPlayingRef.current = isPlaying
|
|
290
|
+
}, [isPlaying])
|
|
291
|
+
|
|
292
|
+
useLayoutEffect(() => {
|
|
293
|
+
playbackRateRef.current = playbackRate
|
|
294
|
+
}, [playbackRate])
|
|
295
|
+
|
|
296
|
+
useLayoutEffect(() => {
|
|
297
|
+
libraryPanelPxRef.current = libraryPanelPx
|
|
298
|
+
queuePanelPxRef.current = queuePanelPx
|
|
299
|
+
}, [libraryPanelPx, queuePanelPx])
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
queueMicrotask(() => {
|
|
303
|
+
setLibraryPanelPx(readStoredPanelPx(STORAGE_LIBRARY_PANEL_PX, 440))
|
|
304
|
+
setQueuePanelPx(readStoredPanelPx(STORAGE_QUEUE_PANEL_PX, 300))
|
|
305
|
+
setRepeatMode(readStoredRepeatMode())
|
|
306
|
+
setShuffle(readStoredShuffle())
|
|
307
|
+
setPlaybackRate(readStoredPlaybackRate())
|
|
308
|
+
})
|
|
309
|
+
}, [])
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!shuffle) shuffleHistoryRef.current = []
|
|
313
|
+
}, [shuffle])
|
|
314
|
+
|
|
315
|
+
useLayoutEffect(() => {
|
|
316
|
+
if (typeof window === 'undefined') return
|
|
317
|
+
const mq = window.matchMedia('(min-width: 1024px)')
|
|
318
|
+
const apply = (): void => {
|
|
319
|
+
setLayoutLg(mq.matches)
|
|
320
|
+
}
|
|
321
|
+
apply()
|
|
322
|
+
mq.addEventListener('change', apply)
|
|
323
|
+
return () => mq.removeEventListener('change', apply)
|
|
324
|
+
}, [])
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
activeQueueIdRef.current = activeQueueId
|
|
328
|
+
}, [activeQueueId])
|
|
329
|
+
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!playbackRestore) return
|
|
332
|
+
const { activeQueueId: nextActiveId, positionSec: nextPos } = playbackRestore
|
|
333
|
+
pendingRestorePositionRef.current = nextPos
|
|
334
|
+
consumePlaybackRestore()
|
|
335
|
+
void Promise.resolve().then(() => {
|
|
336
|
+
setActiveQueueId(nextActiveId)
|
|
337
|
+
setPositionSec(nextPos)
|
|
338
|
+
setIsPlaying(false)
|
|
339
|
+
})
|
|
340
|
+
}, [playbackRestore, consumePlaybackRestore])
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
const el = audioRef.current
|
|
344
|
+
const pos = el && Number.isFinite(el.currentTime) ? el.currentTime : 0
|
|
345
|
+
reportPlayback(activeQueueId, pos)
|
|
346
|
+
}, [activeQueueId, reportPlayback])
|
|
347
|
+
|
|
348
|
+
const activeIndex = useMemo(() => {
|
|
349
|
+
if (queue.length === 0) return -1
|
|
350
|
+
if (activeQueueId) {
|
|
351
|
+
const i = queue.findIndex((q) => q.queueId === activeQueueId)
|
|
352
|
+
if (i >= 0) return i
|
|
353
|
+
}
|
|
354
|
+
return 0
|
|
355
|
+
}, [queue, activeQueueId])
|
|
356
|
+
|
|
357
|
+
const current: Track | undefined = activeIndex >= 0 ? queue[activeIndex]?.track : undefined
|
|
358
|
+
const lastRecordedTrackIdRef = useRef<string | null>(null)
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!isPlaying) return
|
|
362
|
+
const id = current?.id ?? ''
|
|
363
|
+
if (!id) return
|
|
364
|
+
if (lastRecordedTrackIdRef.current === id) return
|
|
365
|
+
lastRecordedTrackIdRef.current = id
|
|
366
|
+
recordRecentlyPlayedTrack(id)
|
|
367
|
+
}, [current?.id, isPlaying, recordRecentlyPlayedTrack])
|
|
368
|
+
|
|
369
|
+
const recentlyPlayedTracks = useMemo(() => {
|
|
370
|
+
if (recentlyPlayedTrackIds.length === 0) return []
|
|
371
|
+
const byId = new Map<string, Track>()
|
|
372
|
+
for (const t of libraryTracks) byId.set(t.id, t)
|
|
373
|
+
const out: Track[] = []
|
|
374
|
+
for (const id of recentlyPlayedTrackIds) {
|
|
375
|
+
const t = byId.get(id)
|
|
376
|
+
if (t) out.push(t)
|
|
377
|
+
if (out.length >= 8) break
|
|
378
|
+
}
|
|
379
|
+
return out
|
|
380
|
+
}, [recentlyPlayedTrackIds, libraryTracks])
|
|
381
|
+
|
|
382
|
+
const suggestedTracks = useMemo(() => {
|
|
383
|
+
if (libraryTracks.length === 0) return []
|
|
384
|
+
const byId = new Map<string, Track>()
|
|
385
|
+
for (const t of libraryTracks) byId.set(t.id, t)
|
|
386
|
+
|
|
387
|
+
const seen = new Set<string>()
|
|
388
|
+
const out: Track[] = []
|
|
389
|
+
|
|
390
|
+
for (const t of recentlyPlayedTracks) {
|
|
391
|
+
seen.add(t.id)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const id of favoriteSongIds) {
|
|
395
|
+
const t = byId.get(id)
|
|
396
|
+
if (!t) continue
|
|
397
|
+
if (seen.has(t.id)) continue
|
|
398
|
+
seen.add(t.id)
|
|
399
|
+
out.push(t)
|
|
400
|
+
if (out.length >= 12) return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (let i = 0; i < libraryTracks.length; i++) {
|
|
404
|
+
const t = libraryTracks[i]
|
|
405
|
+
if (seen.has(t.id)) continue
|
|
406
|
+
seen.add(t.id)
|
|
407
|
+
out.push(t)
|
|
408
|
+
if (out.length >= 12) break
|
|
409
|
+
}
|
|
410
|
+
return out
|
|
411
|
+
}, [libraryTracks, favoriteSongIds, recentlyPlayedTracks])
|
|
412
|
+
|
|
413
|
+
const durationSec = useMemo(() => {
|
|
414
|
+
const fromTrack = current?.durationSec ?? 0
|
|
415
|
+
const fromMedia = Number.isFinite(mediaDuration) && mediaDuration > 0 ? mediaDuration : 0
|
|
416
|
+
return Math.max(fromTrack, fromMedia)
|
|
417
|
+
}, [current?.durationSec, mediaDuration])
|
|
418
|
+
|
|
419
|
+
const selectIndex = useCallback(
|
|
420
|
+
(index: number) => {
|
|
421
|
+
shuffleHistoryRef.current = []
|
|
422
|
+
setActiveQueueId(queue[index]?.queueId ?? null)
|
|
423
|
+
setPositionSec(0)
|
|
424
|
+
setLoadError(null)
|
|
425
|
+
setIsPlaying(true)
|
|
426
|
+
},
|
|
427
|
+
[queue],
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const goNext = useCallback((): void => {
|
|
431
|
+
if (queue.length === 0) return
|
|
432
|
+
const idx = activeIndex >= 0 ? activeIndex : 0
|
|
433
|
+
|
|
434
|
+
if (shuffle && queue.length > 1) {
|
|
435
|
+
shuffleHistoryRef.current.push(idx)
|
|
436
|
+
let j = idx
|
|
437
|
+
for (let n = 0; n < 48 && j === idx; n++) {
|
|
438
|
+
j = Math.floor(Math.random() * queue.length)
|
|
439
|
+
}
|
|
440
|
+
setActiveQueueId(queue[j]?.queueId ?? null)
|
|
441
|
+
setPositionSec(0)
|
|
442
|
+
setLoadError(null)
|
|
443
|
+
setIsPlaying(true)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (idx < queue.length - 1) {
|
|
448
|
+
setActiveQueueId(queue[idx + 1]?.queueId ?? null)
|
|
449
|
+
setPositionSec(0)
|
|
450
|
+
setLoadError(null)
|
|
451
|
+
setIsPlaying(true)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
if (repeatMode === 'all') {
|
|
455
|
+
setActiveQueueId(queue[0]?.queueId ?? null)
|
|
456
|
+
setPositionSec(0)
|
|
457
|
+
setLoadError(null)
|
|
458
|
+
setIsPlaying(true)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
setIsPlaying(false)
|
|
462
|
+
}, [activeIndex, queue, repeatMode, shuffle])
|
|
463
|
+
|
|
464
|
+
const goPrev = useCallback((): void => {
|
|
465
|
+
if (queue.length === 0) return
|
|
466
|
+
const idx = activeIndex >= 0 ? activeIndex : 0
|
|
467
|
+
|
|
468
|
+
if (shuffle && shuffleHistoryRef.current.length > 0) {
|
|
469
|
+
const prevIdx = shuffleHistoryRef.current.pop()
|
|
470
|
+
if (prevIdx !== undefined && prevIdx >= 0 && prevIdx < queue.length) {
|
|
471
|
+
setActiveQueueId(queue[prevIdx]?.queueId ?? null)
|
|
472
|
+
setPositionSec(0)
|
|
473
|
+
setLoadError(null)
|
|
474
|
+
setIsPlaying(true)
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (idx > 0) {
|
|
480
|
+
setActiveQueueId(queue[idx - 1]?.queueId ?? null)
|
|
481
|
+
setPositionSec(0)
|
|
482
|
+
setLoadError(null)
|
|
483
|
+
setIsPlaying(true)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if (repeatMode === 'all') {
|
|
487
|
+
setActiveQueueId(queue[queue.length - 1]?.queueId ?? null)
|
|
488
|
+
setPositionSec(0)
|
|
489
|
+
setLoadError(null)
|
|
490
|
+
setIsPlaying(true)
|
|
491
|
+
}
|
|
492
|
+
}, [activeIndex, queue, repeatMode, shuffle])
|
|
493
|
+
|
|
494
|
+
const cycleRepeatMode = useCallback((): void => {
|
|
495
|
+
setRepeatMode((m) => {
|
|
496
|
+
const next: RepeatMode = m === 'off' ? 'all' : m === 'all' ? 'one' : 'off'
|
|
497
|
+
persistRepeatMode(next)
|
|
498
|
+
return next
|
|
499
|
+
})
|
|
500
|
+
}, [])
|
|
501
|
+
|
|
502
|
+
const toggleShuffle = useCallback((): void => {
|
|
503
|
+
setShuffle((s) => {
|
|
504
|
+
const next = !s
|
|
505
|
+
persistShuffle(next)
|
|
506
|
+
return next
|
|
507
|
+
})
|
|
508
|
+
}, [])
|
|
509
|
+
|
|
510
|
+
const clampPanelsToRow = useCallback((): void => {
|
|
511
|
+
const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
|
|
512
|
+
if (rowW <= 0) return
|
|
513
|
+
const { libraryPx, queuePx } = clampLibraryQueueWidths(
|
|
514
|
+
rowW,
|
|
515
|
+
libraryPanelPxRef.current,
|
|
516
|
+
queuePanelPxRef.current,
|
|
517
|
+
)
|
|
518
|
+
setLibraryPanelPx(libraryPx)
|
|
519
|
+
setQueuePanelPx(queuePx)
|
|
520
|
+
}, [])
|
|
521
|
+
|
|
522
|
+
const persistPanelWidths = useCallback((): void => {
|
|
523
|
+
try {
|
|
524
|
+
window.localStorage.setItem(STORAGE_LIBRARY_PANEL_PX, String(libraryPanelPxRef.current))
|
|
525
|
+
window.localStorage.setItem(STORAGE_QUEUE_PANEL_PX, String(queuePanelPxRef.current))
|
|
526
|
+
} catch {
|
|
527
|
+
/* ignore */
|
|
528
|
+
}
|
|
529
|
+
}, [])
|
|
530
|
+
|
|
531
|
+
const onLibraryQueueResizeStart = useCallback((): void => {
|
|
532
|
+
panelResizeSessionRef.current = {
|
|
533
|
+
kind: 'library-queue',
|
|
534
|
+
startLib: libraryPanelPxRef.current,
|
|
535
|
+
startQ: queuePanelPxRef.current,
|
|
536
|
+
}
|
|
537
|
+
}, [])
|
|
538
|
+
|
|
539
|
+
const onLibraryQueueResizeMove = useCallback((dx: number): void => {
|
|
540
|
+
const s = panelResizeSessionRef.current
|
|
541
|
+
if (!s || s.kind !== 'library-queue') return
|
|
542
|
+
const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
|
|
543
|
+
const next = clampLibraryQueueWidths(rowW, s.startLib + dx, s.startQ - dx)
|
|
544
|
+
setLibraryPanelPx(next.libraryPx)
|
|
545
|
+
setQueuePanelPx(next.queuePx)
|
|
546
|
+
}, [])
|
|
547
|
+
|
|
548
|
+
const onQueuePlayerResizeStart = useCallback((): void => {
|
|
549
|
+
panelResizeSessionRef.current = { kind: 'queue-player', startQ: queuePanelPxRef.current }
|
|
550
|
+
}, [])
|
|
551
|
+
|
|
552
|
+
const onQueuePlayerResizeMove = useCallback((dx: number): void => {
|
|
553
|
+
const s = panelResizeSessionRef.current
|
|
554
|
+
if (!s || s.kind !== 'queue-player') return
|
|
555
|
+
const rowW = mainRowRef.current?.getBoundingClientRect().width ?? 0
|
|
556
|
+
const nextQ = clampQueuePanelWidth(rowW, libraryPanelPxRef.current, s.startQ + dx)
|
|
557
|
+
setQueuePanelPx(nextQ)
|
|
558
|
+
}, [])
|
|
559
|
+
|
|
560
|
+
const onPanelResizeEnd = useCallback((): void => {
|
|
561
|
+
panelResizeSessionRef.current = null
|
|
562
|
+
persistPanelWidths()
|
|
563
|
+
}, [persistPanelWidths])
|
|
564
|
+
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
if (!layoutLg) return undefined
|
|
567
|
+
const onResize = (): void => {
|
|
568
|
+
clampPanelsToRow()
|
|
569
|
+
}
|
|
570
|
+
window.addEventListener('resize', onResize)
|
|
571
|
+
clampPanelsToRow()
|
|
572
|
+
return () => window.removeEventListener('resize', onResize)
|
|
573
|
+
}, [layoutLg, clampPanelsToRow])
|
|
574
|
+
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
const el = audioRef.current
|
|
577
|
+
if (!el) return undefined
|
|
578
|
+
el.volume = volume
|
|
579
|
+
}, [volume])
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
const el = audioRef.current
|
|
583
|
+
if (!el) return undefined
|
|
584
|
+
el.playbackRate = playbackRate
|
|
585
|
+
}, [playbackRate])
|
|
586
|
+
|
|
587
|
+
useEffect(() => {
|
|
588
|
+
if (coverObjectUrlRef.current) {
|
|
589
|
+
URL.revokeObjectURL(coverObjectUrlRef.current)
|
|
590
|
+
coverObjectUrlRef.current = null
|
|
591
|
+
}
|
|
592
|
+
void Promise.resolve().then(() => {
|
|
593
|
+
setCoverArtUrl(null)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
if (!current || (!current.library && !current.youtubeQuery && !current.audioUrl)) {
|
|
597
|
+
const el = audioRef.current
|
|
598
|
+
if (el) {
|
|
599
|
+
el.pause()
|
|
600
|
+
el.removeAttribute('src')
|
|
601
|
+
el.load()
|
|
602
|
+
}
|
|
603
|
+
if (objectUrlRef.current) {
|
|
604
|
+
URL.revokeObjectURL(objectUrlRef.current)
|
|
605
|
+
objectUrlRef.current = null
|
|
606
|
+
}
|
|
607
|
+
return undefined
|
|
608
|
+
}
|
|
609
|
+
if (!current.library) {
|
|
610
|
+
const el = audioRef.current
|
|
611
|
+
if (el) {
|
|
612
|
+
el.pause()
|
|
613
|
+
el.removeAttribute('src')
|
|
614
|
+
el.load()
|
|
615
|
+
}
|
|
616
|
+
if (objectUrlRef.current) {
|
|
617
|
+
URL.revokeObjectURL(objectUrlRef.current)
|
|
618
|
+
objectUrlRef.current = null
|
|
619
|
+
}
|
|
620
|
+
return undefined
|
|
621
|
+
}
|
|
622
|
+
let cancelled = false
|
|
623
|
+
const pendingPos = pendingRestorePositionRef.current
|
|
624
|
+
const rid = requestAnimationFrame(() => {
|
|
625
|
+
setMediaDuration(0)
|
|
626
|
+
setPositionSec(pendingPos ?? 0)
|
|
627
|
+
})
|
|
628
|
+
void (async (): Promise<void> => {
|
|
629
|
+
setLoadError(null)
|
|
630
|
+
const file = await resolveFileForTrack(current)
|
|
631
|
+
if (cancelled) return
|
|
632
|
+
if (!file) {
|
|
633
|
+
setLoadError('Could not read this file from the library.')
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
const coverBytesPromise = getCoverBytesForTrack(current.id, file)
|
|
637
|
+
const url = URL.createObjectURL(file)
|
|
638
|
+
if (objectUrlRef.current) {
|
|
639
|
+
URL.revokeObjectURL(objectUrlRef.current)
|
|
640
|
+
}
|
|
641
|
+
objectUrlRef.current = url
|
|
642
|
+
if (cancelled) {
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
const coverBytes = await coverBytesPromise
|
|
646
|
+
if (cancelled) return
|
|
647
|
+
if (coverBytes) {
|
|
648
|
+
const coverUrl = URL.createObjectURL(new Blob([coverBytes.data], { type: coverBytes.mime }))
|
|
649
|
+
coverObjectUrlRef.current = coverUrl
|
|
650
|
+
setCoverArtUrl(coverUrl)
|
|
651
|
+
}
|
|
652
|
+
const el = audioRef.current
|
|
653
|
+
if (!el || cancelled) {
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
el.src = url
|
|
657
|
+
el.load()
|
|
658
|
+
el.playbackRate = playbackRateRef.current
|
|
659
|
+
if (isPlayingRef.current) {
|
|
660
|
+
void el.play().catch((e: unknown) => {
|
|
661
|
+
setLoadError(e instanceof Error ? e.message : 'Playback failed')
|
|
662
|
+
setIsPlaying(false)
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
})()
|
|
666
|
+
return (): void => {
|
|
667
|
+
cancelled = true
|
|
668
|
+
cancelAnimationFrame(rid)
|
|
669
|
+
if (coverObjectUrlRef.current) {
|
|
670
|
+
URL.revokeObjectURL(coverObjectUrlRef.current)
|
|
671
|
+
coverObjectUrlRef.current = null
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}, [current, resolveFileForTrack])
|
|
675
|
+
|
|
676
|
+
const useSearchPlayback = shouldUseYoutubeSearchPlayback(
|
|
677
|
+
current?.youtubeQuery,
|
|
678
|
+
current?.youtubeVideoId,
|
|
679
|
+
readStoredYoutubeApiKey().length > 0,
|
|
680
|
+
forceSearchFallback,
|
|
681
|
+
)
|
|
682
|
+
const youtubeStreamActive = Boolean(
|
|
683
|
+
current?.youtubeQuery && (current?.youtubeVideoId || useSearchPlayback),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
setForceSearchFallback(false)
|
|
688
|
+
const query = current?.youtubeQuery?.trim()
|
|
689
|
+
if (!query || current?.youtubeVideoId) return undefined
|
|
690
|
+
if (readYoutubeDataApiBlocked() || !readStoredYoutubeApiKey()) return undefined
|
|
691
|
+
const t = window.setTimeout(() => setForceSearchFallback(true), 10000)
|
|
692
|
+
return (): void => {
|
|
693
|
+
window.clearTimeout(t)
|
|
694
|
+
}
|
|
695
|
+
}, [current?.id, current?.youtubeQuery, current?.youtubeVideoId])
|
|
696
|
+
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
if (!current?.youtubeQuery) {
|
|
699
|
+
setStreamResolving(false)
|
|
700
|
+
return undefined
|
|
701
|
+
}
|
|
702
|
+
if (current.youtubeVideoId || useSearchPlayback) {
|
|
703
|
+
setStreamResolving(false)
|
|
704
|
+
return undefined
|
|
705
|
+
}
|
|
706
|
+
if (!readStoredYoutubeApiKey() || readYoutubeDataApiBlocked()) {
|
|
707
|
+
setStreamResolving(false)
|
|
708
|
+
return undefined
|
|
709
|
+
}
|
|
710
|
+
setStreamResolving(true)
|
|
711
|
+
return undefined
|
|
712
|
+
}, [current?.id, current?.youtubeQuery, current?.youtubeVideoId, useSearchPlayback])
|
|
713
|
+
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
const videoId = current?.youtubeVideoId?.trim()
|
|
716
|
+
const query = current?.youtubeQuery?.trim()
|
|
717
|
+
if (!videoId && !query) {
|
|
718
|
+
if (youtubePlayerRef.current) {
|
|
719
|
+
try {
|
|
720
|
+
youtubePlayerRef.current.destroy()
|
|
721
|
+
} catch {
|
|
722
|
+
/* ignore */
|
|
723
|
+
}
|
|
724
|
+
youtubePlayerRef.current = null
|
|
725
|
+
}
|
|
726
|
+
return undefined
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!videoId && !useSearchPlayback) {
|
|
730
|
+
return undefined
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const basePlayerVars = {
|
|
734
|
+
autoplay: isPlaying ? 1 : 0,
|
|
735
|
+
controls: 0,
|
|
736
|
+
disablekb: 1,
|
|
737
|
+
fs: 0,
|
|
738
|
+
rel: 0,
|
|
739
|
+
modestbranding: 1,
|
|
740
|
+
iv_load_policy: 3,
|
|
741
|
+
origin: window.location.origin,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const ensurePlayer = (): void => {
|
|
745
|
+
if (!window.YT?.Player || !youtubeContainerRef.current) {
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
const existingPlayer = youtubePlayerRef.current
|
|
749
|
+
const createPlayer = (): void => {
|
|
750
|
+
if (!youtubeContainerRef.current) return
|
|
751
|
+
const playerVars = useSearchPlayback && query
|
|
752
|
+
? { ...basePlayerVars, listType: 'search' as const, list: query }
|
|
753
|
+
: basePlayerVars
|
|
754
|
+
youtubePlayerRef.current = new window.YT.Player(youtubeContainerRef.current, {
|
|
755
|
+
height: '1',
|
|
756
|
+
width: '1',
|
|
757
|
+
...(videoId && !useSearchPlayback ? { videoId } : {}),
|
|
758
|
+
playerVars,
|
|
759
|
+
events: {
|
|
760
|
+
onReady: (event: any) => {
|
|
761
|
+
try {
|
|
762
|
+
event.target.setVolume(Math.round(volume * 100))
|
|
763
|
+
const d = event.target.getDuration?.()
|
|
764
|
+
if (Number.isFinite(d) && d > 0) {
|
|
765
|
+
setMediaDuration(d)
|
|
766
|
+
bumpTrackDuration(current?.id ?? '', d)
|
|
767
|
+
}
|
|
768
|
+
} catch {
|
|
769
|
+
/* ignore */
|
|
770
|
+
}
|
|
771
|
+
setLoadError(null)
|
|
772
|
+
if (isPlaying) {
|
|
773
|
+
event.target.playVideo()
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
onStateChange: (event: any) => {
|
|
777
|
+
if (event.data === window.YT.PlayerState.ENDED) {
|
|
778
|
+
goNext()
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (existingPlayer) {
|
|
786
|
+
try {
|
|
787
|
+
if (useSearchPlayback && query) {
|
|
788
|
+
existingPlayer.loadPlaylist({ listType: 'search', list: query, index: 0 })
|
|
789
|
+
} else if (videoId) {
|
|
790
|
+
existingPlayer.loadVideoById(videoId)
|
|
791
|
+
}
|
|
792
|
+
if (isPlaying) existingPlayer.playVideo()
|
|
793
|
+
else existingPlayer.pauseVideo()
|
|
794
|
+
existingPlayer.setVolume(Math.round(volume * 100))
|
|
795
|
+
} catch {
|
|
796
|
+
existingPlayer.destroy()
|
|
797
|
+
createPlayer()
|
|
798
|
+
}
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
createPlayer()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (window.YT?.Player) {
|
|
806
|
+
ensurePlayer()
|
|
807
|
+
} else {
|
|
808
|
+
if (!document.getElementById('youtube-iframe-api')) {
|
|
809
|
+
const script = document.createElement('script')
|
|
810
|
+
script.id = 'youtube-iframe-api'
|
|
811
|
+
script.src = 'https://www.youtube.com/iframe_api'
|
|
812
|
+
script.async = true
|
|
813
|
+
document.body.appendChild(script)
|
|
814
|
+
}
|
|
815
|
+
const previous = window.onYouTubeIframeAPIReady
|
|
816
|
+
window.onYouTubeIframeAPIReady = () => {
|
|
817
|
+
ensurePlayer()
|
|
818
|
+
if (typeof previous === 'function') {
|
|
819
|
+
previous()
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return (): void => {
|
|
825
|
+
if (!youtubeStreamActive && youtubePlayerRef.current) {
|
|
826
|
+
try {
|
|
827
|
+
youtubePlayerRef.current.destroy()
|
|
828
|
+
} catch {
|
|
829
|
+
/* ignore */
|
|
830
|
+
}
|
|
831
|
+
youtubePlayerRef.current = null
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}, [
|
|
835
|
+
current?.youtubeVideoId,
|
|
836
|
+
current?.youtubeQuery,
|
|
837
|
+
useSearchPlayback,
|
|
838
|
+
youtubeStreamActive,
|
|
839
|
+
isPlaying,
|
|
840
|
+
volume,
|
|
841
|
+
goNext,
|
|
842
|
+
bumpTrackDuration,
|
|
843
|
+
current?.id,
|
|
844
|
+
])
|
|
845
|
+
|
|
846
|
+
useEffect(() => {
|
|
847
|
+
const player = youtubePlayerRef.current
|
|
848
|
+
if (!player || !youtubeStreamActive) return
|
|
849
|
+
try {
|
|
850
|
+
if (isPlaying) player.playVideo()
|
|
851
|
+
else player.pauseVideo()
|
|
852
|
+
} catch {
|
|
853
|
+
/* ignore */
|
|
854
|
+
}
|
|
855
|
+
}, [isPlaying, youtubeStreamActive])
|
|
856
|
+
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
const player = youtubePlayerRef.current
|
|
859
|
+
if (!player || !youtubeStreamActive) return
|
|
860
|
+
try {
|
|
861
|
+
player.setVolume(Math.round(volume * 100))
|
|
862
|
+
} catch {
|
|
863
|
+
/* ignore */
|
|
864
|
+
}
|
|
865
|
+
}, [volume, youtubeStreamActive])
|
|
866
|
+
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
if (!youtubeStreamActive) return undefined
|
|
869
|
+
const tick = (): void => {
|
|
870
|
+
const player = youtubePlayerRef.current
|
|
871
|
+
if (!player?.getCurrentTime) return
|
|
872
|
+
try {
|
|
873
|
+
const t = player.getCurrentTime()
|
|
874
|
+
if (Number.isFinite(t) && t >= 0) {
|
|
875
|
+
setPositionSec(t)
|
|
876
|
+
reportPlayback(activeQueueIdRef.current, t)
|
|
877
|
+
}
|
|
878
|
+
const d = player.getDuration?.()
|
|
879
|
+
if (Number.isFinite(d) && d > 0) setMediaDuration(d)
|
|
880
|
+
} catch {
|
|
881
|
+
/* ignore */
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
tick()
|
|
885
|
+
const id = window.setInterval(tick, 500)
|
|
886
|
+
return (): void => {
|
|
887
|
+
window.clearInterval(id)
|
|
888
|
+
}
|
|
889
|
+
}, [youtubeStreamActive, reportPlayback])
|
|
890
|
+
|
|
891
|
+
useEffect(() => {
|
|
892
|
+
const el = audioRef.current
|
|
893
|
+
if (!el) return undefined
|
|
894
|
+
if (!el.src) return undefined
|
|
895
|
+
if (isPlaying) {
|
|
896
|
+
void el.play().catch((e: unknown) => {
|
|
897
|
+
setLoadError(e instanceof Error ? e.message : 'Playback failed')
|
|
898
|
+
setIsPlaying(false)
|
|
899
|
+
})
|
|
900
|
+
} else {
|
|
901
|
+
el.pause()
|
|
902
|
+
}
|
|
903
|
+
}, [isPlaying])
|
|
904
|
+
|
|
905
|
+
useEffect(() => {
|
|
906
|
+
const el = audioRef.current
|
|
907
|
+
if (!el || !current) return undefined
|
|
908
|
+
const onTime = (): void => {
|
|
909
|
+
setPositionSec(el.currentTime)
|
|
910
|
+
const now = Date.now()
|
|
911
|
+
if (now - lastPlaybackReportMsRef.current >= 2000) {
|
|
912
|
+
lastPlaybackReportMsRef.current = now
|
|
913
|
+
reportPlayback(activeQueueIdRef.current, el.currentTime)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const onMeta = (): void => {
|
|
917
|
+
if (Number.isFinite(el.duration) && el.duration > 0) {
|
|
918
|
+
setMediaDuration(el.duration)
|
|
919
|
+
bumpTrackDuration(current.id, el.duration)
|
|
920
|
+
}
|
|
921
|
+
const pending = pendingRestorePositionRef.current
|
|
922
|
+
if (pending != null && pending > 0) {
|
|
923
|
+
const seekTo = Math.min(pending, el.duration > 0 ? el.duration : pending)
|
|
924
|
+
el.currentTime = seekTo
|
|
925
|
+
setPositionSec(seekTo)
|
|
926
|
+
pendingRestorePositionRef.current = null
|
|
927
|
+
reportPlayback(activeQueueIdRef.current, seekTo)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const onEnded = (): void => {
|
|
931
|
+
if (repeatMode === 'one') {
|
|
932
|
+
el.currentTime = 0
|
|
933
|
+
setPositionSec(0)
|
|
934
|
+
void el.play().catch((e: unknown) => {
|
|
935
|
+
setLoadError(e instanceof Error ? e.message : 'Playback failed')
|
|
936
|
+
setIsPlaying(false)
|
|
937
|
+
})
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
goNext()
|
|
941
|
+
}
|
|
942
|
+
el.addEventListener('timeupdate', onTime)
|
|
943
|
+
el.addEventListener('loadedmetadata', onMeta)
|
|
944
|
+
el.addEventListener('ended', onEnded)
|
|
945
|
+
return (): void => {
|
|
946
|
+
el.removeEventListener('timeupdate', onTime)
|
|
947
|
+
el.removeEventListener('loadedmetadata', onMeta)
|
|
948
|
+
el.removeEventListener('ended', onEnded)
|
|
949
|
+
}
|
|
950
|
+
}, [current, bumpTrackDuration, goNext, repeatMode, reportPlayback])
|
|
951
|
+
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
return (): void => {
|
|
954
|
+
if (objectUrlRef.current) {
|
|
955
|
+
URL.revokeObjectURL(objectUrlRef.current)
|
|
956
|
+
objectUrlRef.current = null
|
|
957
|
+
}
|
|
958
|
+
if (coverObjectUrlRef.current) {
|
|
959
|
+
URL.revokeObjectURL(coverObjectUrlRef.current)
|
|
960
|
+
coverObjectUrlRef.current = null
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}, [])
|
|
964
|
+
|
|
965
|
+
const onSeekBarPointer = useCallback(
|
|
966
|
+
(ratio: number) => {
|
|
967
|
+
const r = Math.min(1, Math.max(0, ratio))
|
|
968
|
+
const yt = youtubePlayerRef.current
|
|
969
|
+
if (youtubeStreamActive && yt?.seekTo) {
|
|
970
|
+
const total =
|
|
971
|
+
durationSec > 0
|
|
972
|
+
? durationSec
|
|
973
|
+
: (() => {
|
|
974
|
+
try {
|
|
975
|
+
const d = yt.getDuration?.()
|
|
976
|
+
return Number.isFinite(d) && d > 0 ? d : 0
|
|
977
|
+
} catch {
|
|
978
|
+
return 0
|
|
979
|
+
}
|
|
980
|
+
})()
|
|
981
|
+
if (total > 0) {
|
|
982
|
+
const next = r * total
|
|
983
|
+
try {
|
|
984
|
+
yt.seekTo(next, true)
|
|
985
|
+
} catch {
|
|
986
|
+
/* ignore */
|
|
987
|
+
}
|
|
988
|
+
setPositionSec(next)
|
|
989
|
+
reportPlayback(activeQueueIdRef.current, next)
|
|
990
|
+
}
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
const el = audioRef.current
|
|
994
|
+
if (!el || !Number.isFinite(el.duration) || el.duration <= 0) {
|
|
995
|
+
if (durationSec > 0) {
|
|
996
|
+
setPositionSec(r * durationSec)
|
|
997
|
+
if (el) el.currentTime = r * durationSec
|
|
998
|
+
}
|
|
999
|
+
return
|
|
1000
|
+
}
|
|
1001
|
+
const next = Math.min(el.duration, Math.max(0, r * el.duration))
|
|
1002
|
+
el.currentTime = next
|
|
1003
|
+
setPositionSec(next)
|
|
1004
|
+
reportPlayback(activeQueueIdRef.current, next)
|
|
1005
|
+
},
|
|
1006
|
+
[youtubeStreamActive, durationSec, reportPlayback],
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
const onSeekBarKeyDown = useCallback(
|
|
1010
|
+
(e: KeyboardEvent<HTMLDivElement>): void => {
|
|
1011
|
+
if (durationSec <= 0) return
|
|
1012
|
+
|
|
1013
|
+
const stepSmallSec = 5
|
|
1014
|
+
const stepLargeSec = 30
|
|
1015
|
+
let nextTime: number | null = null
|
|
1016
|
+
|
|
1017
|
+
if (e.key === 'ArrowLeft') nextTime = positionSec - stepSmallSec
|
|
1018
|
+
else if (e.key === 'ArrowRight') nextTime = positionSec + stepSmallSec
|
|
1019
|
+
else if (e.key === 'PageDown') nextTime = positionSec - stepLargeSec
|
|
1020
|
+
else if (e.key === 'PageUp') nextTime = positionSec + stepLargeSec
|
|
1021
|
+
else if (e.key === 'Home') nextTime = 0
|
|
1022
|
+
else if (e.key === 'End') nextTime = durationSec
|
|
1023
|
+
else return
|
|
1024
|
+
|
|
1025
|
+
e.preventDefault()
|
|
1026
|
+
const clamped = Math.min(durationSec, Math.max(0, nextTime))
|
|
1027
|
+
onSeekBarPointer(clamped / durationSec)
|
|
1028
|
+
},
|
|
1029
|
+
[durationSec, onSeekBarPointer, positionSec],
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
const statusLabel = isScanning
|
|
1033
|
+
? 'Scanning…'
|
|
1034
|
+
: `${libraryTracks.length} in library · ${queue.length} in queue`
|
|
1035
|
+
|
|
1036
|
+
const queueRowGapClass = compactLists ? 'gap-2' : 'gap-3'
|
|
1037
|
+
const queueRowPadClass = compactLists ? 'px-3 py-2' : 'px-4 py-2.5'
|
|
1038
|
+
const queueRemoveButtonPadClass = compactLists ? 'px-1.5 py-1.5' : 'px-2 py-2'
|
|
1039
|
+
const emptyQueueCardGapClass = compactLists ? 'gap-2' : 'gap-3'
|
|
1040
|
+
const emptyQueueCardPadClass = compactLists ? 'p-2' : 'p-3'
|
|
1041
|
+
|
|
1042
|
+
return (
|
|
1043
|
+
<div className="flex h-full min-h-0 flex-1 flex-col bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
|
1044
|
+
<YouTubeStreamNotification visible={streamResolving} trackTitle={current?.title} />
|
|
1045
|
+
<audio ref={audioRef} className="hidden" preload="metadata" />
|
|
1046
|
+
<div
|
|
1047
|
+
ref={youtubeContainerRef}
|
|
1048
|
+
className="pointer-events-none fixed h-px w-px overflow-hidden opacity-0"
|
|
1049
|
+
aria-hidden
|
|
1050
|
+
tabIndex={-1}
|
|
1051
|
+
/>
|
|
1052
|
+
|
|
1053
|
+
<header className="flex shrink-0 items-center justify-between 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">
|
|
1054
|
+
<div className="flex items-center gap-3">
|
|
1055
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-700 ring-1 ring-amber-500/25 dark:text-amber-400 dark:ring-amber-500/30">
|
|
1056
|
+
<IconQueue className="h-5 w-5" />
|
|
1057
|
+
</div>
|
|
1058
|
+
<div>
|
|
1059
|
+
<h1 className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Muzical</h1>
|
|
1060
|
+
<p className="text-xs text-zinc-500">Local library · browser playback</p>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
<div className="flex items-center gap-2">
|
|
1064
|
+
<span className="hidden rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs text-zinc-500 shadow-sm sm:inline dark:border-zinc-700/80 dark:bg-zinc-900/80 dark:text-zinc-400 dark:shadow-none">
|
|
1065
|
+
{statusLabel}
|
|
1066
|
+
</span>
|
|
1067
|
+
<Link
|
|
1068
|
+
href="/settings"
|
|
1069
|
+
className="flex h-9 w-9 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"
|
|
1070
|
+
aria-label="Library settings"
|
|
1071
|
+
>
|
|
1072
|
+
<IconSettings className="h-[18px] w-[18px]" />
|
|
1073
|
+
</Link>
|
|
1074
|
+
<ThemeToggle />
|
|
1075
|
+
</div>
|
|
1076
|
+
</header>
|
|
1077
|
+
|
|
1078
|
+
{loadError ? (
|
|
1079
|
+
<p
|
|
1080
|
+
className="shrink-0 border-b border-red-200 bg-red-50 px-6 py-2 text-sm text-red-800 dark:border-red-900/40 dark:bg-red-950/30 dark:text-red-200"
|
|
1081
|
+
role="alert"
|
|
1082
|
+
>
|
|
1083
|
+
{loadError}
|
|
1084
|
+
</p>
|
|
1085
|
+
) : null}
|
|
1086
|
+
|
|
1087
|
+
<div
|
|
1088
|
+
ref={mainRowRef}
|
|
1089
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row"
|
|
1090
|
+
>
|
|
1091
|
+
<div
|
|
1092
|
+
className="flex min-h-0 min-w-0 flex-col overflow-hidden max-lg:flex-2 max-lg:w-full lg:h-full lg:min-w-0 lg:shrink-0"
|
|
1093
|
+
style={layoutLg ? { width: libraryPanelPx, flex: '0 0 auto' } : undefined}
|
|
1094
|
+
>
|
|
1095
|
+
<BrowsePanel />
|
|
1096
|
+
</div>
|
|
1097
|
+
<PanelResizeHandle
|
|
1098
|
+
aria-label="Resize library and queue panels"
|
|
1099
|
+
onSessionStart={onLibraryQueueResizeStart}
|
|
1100
|
+
onSessionMove={onLibraryQueueResizeMove}
|
|
1101
|
+
onSessionEnd={onPanelResizeEnd}
|
|
1102
|
+
/>
|
|
1103
|
+
<section
|
|
1104
|
+
className="flex min-h-0 min-w-0 flex-col overflow-hidden border-b border-zinc-200 bg-white dark:border-zinc-800/80 dark:bg-zinc-950/50 max-lg:flex-1 max-lg:w-full lg:h-full lg:shrink-0 lg:border-b-0 lg:border-r lg:border-zinc-200 lg:dark:border-zinc-800"
|
|
1105
|
+
style={layoutLg ? { width: queuePanelPx, flex: '0 0 auto' } : undefined}
|
|
1106
|
+
>
|
|
1107
|
+
{!isQueueReady ? (
|
|
1108
|
+
<QueueLoadingSpinner />
|
|
1109
|
+
) : (
|
|
1110
|
+
<>
|
|
1111
|
+
<div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-zinc-200 bg-white/80 px-3 dark:border-zinc-800 dark:bg-zinc-950/80">
|
|
1112
|
+
<h2 className="text-xs font-medium uppercase leading-none tracking-wider text-zinc-500">Queue</h2>
|
|
1113
|
+
{queue.length > 0 ? (
|
|
1114
|
+
<button
|
|
1115
|
+
type="button"
|
|
1116
|
+
onClick={() => {
|
|
1117
|
+
clearQueue()
|
|
1118
|
+
setActiveQueueId(null)
|
|
1119
|
+
setIsPlaying(false)
|
|
1120
|
+
setPositionSec(0)
|
|
1121
|
+
}}
|
|
1122
|
+
className="text-xs font-medium text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:hover:text-zinc-300"
|
|
1123
|
+
>
|
|
1124
|
+
Clear
|
|
1125
|
+
</button>
|
|
1126
|
+
) : null}
|
|
1127
|
+
</div>
|
|
1128
|
+
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
|
1129
|
+
{queue.length === 0 ? (
|
|
1130
|
+
<div className="px-4 py-6">
|
|
1131
|
+
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Queue is empty</p>
|
|
1132
|
+
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
|
1133
|
+
Add something to start playback.
|
|
1134
|
+
<Link
|
|
1135
|
+
href="/settings"
|
|
1136
|
+
className="ml-2 font-medium text-amber-700 underline-offset-2 hover:underline dark:text-amber-400"
|
|
1137
|
+
>
|
|
1138
|
+
Library folders
|
|
1139
|
+
</Link>
|
|
1140
|
+
</p>
|
|
1141
|
+
|
|
1142
|
+
{recentlyPlayedTracks.length > 0 ? (
|
|
1143
|
+
<div className="mt-5">
|
|
1144
|
+
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Recently played</p>
|
|
1145
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
1146
|
+
{recentlyPlayedTracks.map((t) => (
|
|
1147
|
+
<div
|
|
1148
|
+
key={t.id}
|
|
1149
|
+
className={`flex min-w-0 items-center ${emptyQueueCardGapClass} rounded-xl border border-zinc-200 bg-white ${emptyQueueCardPadClass} shadow-sm dark:border-zinc-800 dark:bg-zinc-950/40 dark:shadow-none`}
|
|
1150
|
+
>
|
|
1151
|
+
<div className="min-w-0 flex-1">
|
|
1152
|
+
<p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
|
|
1153
|
+
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
|
|
1154
|
+
{t.artist} · {t.album}
|
|
1155
|
+
</p>
|
|
1156
|
+
</div>
|
|
1157
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1158
|
+
<span className="text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
|
|
1159
|
+
{t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
|
|
1160
|
+
</span>
|
|
1161
|
+
<FavoriteStarButton
|
|
1162
|
+
className="rounded-full"
|
|
1163
|
+
filled={isFavoriteSong(t.id)}
|
|
1164
|
+
onPress={() => toggleFavoriteTrack(t)}
|
|
1165
|
+
label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
|
|
1166
|
+
/>
|
|
1167
|
+
<button
|
|
1168
|
+
type="button"
|
|
1169
|
+
onClick={() => addToQueue(t)}
|
|
1170
|
+
className="shrink-0 rounded-full bg-amber-500/15 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-500/25 transition hover:bg-amber-500/25 dark:text-amber-300 dark:ring-amber-500/40"
|
|
1171
|
+
>
|
|
1172
|
+
Add
|
|
1173
|
+
</button>
|
|
1174
|
+
</div>
|
|
1175
|
+
</div>
|
|
1176
|
+
))}
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
) : null}
|
|
1180
|
+
|
|
1181
|
+
{suggestedTracks.length > 0 ? (
|
|
1182
|
+
<div className="mt-6">
|
|
1183
|
+
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Suggestions</p>
|
|
1184
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
1185
|
+
{suggestedTracks.map((t) => (
|
|
1186
|
+
<div
|
|
1187
|
+
key={t.id}
|
|
1188
|
+
role="button"
|
|
1189
|
+
tabIndex={0}
|
|
1190
|
+
onClick={() => addToQueue(t)}
|
|
1191
|
+
onKeyDown={(e) => {
|
|
1192
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1193
|
+
e.preventDefault()
|
|
1194
|
+
addToQueue(t)
|
|
1195
|
+
}
|
|
1196
|
+
}}
|
|
1197
|
+
aria-label={`Add ${t.title} to queue`}
|
|
1198
|
+
className={`flex w-full min-w-0 cursor-pointer items-center text-left ${emptyQueueCardGapClass} rounded-xl border border-zinc-200 bg-white ${emptyQueueCardPadClass} shadow-sm transition hover:border-amber-400/50 hover:bg-amber-50/50 dark:border-zinc-800 dark:bg-zinc-950/40 dark:hover:border-amber-500/30 dark:hover:bg-amber-950/20 dark:shadow-none`}
|
|
1199
|
+
>
|
|
1200
|
+
<div className="min-w-0 flex-1">
|
|
1201
|
+
<p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{t.title}</p>
|
|
1202
|
+
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
|
|
1203
|
+
{t.artist} · {t.album}
|
|
1204
|
+
</p>
|
|
1205
|
+
</div>
|
|
1206
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1207
|
+
<span className="text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
|
|
1208
|
+
{t.durationSec > 0 ? formatDuration(t.durationSec) : '—'}
|
|
1209
|
+
</span>
|
|
1210
|
+
<FavoriteStarButton
|
|
1211
|
+
className="rounded-full"
|
|
1212
|
+
filled={isFavoriteSong(t.id)}
|
|
1213
|
+
onPress={() => toggleFavoriteTrack(t)}
|
|
1214
|
+
label={isFavoriteSong(t.id) ? 'Remove song from favorites' : 'Add song to favorites'}
|
|
1215
|
+
/>
|
|
1216
|
+
</div>
|
|
1217
|
+
</div>
|
|
1218
|
+
))}
|
|
1219
|
+
</div>
|
|
1220
|
+
</div>
|
|
1221
|
+
) : null}
|
|
1222
|
+
</div>
|
|
1223
|
+
) : (
|
|
1224
|
+
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800" role="listbox" aria-label="Track queue">
|
|
1225
|
+
{queue.map((row, index) => {
|
|
1226
|
+
const track = row.track
|
|
1227
|
+
const selected = index === activeIndex
|
|
1228
|
+
const isDropTarget = dragOverQueueId === row.queueId && draggingQueueId !== row.queueId
|
|
1229
|
+
return (
|
|
1230
|
+
<li
|
|
1231
|
+
key={row.queueId}
|
|
1232
|
+
className={[
|
|
1233
|
+
'group/row flex items-center gap-0',
|
|
1234
|
+
selected ? 'bg-amber-50/90 dark:bg-white/6' : '',
|
|
1235
|
+
isDropTarget ? 'ring-1 ring-amber-400/30' : '',
|
|
1236
|
+
].join(' ')}
|
|
1237
|
+
onDragOver={(e) => {
|
|
1238
|
+
if (!draggingQueueId) return
|
|
1239
|
+
e.preventDefault()
|
|
1240
|
+
if (dragOverQueueId !== row.queueId) setDragOverQueueId(row.queueId)
|
|
1241
|
+
}}
|
|
1242
|
+
onDrop={(e) => {
|
|
1243
|
+
e.preventDefault()
|
|
1244
|
+
const fromQueueId = draggingQueueId
|
|
1245
|
+
setDraggingQueueId(null)
|
|
1246
|
+
setDragOverQueueId(null)
|
|
1247
|
+
if (!fromQueueId) return
|
|
1248
|
+
if (fromQueueId === row.queueId) return
|
|
1249
|
+
const fromIndex = queue.findIndex((q) => q.queueId === fromQueueId)
|
|
1250
|
+
if (fromIndex < 0) return
|
|
1251
|
+
|
|
1252
|
+
const rect = (e.currentTarget as HTMLLIElement).getBoundingClientRect()
|
|
1253
|
+
const insertAfter = e.clientY > rect.top + rect.height / 2
|
|
1254
|
+
// Desired insertion index in the original list (before any splice shifting).
|
|
1255
|
+
const desiredInsertIndex = insertAfter ? index + 1 : index
|
|
1256
|
+
// Convert to insertion index after removal of the dragged item.
|
|
1257
|
+
const adjustedToIndex = fromIndex < desiredInsertIndex ? desiredInsertIndex - 1 : desiredInsertIndex
|
|
1258
|
+
reorderQueueItems(fromIndex, adjustedToIndex)
|
|
1259
|
+
}}
|
|
1260
|
+
>
|
|
1261
|
+
<button
|
|
1262
|
+
type="button"
|
|
1263
|
+
role="option"
|
|
1264
|
+
aria-selected={selected}
|
|
1265
|
+
onClick={() => selectIndex(index)}
|
|
1266
|
+
draggable
|
|
1267
|
+
onDragStart={(e) => {
|
|
1268
|
+
setDraggingQueueId(row.queueId)
|
|
1269
|
+
setDragOverQueueId(row.queueId)
|
|
1270
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
1271
|
+
e.dataTransfer.setData('text/plain', row.queueId)
|
|
1272
|
+
}}
|
|
1273
|
+
onDragEnd={() => {
|
|
1274
|
+
setDraggingQueueId(null)
|
|
1275
|
+
setDragOverQueueId(null)
|
|
1276
|
+
}}
|
|
1277
|
+
className={[
|
|
1278
|
+
`flex min-w-0 flex-1 items-center ${queueRowGapClass} border-l-2 border-transparent ${queueRowPadClass} text-left transition-colors`,
|
|
1279
|
+
selected
|
|
1280
|
+
? 'border-amber-500 dark:border-amber-400'
|
|
1281
|
+
: 'hover:bg-zinc-50 dark:hover:bg-zinc-900/60',
|
|
1282
|
+
'cursor-grab active:cursor-grabbing',
|
|
1283
|
+
isDropTarget ? 'border-amber-500/20 dark:border-amber-400/20' : '',
|
|
1284
|
+
].join(' ')}
|
|
1285
|
+
>
|
|
1286
|
+
<span className="w-5 shrink-0 text-right text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
|
|
1287
|
+
{index + 1}
|
|
1288
|
+
</span>
|
|
1289
|
+
<div className="min-w-0 flex-1">
|
|
1290
|
+
<p className="truncate text-sm font-medium leading-snug text-zinc-900 dark:text-zinc-100">
|
|
1291
|
+
{track.title}
|
|
1292
|
+
</p>
|
|
1293
|
+
<p className="truncate text-xs leading-snug text-zinc-500 dark:text-zinc-400">
|
|
1294
|
+
{track.artist} · {track.album}
|
|
1295
|
+
</p>
|
|
1296
|
+
</div>
|
|
1297
|
+
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
|
|
1298
|
+
{track.durationSec > 0 ? formatDuration(track.durationSec) : '—'}
|
|
1299
|
+
</span>
|
|
1300
|
+
</button>
|
|
1301
|
+
<div
|
|
1302
|
+
className={[
|
|
1303
|
+
'flex shrink-0 items-center border-l pr-1 pl-0.5',
|
|
1304
|
+
selected
|
|
1305
|
+
? 'border-amber-200/80 dark:border-white/8'
|
|
1306
|
+
: 'border-zinc-200 bg-zinc-50/80 dark:border-zinc-800 dark:bg-zinc-900/40',
|
|
1307
|
+
].join(' ')}
|
|
1308
|
+
>
|
|
1309
|
+
<FavoriteStarButton
|
|
1310
|
+
className="rounded-none"
|
|
1311
|
+
filled={isFavoriteSong(track.id)}
|
|
1312
|
+
onPress={() => toggleFavoriteTrack(track)}
|
|
1313
|
+
label={isFavoriteSong(track.id) ? 'Remove song from favorites' : 'Add song to favorites'}
|
|
1314
|
+
/>
|
|
1315
|
+
<button
|
|
1316
|
+
type="button"
|
|
1317
|
+
aria-label={`Remove ${track.title} from queue`}
|
|
1318
|
+
onClick={() => {
|
|
1319
|
+
const isCurrent = activeQueueId === row.queueId
|
|
1320
|
+
const nextId = isCurrent
|
|
1321
|
+
? (queue[index + 1]?.queueId ?? queue[index - 1]?.queueId ?? null)
|
|
1322
|
+
: activeQueueId
|
|
1323
|
+
removeFromQueue(row.queueId)
|
|
1324
|
+
setActiveQueueId(nextId)
|
|
1325
|
+
if (isCurrent && !nextId) {
|
|
1326
|
+
setIsPlaying(false)
|
|
1327
|
+
setPositionSec(0)
|
|
1328
|
+
}
|
|
1329
|
+
}}
|
|
1330
|
+
className={`shrink-0 ${queueRemoveButtonPadClass} text-[11px] text-zinc-500 opacity-80 transition hover:bg-zinc-200/80 hover:text-zinc-900 sm:opacity-0 sm:group-hover/row:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100`}
|
|
1331
|
+
>
|
|
1332
|
+
Remove
|
|
1333
|
+
</button>
|
|
1334
|
+
</div>
|
|
1335
|
+
</li>
|
|
1336
|
+
)
|
|
1337
|
+
})}
|
|
1338
|
+
</ul>
|
|
1339
|
+
)}
|
|
1340
|
+
</div>
|
|
1341
|
+
</>
|
|
1342
|
+
)}
|
|
1343
|
+
</section>
|
|
1344
|
+
<PanelResizeHandle
|
|
1345
|
+
aria-label="Resize queue and player panels"
|
|
1346
|
+
onSessionStart={onQueuePlayerResizeStart}
|
|
1347
|
+
onSessionMove={onQueuePlayerResizeMove}
|
|
1348
|
+
onSessionEnd={onPanelResizeEnd}
|
|
1349
|
+
/>
|
|
1350
|
+
<aside className="flex min-h-0 min-w-0 flex-1 flex-col gap-6 overflow-y-auto overflow-x-hidden bg-zinc-50 p-6 dark:bg-transparent lg:h-full lg:min-w-0 lg:flex-1">
|
|
1351
|
+
<div className="mx-auto flex w-full max-w-[280px] flex-col gap-4">
|
|
1352
|
+
<div
|
|
1353
|
+
className="relative aspect-square w-full overflow-hidden rounded-2xl bg-linear-to-br from-amber-200/90 via-zinc-100 to-zinc-200 ring-1 ring-zinc-300/70 shadow-xl shadow-zinc-400/20 dark:from-amber-900/40 dark:via-zinc-800 dark:to-zinc-900 dark:ring-zinc-700/60 dark:shadow-2xl dark:shadow-black/40"
|
|
1354
|
+
aria-hidden
|
|
1355
|
+
>
|
|
1356
|
+
{coverArtUrl ? (
|
|
1357
|
+
// Blob object URLs are not supported by next/image without a custom loader.
|
|
1358
|
+
// eslint-disable-next-line @next/next/no-img-element -- local object URL from tags
|
|
1359
|
+
<img
|
|
1360
|
+
src={coverArtUrl}
|
|
1361
|
+
alt=""
|
|
1362
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
1363
|
+
decoding="async"
|
|
1364
|
+
/>
|
|
1365
|
+
) : (
|
|
1366
|
+
<div className="flex h-full w-full items-center justify-center p-8">
|
|
1367
|
+
<span className="select-none text-6xl font-bold tracking-tighter text-amber-800/20 dark:text-zinc-700/90">
|
|
1368
|
+
{current?.album ? current.album.charAt(0) : '♪'}
|
|
1369
|
+
</span>
|
|
1370
|
+
</div>
|
|
1371
|
+
)}
|
|
1372
|
+
</div>
|
|
1373
|
+
<div className="text-center">
|
|
1374
|
+
<p className="truncate text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
|
1375
|
+
{current?.title ?? '—'}
|
|
1376
|
+
</p>
|
|
1377
|
+
<p className="mt-1 truncate text-sm text-zinc-600 dark:text-zinc-400">{current?.artist ?? ''}</p>
|
|
1378
|
+
<p className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-600">{current?.album ?? ''}</p>
|
|
1379
|
+
</div>
|
|
1380
|
+
</div>
|
|
1381
|
+
|
|
1382
|
+
<div className="mt-auto space-y-3">
|
|
1383
|
+
<div className="flex items-center justify-between text-xs tabular-nums text-zinc-500">
|
|
1384
|
+
<span>{formatDuration(positionSec)}</span>
|
|
1385
|
+
<span>{durationSec > 0 ? formatDuration(durationSec) : '—'}</span>
|
|
1386
|
+
</div>
|
|
1387
|
+
<div
|
|
1388
|
+
className="group relative h-2 cursor-pointer rounded-full bg-zinc-200 dark:bg-zinc-800"
|
|
1389
|
+
onPointerDown={(e) => {
|
|
1390
|
+
const el = e.currentTarget
|
|
1391
|
+
const rect = el.getBoundingClientRect()
|
|
1392
|
+
const ratio = (e.clientX - rect.left) / Math.max(1, rect.width)
|
|
1393
|
+
onSeekBarPointer(ratio)
|
|
1394
|
+
const move = (ev: PointerEvent): void => {
|
|
1395
|
+
const r = (ev.clientX - rect.left) / Math.max(1, rect.width)
|
|
1396
|
+
onSeekBarPointer(r)
|
|
1397
|
+
}
|
|
1398
|
+
const up = (): void => {
|
|
1399
|
+
window.removeEventListener('pointermove', move)
|
|
1400
|
+
window.removeEventListener('pointerup', up)
|
|
1401
|
+
}
|
|
1402
|
+
window.addEventListener('pointermove', move)
|
|
1403
|
+
window.addEventListener('pointerup', up)
|
|
1404
|
+
}}
|
|
1405
|
+
role="slider"
|
|
1406
|
+
tabIndex={0}
|
|
1407
|
+
onKeyDown={onSeekBarKeyDown}
|
|
1408
|
+
aria-valuemin={0}
|
|
1409
|
+
aria-valuemax={Math.round(durationSec)}
|
|
1410
|
+
aria-valuenow={Math.round(positionSec)}
|
|
1411
|
+
aria-label="Seek"
|
|
1412
|
+
>
|
|
1413
|
+
<div
|
|
1414
|
+
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-linear-to-r from-amber-600 to-amber-400"
|
|
1415
|
+
style={{
|
|
1416
|
+
width: `${
|
|
1417
|
+
durationSec > 0 ? (100 * positionSec) / durationSec : 0
|
|
1418
|
+
}%`,
|
|
1419
|
+
}}
|
|
1420
|
+
/>
|
|
1421
|
+
</div>
|
|
1422
|
+
|
|
1423
|
+
<div className="flex items-center justify-between gap-4 pt-1">
|
|
1424
|
+
<div className="flex items-center gap-1">
|
|
1425
|
+
<button
|
|
1426
|
+
type="button"
|
|
1427
|
+
aria-label="Previous track"
|
|
1428
|
+
onClick={() => goPrev()}
|
|
1429
|
+
disabled={queue.length === 0}
|
|
1430
|
+
className="rounded-full p-2.5 text-zinc-600 transition hover:bg-zinc-200 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
|
1431
|
+
>
|
|
1432
|
+
<IconSkipBack className="h-6 w-6" />
|
|
1433
|
+
</button>
|
|
1434
|
+
<button
|
|
1435
|
+
type="button"
|
|
1436
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
1437
|
+
onClick={() => setIsPlaying((p) => !p)}
|
|
1438
|
+
disabled={queue.length === 0 || !current}
|
|
1439
|
+
className="mx-1 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500 text-zinc-950 shadow-lg shadow-amber-600/25 transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40 dark:shadow-amber-900/30"
|
|
1440
|
+
>
|
|
1441
|
+
{isPlaying ? <IconPause className="h-7 w-7" /> : <IconPlay className="h-7 w-7 pl-0.5" />}
|
|
1442
|
+
</button>
|
|
1443
|
+
<button
|
|
1444
|
+
type="button"
|
|
1445
|
+
aria-label="Next track"
|
|
1446
|
+
onClick={() => goNext()}
|
|
1447
|
+
disabled={queue.length === 0}
|
|
1448
|
+
className="rounded-full p-2.5 text-zinc-600 transition hover:bg-zinc-200 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
|
1449
|
+
>
|
|
1450
|
+
<IconSkipForward className="h-6 w-6" />
|
|
1451
|
+
</button>
|
|
1452
|
+
</div>
|
|
1453
|
+
|
|
1454
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
1455
|
+
<IconVolume className="h-5 w-5 shrink-0 text-zinc-500" />
|
|
1456
|
+
<input
|
|
1457
|
+
type="range"
|
|
1458
|
+
min={0}
|
|
1459
|
+
max={1}
|
|
1460
|
+
step={0.01}
|
|
1461
|
+
value={volume}
|
|
1462
|
+
onChange={(e) => setVolume(Number(e.target.value))}
|
|
1463
|
+
className="h-1 w-full min-w-0 cursor-pointer accent-amber-500"
|
|
1464
|
+
aria-label="Volume"
|
|
1465
|
+
/>
|
|
1466
|
+
</div>
|
|
1467
|
+
</div>
|
|
1468
|
+
|
|
1469
|
+
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
|
1470
|
+
<button
|
|
1471
|
+
type="button"
|
|
1472
|
+
onClick={() => cycleRepeatMode()}
|
|
1473
|
+
className="relative flex h-9 w-9 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
|
1474
|
+
aria-label={
|
|
1475
|
+
repeatMode === 'off'
|
|
1476
|
+
? 'Repeat off. Click for repeat all.'
|
|
1477
|
+
: repeatMode === 'all'
|
|
1478
|
+
? 'Repeat all. Click for repeat one.'
|
|
1479
|
+
: 'Repeat one. Click for repeat off.'
|
|
1480
|
+
}
|
|
1481
|
+
>
|
|
1482
|
+
<IconRepeatLoop dimmed={repeatMode === 'off'} className="h-5 w-5" />
|
|
1483
|
+
{repeatMode === 'one' ? (
|
|
1484
|
+
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded bg-amber-500 px-0.5 text-[9px] font-bold leading-none text-zinc-950">
|
|
1485
|
+
1
|
|
1486
|
+
</span>
|
|
1487
|
+
) : null}
|
|
1488
|
+
</button>
|
|
1489
|
+
<button
|
|
1490
|
+
type="button"
|
|
1491
|
+
onClick={() => toggleShuffle()}
|
|
1492
|
+
aria-pressed={shuffle}
|
|
1493
|
+
aria-label={shuffle ? 'Shuffle on' : 'Shuffle off'}
|
|
1494
|
+
className={[
|
|
1495
|
+
'flex h-9 w-9 items-center justify-center rounded-full border transition',
|
|
1496
|
+
shuffle
|
|
1497
|
+
? 'border-amber-500/50 bg-amber-500/15 text-amber-800 dark:text-amber-300'
|
|
1498
|
+
: 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-100',
|
|
1499
|
+
].join(' ')}
|
|
1500
|
+
>
|
|
1501
|
+
<IconShuffle className="h-5 w-5" />
|
|
1502
|
+
</button>
|
|
1503
|
+
<label className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
|
|
1504
|
+
<span className="sr-only">Playback speed</span>
|
|
1505
|
+
<span aria-hidden className="tabular-nums">
|
|
1506
|
+
Speed
|
|
1507
|
+
</span>
|
|
1508
|
+
<select
|
|
1509
|
+
value={playbackRate}
|
|
1510
|
+
onChange={(e) => {
|
|
1511
|
+
const v = Number(e.target.value)
|
|
1512
|
+
setPlaybackRate(v)
|
|
1513
|
+
persistPlaybackRate(v)
|
|
1514
|
+
}}
|
|
1515
|
+
className="cursor-pointer rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-800 outline-none ring-amber-500/0 transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
|
1516
|
+
aria-label="Playback speed"
|
|
1517
|
+
>
|
|
1518
|
+
{PLAYBACK_RATES.map((r) => (
|
|
1519
|
+
<option key={r} value={r}>
|
|
1520
|
+
{r === 1 ? '1×' : `${r}×`}
|
|
1521
|
+
</option>
|
|
1522
|
+
))}
|
|
1523
|
+
</select>
|
|
1524
|
+
</label>
|
|
1525
|
+
</div>
|
|
1526
|
+
</div>
|
|
1527
|
+
</aside>
|
|
1528
|
+
</div>
|
|
1529
|
+
</div>
|
|
1530
|
+
)
|
|
1531
|
+
}
|