From d0d95653463460328834b154e2f84c1d3ad40163 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:10:08 +0000 Subject: [PATCH 1/9] feat(pwa): keep the installed app updating itself in place The app is configured with registerType:'autoUpdate', but on iOS a standalone PWA is frozen and resumed rather than reloaded, so Safari's built-in update check rarely fires and the app looks stale until it's removed and re-added from the home screen. That workaround is also risky: imported songs/playlists live in IndexedDB, which iOS can drop when the home-screen icon is deleted. - Register the service worker via virtual:pwa-register and call registration.update() on a timer and on every foreground resume, so a resumed iOS app picks up new builds without a reinstall. - Request persistent storage at startup (not just on import) so the library is far less likely to be evicted while the app sits unused. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL --- src/lib/pwa.js | 34 ++++++++++++++++++++++++++++++++++ src/main.jsx | 12 ++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/lib/pwa.js diff --git a/src/lib/pwa.js b/src/lib/pwa.js new file mode 100644 index 0000000..51e2798 --- /dev/null +++ b/src/lib/pwa.js @@ -0,0 +1,34 @@ +import { registerSW } from 'virtual:pwa-register' + +// How often to re-check the server for a fresh build while the app stays open. +const UPDATE_INTERVAL_MS = 60 * 60 * 1000 // 1h + +// Registers the service worker and — the important part — keeps nudging it to +// look for new builds. +// +// vite.config.js uses registerType:'autoUpdate', so once a new build is found +// the worker activates and reloads the page on its own; the user never has to +// remove/re-add the home-screen icon. The catch on iOS: a standalone PWA is +// FROZEN and later RESUMED rather than genuinely reloaded, so Safari's built-in +// update check frequently never fires and the app looks stale for days. We work +// around that by explicitly calling registration.update() on a timer while the +// app is open AND every time it returns to the foreground — which is exactly the +// moment an iOS user reopens Melody expecting the latest version. +export function registerServiceWorker() { + return registerSW({ + immediate: true, + onRegisteredSW(_swScriptUrl, registration) { + if (!registration) return + + const check = () => registration.update().catch(() => {}) + + // Periodic check for long-lived sessions (e.g. left open playing music). + setInterval(check, UPDATE_INTERVAL_MS) + + // The one that matters on iOS: re-check the instant the app is resumed. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') check() + }) + }, + }) +} diff --git a/src/main.jsx b/src/main.jsx index 46eb46c..6bf59f8 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -5,12 +5,24 @@ import '@fontsource-variable/bricolage-grotesque' import App from './App.jsx' import './index.css' import { seedIfEmpty, removeSamples } from './lib/seed' +import { requestPersistentStorage } from './lib/db' +import { registerServiceWorker } from './lib/pwa' // 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().catch(() => {}) else removeSamples().catch(() => {}) +// Ask iOS to mark our storage durable at startup (not just on import), so the +// imported songs/playlists/stars in IndexedDB are far less likely to be evicted +// while the app sits unused. This is what makes removing/re-adding the icon +// unnecessary — and unsafe, since on iOS that can drop the origin's storage. +requestPersistentStorage().catch(() => {}) + +// Register the service worker and keep it checking for new deploys, so the app +// updates itself in place (see pwa.js) instead of needing a home-screen reinstall. +registerServiceWorker() + ReactDOM.createRoot(document.getElementById('root')).render( From 40d155d300af42805e622f914ce684fa6d949e14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:14:52 +0000 Subject: [PATCH 2/9] feat: hidden build/version readout via long-press on the tab bar Long-press anywhere on the tab bar to peek at which deploy is running (short commit hash + build timestamp, stamped at build time via a Vite define). The pill auto-hides after 5s and never appears in normal use, so the UI stays clean while still making it possible to confirm that the PWA's in-place auto-update actually landed a new build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL --- src/App.jsx | 33 ++++++++++++++++++++++++++++++--- src/index.css | 20 ++++++++++++++++++++ vite.config.js | 16 ++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e1cff44..4917551 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { PlayerProvider } from './state/PlayerProvider' import { UIProvider } from './state/UIProvider' +import { useLongPress } from './lib/useLongPress' import SearchView from './components/SearchView' import LibraryView from './components/LibraryView' import PlaylistsView from './components/PlaylistsView' @@ -13,8 +14,28 @@ const TABS = [ { id: 'playlists', label: 'Playlists', icon: PlaylistIcon }, ] +// Hidden build readout: invisible in normal use; long-press anywhere on the +// tab bar to peek at which deploy is running (commit + build time), so you can +// confirm the PWA auto-updated without a version string cluttering the UI. +// Auto-hides; tapping it dismisses it immediately. +function VersionPeek({ onDismiss }) { + useEffect(() => { + const t = setTimeout(onDismiss, 5000) + return () => clearTimeout(t) + }, [onDismiss]) + const { commit, builtAt } = __BUILD_INFO__ + const built = new Date(builtAt) + return ( + + ) +} + export default function App() { const [tab, setTab] = useState('library') + const [showVersion, setShowVersion] = useState(false) + const { handlers: versionPress, suppressClick } = useLongPress(() => setShowVersion(true)) return ( @@ -28,13 +49,19 @@ export default function App() { {/* Floating dock: mini-player card stacked above the tab bar */}
+ {showVersion && setShowVersion(false)} />} -
)} + + {/* Quiet backup corner. The file input lives here (not in App) so the + Library tab keeps exactly one file input for the test harnesses. */} +
+ + +
+ + {backupNote && ( +

+ {backupNote} +

+ )} )} diff --git a/src/index.css b/src/index.css index dd53bed..de02863 100644 --- a/src/index.css +++ b/src/index.css @@ -277,6 +277,16 @@ body { .plrow { display: flex; align-items: center; } .plrow .row { flex: 1; } +/* Quiet backup pills under the playlist list — same recipe as + .lyrics__research, deliberately dim so they read as an afterthought + next to + New rather than a feature. */ +.backuprow { display: flex; gap: 8px; margin-top: 26px; } +.backupbtn { + background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; + color: var(--text-dim); font-family: var(--font); font-size: 12px; font-weight: 600; + padding: 8px 16px; cursor: pointer; +} + /* ── bottom sheet ────────────────────────────────────────────── */ .sheet-overlay { position: fixed; inset: 0; z-index: 60; diff --git a/src/lib/db.js b/src/lib/db.js index 7c9bed3..9cf2278 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -127,7 +127,18 @@ export async function addLocalTrack({ title, artist, duration, blob, thumbnailUr ) .first() if (dup) { - result = { id: dup.id, duplicate: true } + // A restored backup row carries metadata only — no audioBlobs entry. When + // the user re-imports the same file, the dedup match lands here; attach + // the bytes to the EXISTING id so playlists and stars keep pointing at + // it, and report duplicate:false so the import summary counts it as + // added (which, from the user's point of view, it just was). + const hasBlob = await db.audioBlobs.get(dup.id) + if (hasBlob) { + result = { id: dup.id, duplicate: true } + } else { + await db.audioBlobs.add({ id: dup.id, blob }) + result = { id: dup.id, duplicate: false } + } return } @@ -173,6 +184,98 @@ export async function deleteTrack(trackId) { }) } +// ── Backup / restore ──────────────────────────────────────────────────────── +// A backup is the catalog + playlists as plain JSON. Audio Blobs live only in +// the separate audioBlobs store, so track rows serialize as-is; lyrics are +// skipped because ensureLyrics re-fetches them lazily. Rows are exported +// VERBATIM — `duration` especially is the exact float readDuration produced +// and one leg of addLocalTrack's dedup triple-match, so rounding it would +// break blob relink after a restore. + +export async function exportBackup() { + return { + schema: 3, + exportedAt: new Date().toISOString(), + tracks: await db.tracks.toArray(), + playlists: await db.playlists.toArray(), + } +} + +/** + * Merge a backup produced by exportBackup() into the live database. Tracks + * merge by id (the live row keeps its catalog metadata, user state combines); + * playlists merge by name, appending unseen track ids. Restored tracks have no + * audio bytes yet — re-importing the same files reattaches them via the dedup + * match in addLocalTrack. Returns row counts for the status message. + */ +export async function importBackup(data) { + // Validate before the transaction opens: awaiting any non-Dexie promise + // inside the transaction zone would kill it, so everything here — and the + // JSON parsing in the UI handler — stays outside. + if (!data || !Array.isArray(data.tracks) || !Array.isArray(data.playlists)) { + throw new Error('invalid-backup') + } + const trackRows = data.tracks.filter((t) => t && typeof t.id === 'string') + const playlistRows = data.playlists.filter((p) => p && typeof p.name === 'string') + + await db.transaction('rw', db.tracks, db.playlists, async () => { + for (const row of trackRows) { + const existing = await db.tracks.get(row.id) + if (existing) { + // Merge only user state — the live row's catalog metadata wins, same + // ownership split as upsertCatalog, just from the other side. + const lastPlayedAt = + existing.lastPlayedAt == null && row.lastPlayedAt == null + ? null + : Math.max(existing.lastPlayedAt ?? -Infinity, row.lastPlayedAt ?? -Infinity) + await db.tracks.update(row.id, { + playCount: Math.max(existing.playCount || 0, row.playCount || 0), + starred: (existing.starred || row.starred) ? 1 : 0, // 0/1 — Dexie can't index booleans + lastPlayedAt, + dateAdded: Math.min(existing.dateAdded ?? Date.now(), row.dateAdded ?? Date.now()), + }) + } else { + // The restored row keeps its original id and srcType ('idb' for + // imported tracks) — that is what lets a later re-import of the same + // file relink its bytes to this exact row. Until then the player shows + // its graceful "Audio unavailable" state. + await db.tracks.add({ + starred: 0, + playCount: 0, + lastPlayedAt: null, + dateAdded: Date.now(), + ...row, + starred: row.starred ? 1 : 0, + }) + } + } + + for (const pl of playlistRows) { + const existing = await db.playlists.where('name').equals(pl.name).first() + if (existing) { + // Append restored ids the playlist doesn't have yet, atomically (see + // addToPlaylist). Dangling ids are fine — PlaylistDetail filters them. + await db.playlists.where('id').equals(existing.id).modify((p) => { + for (const tid of pl.trackIds || []) { + if (!p.trackIds.includes(tid)) p.trackIds.push(tid) + } + }) + } else { + // NEVER restore the old id: playlist ids are ++id auto-increment + // numbers, and inserting a foreign id collides with or corrupts the + // counter. Let Dexie assign a fresh one. + await db.playlists.add({ + name: pl.name, + trackIds: [...(pl.trackIds || [])], + createdAt: pl.createdAt ?? Date.now(), + }) + } + } + }) + + return { tracks: trackRows.length, playlists: playlistRows.length } +} + // ── Playlist mutations ────────────────────────────────────────────────────── export async function createPlaylist(name) { return db.playlists.add({ name: name.trim() || 'Untitled', trackIds: [], createdAt: Date.now() }) From 18222c952804c045c8ccf79f6f1edabd5c163d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:18:41 +0000 Subject: [PATCH 7/9] feat: library sort + counts, empty state, playlist mosaics, dock toast UI polish pass over issue #39: - format.js gains formatTotalDuration ('2 hr 51 min' / '48 min' / '42 sec', '' for empty) so views can append durations to song counts; summarize and formatTime are untouched. - Library gets a Recent / Most played / A-Z sort segmented control persisted in localStorage (melody:librarySort, try/catch for private-mode Safari, pure lazy initializer for StrictMode), a song-count + duration meta line under the title, and a centered empty state with an icon and a second ImportButton CTA (only mounted while the library is empty, so the test harnesses' strict file-input locator still resolves on seeded dev). - Playlist cards render a 2x2 artwork mosaic (Artwork's gradient fallback covers empty cells) plus a resolved song count and total duration, filtering dangling track ids the same way PlaylistDetail does. - New toast in UIProvider/App replaces both inline importnote renderings (import summaries and backup-restore status) with a transient pill absolutely positioned inside the dock, keyed by id so repeat messages restart the 4s timer; the unused .importnote css rule is removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL --- src/App.jsx | 26 ++++++++++- src/components/ImportButton.jsx | 19 +++----- src/components/LibraryView.jsx | 76 +++++++++++++++++++++++++++++--- src/components/PlaylistsView.jsx | 51 +++++++++++++++------ src/index.css | 48 ++++++++++++++++++-- src/lib/format.js | 17 +++++++ src/state/UIProvider.jsx | 10 ++++- 7 files changed, 210 insertions(+), 37 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 4917551..80176a0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { PlayerProvider } from './state/PlayerProvider' -import { UIProvider } from './state/UIProvider' +import { UIProvider, useUI } from './state/UIProvider' import { useLongPress } from './lib/useLongPress' import SearchView from './components/SearchView' import LibraryView from './components/LibraryView' @@ -32,6 +32,29 @@ function VersionPeek({ onDismiss }) { ) } +// Transient status pill above the dock (import summaries, restore results). +// Same lifecycle as VersionPeek: auto-hides, tap dismisses. Keyed by toast id +// in ToastHost so a repeat of the same message restarts the timer. +function Toast({ message, onDismiss }) { + useEffect(() => { + const t = setTimeout(onDismiss, 4000) + return () => clearTimeout(t) + }, [onDismiss]) + return ( + + ) +} + +// Rendered inside the dock, above VersionPeek. Must stay conditional — a +// permanently-mounted element before the tabbar would break the +// .dock > .tabbar:first-child divider rule. +function ToastHost() { + const { toast, clearToast } = useUI() + return toast && +} + export default function App() { const [tab, setTab] = useState('library') const [showVersion, setShowVersion] = useState(false) @@ -50,6 +73,7 @@ export default function App() { {/* Floating dock: mini-player card stacked above the tab bar */}
{showVersion && setShowVersion(false)} />} + {/* Long-press handlers live on the nav; suppressClick keeps the long-press from also switching tabs on release. */} diff --git a/src/components/ImportButton.jsx b/src/components/ImportButton.jsx index 1a9df2c..7abd0d8 100644 --- a/src/components/ImportButton.jsx +++ b/src/components/ImportButton.jsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import { addLocalTrack, requestPersistentStorage, storageEstimate } from '../lib/db' +import { useUI } from '../state/UIProvider' import { readDuration, parseFilename } from '../lib/audio' import { ensureLyrics } from '../lib/lyrics' import { fetchYouTubePreview } from '../lib/youtube' @@ -10,13 +11,12 @@ import { fetchYouTubePreview } from '../lib/youtube' export default function ImportButton() { const inputRef = useRef(null) const [remaining, setRemaining] = useState(0) - const [notice, setNotice] = useState(null) // user-facing result/errors + const { showToast } = useUI() // results surface in the app-wide toast const onPick = async (e) => { const picked = [...e.target.files] e.target.value = '' // reset so the same file can be re-picked later if (!picked.length) return - setNotice(null) // Select-All in the a-Shell folder sweeps in .json/.txt sidecars alongside // the audio — the accept attr is advisory only on iOS, so filter here, @@ -25,7 +25,7 @@ export default function ImportButton() { (f) => f.type.startsWith('audio/') || /\.(m4a|mp3|aac|wav|flac|ogg)$/i.test(f.name) ) if (!files.length) { - setNotice('No new songs — everything already in your library.') + showToast('No new songs — everything already in your library.') return } @@ -37,7 +37,7 @@ export default function ImportButton() { const totalBytes = files.reduce((n, f) => n + (f.size || 0), 0) const est = await storageEstimate() if (est?.quota && est.usage + totalBytes > est.quota * 0.95) { - setNotice("Not enough storage to import these. Free up space and try again.") + showToast("Not enough storage to import these. Free up space and try again.") return } @@ -89,14 +89,14 @@ export default function ImportButton() { // Always leave a summary — a re-pick of the whole folder is a sync, and // "nothing happened" must still be an answer, never a silent no-op. if (outOfSpace) { - setNotice("Ran out of storage — not all tracks were imported.") + showToast("Ran out of storage — not all tracks were imported.") } else if (added === 0 && failed === 0) { - setNotice('No new songs — everything already in your library.') + showToast('No new songs — everything already in your library.') } else { const parts = [`${added} added`] if (skipped) parts.push(`${skipped} already in your library`) if (failed) parts.push(`${failed} couldn't be imported`) - setNotice(parts.join(' · ')) + showToast(parts.join(' · ')) } } @@ -119,11 +119,6 @@ export default function ImportButton() { hidden onChange={onPick} /> - {notice && ( -

- {notice} -

- )} ) } diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index 37f6df4..fac303e 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -2,17 +2,57 @@ import { useState } from 'react' import { useTracks } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' import { shuffle } from '../lib/shuffle' +import { formatTotalDuration } from '../lib/format' import TrackRow from './TrackRow' import ImportButton from './ImportButton' +const SORT_KEY = 'melody:librarySort' +const SORTS = [ + { value: 'recent', label: 'Recent' }, + { value: 'plays', label: 'Most played' }, + { value: 'alpha', label: 'A–Z' }, +] + +// Reads must be try/catch-wrapped (private-mode Safari throws) and live in a +// pure lazy initializer so StrictMode's double render stays side-effect free. +function readSavedSort() { + try { + const v = localStorage.getItem(SORT_KEY) + return v === 'plays' || v === 'alpha' ? v : 'recent' + } catch { + return 'recent' + } +} + export default function LibraryView() { const tracks = useTracks() const { playQueue } = usePlayer() const [showStarred, setShowStarred] = useState(false) + const [sort, setSort] = useState(readSavedSort) + + const pickSort = (value) => { + setSort(value) + try { + localStorage.setItem(SORT_KEY, value) + } catch { + // Private-mode Safari: the choice still applies for this session. + } + } if (tracks === undefined) return

Loading…

- const shown = showStarred ? tracks.filter((t) => t.starred) : tracks + const filtered = showStarred ? tracks.filter((t) => t.starred) : tracks + // useTracks already returns dateAdded-desc, so 'recent' is the array as-is. + // The others must copy first — live-query arrays must never be sorted in place. + const shown = + sort === 'plays' + ? [...filtered].sort((a, b) => (b.playCount || 0) - (a.playCount || 0) || b.dateAdded - a.dateAdded) + : sort === 'alpha' + ? [...filtered].sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })) + : filtered + + const total = tracks.reduce((s, t) => s + (t.duration || 0), 0) + const dur = formatTotalDuration(total) return (
@@ -21,12 +61,23 @@ export default function LibraryView() {

Your music

Library

+

+ {tracks.length} {tracks.length === 1 ? 'song' : 'songs'} + {dur && ` · ${dur}`} +

-
- - +
+
+ + +
+
+ {SORTS.map(({ value, label }) => ( + + ))} +
@@ -46,8 +97,14 @@ export default function LibraryView() { ))} + ) : showStarred ? ( +

No favorites yet — tap the star on any track.

) : ( -

{showStarred ? 'No favorites yet — tap the star on any track.' : 'Your library is empty.'}

+
+ +

Nothing here yet. Import songs from the Files app to build your library.

+ +
)} ) @@ -59,3 +116,12 @@ function PlayGlyph() { function ShuffleGlyph() { return } +function NotesGlyph() { + return ( + + ) +} diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 8ff23eb..754b5dd 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -2,9 +2,11 @@ import { useRef, useState } from 'react' import { usePlaylists, useTracks } from '../state/useLibrary' import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist, exportBackup, importBackup } from '../lib/db' import { usePlayer } from '../state/PlayerProvider' +import { useUI } from '../state/UIProvider' import { useLongPress } from '../lib/useLongPress' -import { summarize } from '../lib/format' +import { summarize, formatTotalDuration } from '../lib/format' import { shuffle } from '../lib/shuffle' +import Artwork from './Artwork' import TrackRow from './TrackRow' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' @@ -12,12 +14,13 @@ import PlaylistActionsSheet from './PlaylistActionsSheet' export default function PlaylistsView() { const playlists = usePlaylists() + const allTracks = useTracks() // resolves each card's mosaic + duration meta + const { showToast } = useUI() const [openId, setOpenId] = useState(null) const [creating, setCreating] = useState(false) const [actionsFor, setActionsFor] = useState(null) // playlist in the ⋯ sheet const [renaming, setRenaming] = useState(null) const [deleting, setDeleting] = useState(null) - const [backupNote, setBackupNote] = useState(null) // restore result / error const restoreRef = useRef(null) const open = playlists?.find((p) => p.id === openId) @@ -49,15 +52,19 @@ export default function PlaylistsView() { // file.text() inside the transaction zone would kill it. const parsed = JSON.parse(await file.text()) const { tracks, playlists: pls } = await importBackup(parsed) - setBackupNote(`${tracks} songs · ${pls} playlists restored`) + showToast(`${tracks} songs · ${pls} playlists restored`) } catch { - setBackupNote("Couldn't read that backup file.") + showToast("Couldn't read that backup file.") } } + // Resolve ids the same way PlaylistDetail does, dropping dangling ids — + // deleted or restored-but-not-reimported tracks must not inflate the counts. + const byId = allTracks && new Map(allTracks.map((t) => [t.id, t])) + return (
- {playlists === undefined ? ( + {playlists === undefined || allTracks === undefined ? (

Loading…

) : open ? ( setOpenId(null)} onActions={() => setActionsFor(open)} /> @@ -74,7 +81,13 @@ export default function PlaylistsView() { ) : (
{playlists.map((p) => ( - setOpenId(p.id)} onLongPress={() => setActionsFor(p)} /> + byId.get(id)).filter(Boolean)} + onOpen={() => setOpenId(p.id)} + onLongPress={() => setActionsFor(p)} + /> ))}
)} @@ -92,11 +105,6 @@ export default function PlaylistsView() { hidden onChange={onRestore} /> - {backupNote && ( -

- {backupNote} -

- )} )} @@ -149,16 +157,31 @@ export default function PlaylistsView() { ) } -function PlaylistCard({ playlist, onOpen, onLongPress }) { +function PlaylistCard({ playlist, tracks, onOpen, onLongPress }) { const lp = useLongPress(onLongPress) + const n = tracks.length + const dur = formatTotalDuration(tracks.reduce((s, t) => s + (t.duration || 0), 0)) return ( ) } diff --git a/src/index.css b/src/index.css index de02863..a4b4033 100644 --- a/src/index.css +++ b/src/index.css @@ -80,7 +80,6 @@ body { .importbtn { display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0; margin-top: 6px; } .importbtn:disabled { opacity: 0.7; } -.importnote { margin-top: 8px; font-size: 12px; color: var(--text-dim); max-width: 100%; } .spinner { width: 14px; height: 14px; border-radius: 50%; @@ -94,6 +93,7 @@ body { letter-spacing: -0.03em; margin: 6px 0 0; } +.view__meta { margin: 4px 0 0; font-size: 13px; } .eyebrow { font-size: 11px; font-weight: 700; @@ -143,6 +143,9 @@ body { } /* segmented control */ +/* Row of segmented controls (Library: filter + sort). Wraps on narrow screens + instead of squeezing the pills. */ +.segrow { display: flex; flex-wrap: wrap; column-gap: 10px; align-items: center; } .segmented { display: inline-flex; background: var(--surface); @@ -160,6 +163,12 @@ body { } .segmented button.on { background: var(--text); color: var(--bg); } +/* Library empty state — centered, unlike SearchView's left-aligned .empty. */ +.emptystate { + display: flex; flex-direction: column; align-items: center; + text-align: center; gap: 14px; padding-top: 48px; +} + /* ── search ──────────────────────────────────────────────────── */ .searchbar { display: flex; align-items: center; gap: 10px; @@ -267,13 +276,23 @@ body { /* ── playlists ───────────────────────────────────────────────── */ .plcard { - display: flex; align-items: center; justify-content: space-between; + display: flex; align-items: center; gap: 14px; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--line); - border-radius: 14px; padding: 16px 18px; margin-bottom: 10px; + border-radius: 14px; padding: 14px 18px; margin-bottom: 10px; color: var(--text); cursor: pointer; font-family: var(--font); } -.plcard__name { font-size: 16px; font-weight: 600; } +/* 2×2 artwork mosaic; empty cells fall back to Artwork's gradient tiles. */ +.plcard__mosaic { + display: grid; grid-template-columns: repeat(2, 24px); gap: 2px; + border-radius: 8px; overflow: hidden; flex-shrink: 0; +} +.plcard__text { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.plcard__name { + font-size: 16px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.plcard__meta { font-size: 13px; } .plrow { display: flex; align-items: center; } .plrow .row { flex: 1; } @@ -386,6 +405,27 @@ body { cursor: pointer; z-index: 5; } +/* Transient status pill (import summaries, restore results). Same absolute- + inside-dock recipe as .versionpeek — never position:fixed, the dock carries + the safe-area inset and sits above the mini-player automatically. z-index 5 + keeps it under .now (50), sheets (60), and modals (70). */ +.toast { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + max-width: calc(100% - 32px); + padding: 7px 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + color: var(--text); + font-family: var(--font); + font-size: 12px; + cursor: pointer; + z-index: 5; +} + /* Only show a divider above the tab bar when nothing is playing; when the mini card is present it provides the separation (and it's the dock's first child). */ .dock > .tabbar:first-child { border-top: 1px solid var(--line); } diff --git a/src/lib/format.js b/src/lib/format.js index 4555e9d..4be4183 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -8,6 +8,23 @@ export function summarize(tracks) { return `${count} · ${dur}` } +/** 10260 → "2 hr 51 min", 2880 → "48 min", 42 → "42 sec". Returns '' for + null/0/NaN so callers can render the bare count, mirroring summarize. */ +export function formatTotalDuration(totalSeconds) { + if (!totalSeconds || Number.isNaN(totalSeconds)) return '' + const s = Math.floor(totalSeconds) + if (s >= 3600) { + // Round to whole minutes first so 59.5 leftover minutes carries into the + // hour instead of rendering as "1 hr 60 min". + const mins = Math.round(s / 60) + const h = Math.floor(mins / 60) + const m = mins % 60 + return m ? `${h} hr ${m} min` : `${h} hr` + } + if (s >= 60) return `${Math.round(s / 60)} min` + return `${s} sec` +} + /** 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' diff --git a/src/state/UIProvider.jsx b/src/state/UIProvider.jsx index 51ee66d..b97deb4 100644 --- a/src/state/UIProvider.jsx +++ b/src/state/UIProvider.jsx @@ -2,17 +2,25 @@ import { createContext, useContext, useState, useCallback } from 'react' // Tiny global UI state so any TrackRow can open the "Add to playlist" sheet // without prop-drilling. The sheet itself is rendered once at the app root. +// The toast follows the same pattern: any component can announce a result and +// App renders the single toast host inside the dock. const UIContext = createContext(null) export const useUI = () => useContext(UIContext) export function UIProvider({ children }) { const [addTarget, setAddTarget] = useState(null) // track being added, or null + const [toast, setToast] = useState(null) // { id, message } or null const openAddToPlaylist = useCallback((track) => setAddTarget(track), []) const closeAddToPlaylist = useCallback(() => setAddTarget(null), []) + // id changes per call so App can key the Toast — remounting restarts the + // auto-dismiss timer even when the same message fires twice in a row. + const showToast = useCallback((message) => setToast({ id: Date.now(), message }), []) + const clearToast = useCallback(() => setToast(null), []) + return ( - + {children} ) From 09e765b3abbdc92adec2668338626a1cf25e2398 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:26:30 +0000 Subject: [PATCH 8/9] fix: address review findings (round 1) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL --- src/components/LibraryView.jsx | 2 +- src/lib/db.js | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index fac303e..a7619fe 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -48,7 +48,7 @@ export default function LibraryView() { sort === 'plays' ? [...filtered].sort((a, b) => (b.playCount || 0) - (a.playCount || 0) || b.dateAdded - a.dateAdded) : sort === 'alpha' - ? [...filtered].sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })) + ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' })) : filtered const total = tracks.reduce((s, t) => s + (t.duration || 0), 0) diff --git a/src/lib/db.js b/src/lib/db.js index 9cf2278..b74314a 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -215,7 +215,20 @@ export async function importBackup(data) { if (!data || !Array.isArray(data.tracks) || !Array.isArray(data.playlists)) { throw new Error('invalid-backup') } - const trackRows = data.tracks.filter((t) => t && typeof t.id === 'string') + // Beyond the id check, coerce the fields the UI and dedup depend on — a + // hand-edited or foreign-schema backup row missing `title` would otherwise + // crash the Library A-Z sort on every launch. The fallbacks are exactly + // addLocalTrack's normalizations, so re-importing the real file still + // triple-matches this row; a numeric `duration` passes through verbatim + // because rounding it would break that same match. + const trackRows = data.tracks + .filter((t) => t && typeof t.id === 'string') + .map((t) => ({ + ...t, + title: typeof t.title === 'string' ? t.title : 'Unknown', + artist: typeof t.artist === 'string' ? t.artist : 'Imported', + duration: typeof t.duration === 'number' ? t.duration : 0, + })) const playlistRows = data.playlists.filter((p) => p && typeof p.name === 'string') await db.transaction('rw', db.tracks, db.playlists, async () => { From d1a011f60e2097cbe1d846b7db925ed9ac031240 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:33:41 +0000 Subject: [PATCH 9/9] polish: apply review nits from the ship run - summarize() now says "songs" and delegates to formatTotalDuration; Library header, playlist cards, and playlist detail all share it, so the count/duration vocabulary can't drift again - formatTotalDuration: drop the unreachable NaN check and round to minutes before splitting off hours, so 3599 s renders "1 hr", not "60 min" - importBackup: reject backups with a schema newer than 3; drop the dead starred default that the spread always overwrote - ImportButton: picking only sidecar files now says "No audio files in that selection" instead of claiming the songs were already imported - PlaylistsView: export failures surface a toast instead of vanishing Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL --- src/components/ImportButton.jsx | 2 +- src/components/LibraryView.jsx | 10 ++-------- src/components/PlaylistsView.jsx | 11 +++-------- src/lib/db.js | 6 +++++- src/lib/format.js | 33 +++++++++++++++----------------- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/src/components/ImportButton.jsx b/src/components/ImportButton.jsx index 7abd0d8..e69e328 100644 --- a/src/components/ImportButton.jsx +++ b/src/components/ImportButton.jsx @@ -25,7 +25,7 @@ export default function ImportButton() { (f) => f.type.startsWith('audio/') || /\.(m4a|mp3|aac|wav|flac|ogg)$/i.test(f.name) ) if (!files.length) { - showToast('No new songs — everything already in your library.') + showToast('No audio files in that selection.') return } diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index a7619fe..f4f587d 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useTracks } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' import { shuffle } from '../lib/shuffle' -import { formatTotalDuration } from '../lib/format' +import { summarize } from '../lib/format' import TrackRow from './TrackRow' import ImportButton from './ImportButton' @@ -51,9 +51,6 @@ export default function LibraryView() { ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' })) : filtered - const total = tracks.reduce((s, t) => s + (t.duration || 0), 0) - const dur = formatTotalDuration(total) - return (
@@ -61,10 +58,7 @@ export default function LibraryView() {

Your music

Library

-

- {tracks.length} {tracks.length === 1 ? 'song' : 'songs'} - {dur && ` · ${dur}`} -

+

{summarize(tracks)}

diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 754b5dd..210ed27 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -4,7 +4,7 @@ import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist, exp import { usePlayer } from '../state/PlayerProvider' import { useUI } from '../state/UIProvider' import { useLongPress } from '../lib/useLongPress' -import { summarize, formatTotalDuration } from '../lib/format' +import { summarize } from '../lib/format' import { shuffle } from '../lib/shuffle' import Artwork from './Artwork' import TrackRow from './TrackRow' @@ -38,8 +38,7 @@ export default function PlaylistsView() { // URLs pin their Blobs in memory for the app's whole lifetime. setTimeout(() => URL.revokeObjectURL(url), 5000) } catch { - // Export reads only local data, so failure is vanishingly rare; swallow - // it rather than let a rejection escape the handler. + showToast("Couldn't export the backup.") } } @@ -159,8 +158,6 @@ export default function PlaylistsView() { function PlaylistCard({ playlist, tracks, onOpen, onLongPress }) { const lp = useLongPress(onLongPress) - const n = tracks.length - const dur = formatTotalDuration(tracks.reduce((s, t) => s + (t.duration || 0), 0)) return ( ) diff --git a/src/lib/db.js b/src/lib/db.js index b74314a..ef00f30 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -215,6 +215,11 @@ export async function importBackup(data) { if (!data || !Array.isArray(data.tracks) || !Array.isArray(data.playlists)) { throw new Error('invalid-backup') } + // Refuse backups from a future schema — importing one silently could apply + // this version's merge semantics to fields it doesn't understand. + if (typeof data.schema === 'number' && data.schema > 3) { + throw new Error('invalid-backup') + } // Beyond the id check, coerce the fields the UI and dedup depend on — a // hand-edited or foreign-schema backup row missing `title` would otherwise // crash the Library A-Z sort on every launch. The fallbacks are exactly @@ -253,7 +258,6 @@ export async function importBackup(data) { // file relink its bytes to this exact row. Until then the player shows // its graceful "Audio unavailable" state. await db.tracks.add({ - starred: 0, playCount: 0, lastPlayedAt: null, dateAdded: Date.now(), diff --git a/src/lib/format.js b/src/lib/format.js index 4be4183..48e8e1d 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -1,28 +1,25 @@ -/** Summarize a track list: "5 tracks · 12 min" (or "· 48 sec" when short). */ +/** Summarize a track list: "5 songs · 12 min" (or "· 48 sec" when short). */ export function summarize(tracks) { const n = tracks.length - const total = tracks.reduce((s, t) => s + (t.duration || 0), 0) - const count = `${n} ${n === 1 ? 'track' : 'tracks'}` - if (!total) return count - const dur = total >= 60 ? `${Math.round(total / 60)} min` : `${Math.round(total)} sec` - return `${count} · ${dur}` + const count = `${n} ${n === 1 ? 'song' : 'songs'}` + const dur = formatTotalDuration(tracks.reduce((s, t) => s + (t.duration || 0), 0)) + return dur ? `${count} · ${dur}` : count } /** 10260 → "2 hr 51 min", 2880 → "48 min", 42 → "42 sec". Returns '' for - null/0/NaN so callers can render the bare count, mirroring summarize. */ + null/0/NaN (NaN is falsy) so callers can render the bare count. */ export function formatTotalDuration(totalSeconds) { - if (!totalSeconds || Number.isNaN(totalSeconds)) return '' + if (!totalSeconds) return '' const s = Math.floor(totalSeconds) - if (s >= 3600) { - // Round to whole minutes first so 59.5 leftover minutes carries into the - // hour instead of rendering as "1 hr 60 min". - const mins = Math.round(s / 60) - const h = Math.floor(mins / 60) - const m = mins % 60 - return m ? `${h} hr ${m} min` : `${h} hr` - } - if (s >= 60) return `${Math.round(s / 60)} min` - return `${s} sec` + if (s < 60) return `${s} sec` + // Round to whole minutes before splitting off hours, so 59.5 leftover + // minutes carries into the hour ("2 hr", never "1 hr 60 min") and 3599 s + // rounds up to "1 hr", never "60 min". + const mins = Math.round(s / 60) + if (mins < 60) return `${mins} min` + const h = Math.floor(mins / 60) + const m = mins % 60 + return m ? `${h} hr ${m} min` : `${h} hr` } /** 73 → "1:13", 605 → "10:05", 3661 → "1:01:01". Handles null/NaN gracefully. */