Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/AddToPlaylistSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function AddToPlaylistSheet() {
<button className="sheet__item" onClick={() => { addToQueue(addTarget); close() }}>
<span>Add to queue</span><Glyph d="M3 5h10M3 9h10M3 13h6M14 11v6M14 17l3-2M14 17l-3-2" />
</button>
<button className="sheet__item" onClick={() => { toggleStar(addTarget.id); close() }}>
<button className="sheet__item" onClick={() => { toggleStar(addTarget.id).catch(() => {}); close() }}>
<span>{addTarget.starred ? 'Remove from favorites' : 'Add to favorites'}</span>
<Glyph filled={!!addTarget.starred} d="M9 1.5l2.2 4.5 5 .7-3.6 3.5.85 5L9 12.9 4.7 15.2l.85-5L2 6.7l5-.7z" />
</button>
Expand Down
3 changes: 2 additions & 1 deletion src/components/LibraryView.jsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -35,7 +36,7 @@ export default function LibraryView() {
<button className="btn btn--accent playall" onClick={() => playQueue(shown, 0)}>
<PlayGlyph /> Play
</button>
<button className="btn btn--ghost playall" onClick={() => playQueue([...shown].sort(() => Math.random() - 0.5), 0)}>
<button className="btn btn--ghost playall" onClick={() => playQueue(shuffle(shown), 0)}>
<ShuffleGlyph /> Shuffle
</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Player.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function NowPlaying({ p, onClose }) {
</div>
<button
className={`iconbtn ${starred ? 'iconbtn--star-on' : ''}`}
onClick={() => toggleStar(p.current.id)}
onClick={() => toggleStar(p.current.id).catch(() => {})}
aria-label="Favorite"
>
<StarIcon filled={!!starred} />
Expand Down
3 changes: 2 additions & 1 deletion src/components/PlaylistsView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<>
Expand Down
15 changes: 10 additions & 5 deletions src/components/SearchView.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -70,15 +75,15 @@ export default function SearchView() {
<YouTubeLinkCard yt={yt} copied={autoCopied} />
) : tracks === undefined ? (
<p className="dim">Loading…</p>
) : q && results.length > 0 ? (
) : dq && results.length > 0 ? (
<div className="list">
{results.map((t) => (
<TrackRow key={t.id} track={t} list={results} />
))}
</div>
) : (
<p className="dim searchhint">
{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.'}
</p>
)}
</section>
Expand Down
2 changes: 1 addition & 1 deletion src/components/TrackRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function TrackRow({ track, list }) {

<button
className={`iconbtn ${track.starred ? 'iconbtn--star-on' : ''}`}
onClick={() => toggleStar(track.id)}
onClick={() => toggleStar(track.id).catch(() => {})}
aria-label={track.starred ? 'Unfavorite' : 'Favorite'}
>
<StarIcon filled={!!track.starred} />
Expand Down
10 changes: 6 additions & 4 deletions src/lib/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
11 changes: 11 additions & 0 deletions src/lib/shuffle.js
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
Expand Down
10 changes: 8 additions & 2 deletions src/state/PlayerProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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' }]
: [],
})

Expand Down
Loading