diff --git a/src/components/AddToPlaylistSheet.jsx b/src/components/AddToPlaylistSheet.jsx index 97867df..4d50cf2 100644 --- a/src/components/AddToPlaylistSheet.jsx +++ b/src/components/AddToPlaylistSheet.jsx @@ -47,7 +47,7 @@ export default function AddToPlaylistSheet() { { addToQueue(addTarget); close() }}> Add to queue - { toggleStar(addTarget.id); close() }}> + { toggleStar(addTarget.id).catch(() => {}); close() }}> {addTarget.starred ? 'Remove from favorites' : 'Add to favorites'} diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index 6aa47d9..37f6df4 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTracks } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' +import { shuffle } from '../lib/shuffle' import TrackRow from './TrackRow' import ImportButton from './ImportButton' @@ -35,7 +36,7 @@ export default function LibraryView() { playQueue(shown, 0)}> Play - playQueue([...shown].sort(() => Math.random() - 0.5), 0)}> + playQueue(shuffle(shown), 0)}> Shuffle diff --git a/src/components/Player.jsx b/src/components/Player.jsx index c3adc54..6bf58e5 100644 --- a/src/components/Player.jsx +++ b/src/components/Player.jsx @@ -89,7 +89,7 @@ function NowPlaying({ p, onClose }) { toggleStar(p.current.id)} + onClick={() => toggleStar(p.current.id).catch(() => {})} aria-label="Favorite" > diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index dbcc421..516799d 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -4,6 +4,7 @@ import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist } fr import { usePlayer } from '../state/PlayerProvider' import { useLongPress } from '../lib/useLongPress' import { summarize } from '../lib/format' +import { shuffle } from '../lib/shuffle' import TrackRow from './TrackRow' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' @@ -116,7 +117,7 @@ function PlaylistDetail({ playlist, onBack, onActions }) { // Resolve ids → track objects in saved order, dropping any since-deleted ids. const byId = new Map(allTracks.map((t) => [t.id, t])) const tracks = playlist.trackIds.map((id) => byId.get(id)).filter(Boolean) - const shuffled = () => [...tracks].sort(() => Math.random() - 0.5) + const shuffled = () => shuffle(tracks) return ( <> diff --git a/src/components/SearchView.jsx b/src/components/SearchView.jsx index 6adc403..7a59ffc 100644 --- a/src/components/SearchView.jsx +++ b/src/components/SearchView.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useDeferredValue } from 'react' import { useTracks, useSearch } from '../state/useLibrary' import { parseYouTube, buildYtDlpCommand } from '../lib/youtube' import TrackRow from './TrackRow' @@ -8,8 +8,13 @@ export default function SearchView() { const [query, setQuery] = useState('') const [autoCopied, setAutoCopied] = useState(false) const tracks = useTracks() - const results = useSearch(tracks, query) - const q = query.trim() + // Defer the query fed to the (synchronous) fuzzy search so fast typing over a + // large library doesn't drop input frames — React can skip intermediate list + // renders and catch up when idle. + const deferredQuery = useDeferredValue(query) + const results = useSearch(tracks, deferredQuery) + const q = query.trim() // immediate — drives the clear button + const dq = deferredQuery.trim() // matches `results`, so the list/hint stay consistent const yt = parseYouTube(query) // non-null when a YouTube link is pasted // One tap: read the link from the clipboard AND copy the a-Shell command back, @@ -70,7 +75,7 @@ export default function SearchView() { ) : tracks === undefined ? ( Loading… - ) : q && results.length > 0 ? ( + ) : dq && results.length > 0 ? ( {results.map((t) => ( @@ -78,7 +83,7 @@ export default function SearchView() { ) : ( - {q ? `Nothing matches “${q}”.` : 'Tap the paste icon to drop in a YouTube link, or type to search your library.'} + {dq ? `Nothing matches “${dq}”.` : 'Tap the paste icon to drop in a YouTube link, or type to search your library.'} )} diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index f2ddafe..e68388a 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -26,7 +26,7 @@ export default function TrackRow({ track, list }) { toggleStar(track.id)} + onClick={() => toggleStar(track.id).catch(() => {})} aria-label={track.starred ? 'Unfavorite' : 'Favorite'} > diff --git a/src/lib/format.js b/src/lib/format.js index 2c2d93e..4555e9d 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -8,11 +8,13 @@ export function summarize(tracks) { return `${count} · ${dur}` } -/** 73 → "1:13", 605 → "10:05". Handles null/NaN gracefully. */ +/** 73 → "1:13", 605 → "10:05", 3661 → "1:01:01". Handles null/NaN gracefully. */ export function formatTime(sec) { if (sec == null || Number.isNaN(sec)) return '0:00' const s = Math.max(0, Math.floor(sec)) - const m = Math.floor(s / 60) - const r = s % 60 - return `${m}:${r.toString().padStart(2, '0')}` + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const r = (s % 60).toString().padStart(2, '0') + if (h) return `${h}:${m.toString().padStart(2, '0')}:${r}` + return `${m}:${r}` } diff --git a/src/lib/shuffle.js b/src/lib/shuffle.js new file mode 100644 index 0000000..48ab837 --- /dev/null +++ b/src/lib/shuffle.js @@ -0,0 +1,11 @@ +// Uniform Fisher–Yates shuffle. Returns a new array; does not mutate the input. +// `[...a].sort(() => Math.random() - 0.5)` is NOT uniform — the comparator is +// inconsistent between calls, so some orderings are far more likely than others. +export function shuffle(list) { + const a = [...list] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} diff --git a/src/main.jsx b/src/main.jsx index b3f3f0b..46eb46c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,8 +8,8 @@ import { seedIfEmpty, removeSamples } from './lib/seed' // In dev we seed sample tones to develop against; in the real (prod) app there // are no samples — and we clean up any that were seeded by earlier builds. -if (import.meta.env.DEV) seedIfEmpty() -else removeSamples() +if (import.meta.env.DEV) seedIfEmpty().catch(() => {}) +else removeSamples().catch(() => {}) ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index f4a5e62..20db91c 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -139,6 +139,12 @@ export function PlayerProvider({ children }) { if (audioRef.current) audioRef.current.loop = loopMode === 'one' }, [loopMode]) + // Revoke the last object URL when the provider unmounts (the per-track effect + // only revokes the PREVIOUS one on the next load). + useEffect(() => () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + }, []) + // Resume playback. Idempotent (play() while already playing is a no-op), so // it can never invert iOS's own control of the element — this is what makes // AirPods/lock-screen resume reliable. On reject we sync isPlaying=false so @@ -212,7 +218,7 @@ export function PlayerProvider({ children }) { // track the element is actually loaded with, not a since-changed `current`. if (!countedRef.current && loadedIdRef.current && a.currentTime >= Math.min(5, (a.duration || 10) * 0.5)) { countedRef.current = true - bumpPlayCount(loadedIdRef.current) + bumpPlayCount(loadedIdRef.current).catch(() => {}) } // Feed the lock-screen scrubber on iOS. if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && a.duration && Number.isFinite(a.duration)) { @@ -268,7 +274,7 @@ export function PlayerProvider({ children }) { title: current.title || 'Unknown', artist: current.artist || '', artwork: current.thumbnailUrl - ? [{ src: current.thumbnailUrl, sizes: '512x512', type: 'image/png' }] + ? [{ src: current.thumbnailUrl, sizes: 'any', type: 'image/jpeg' }] : [], })
Loading…
- {q ? `Nothing matches “${q}”.` : 'Tap the paste icon to drop in a YouTube link, or type to search your library.'} + {dq ? `Nothing matches “${dq}”.` : 'Tap the paste icon to drop in a YouTube link, or type to search your library.'}