From d0d95653463460328834b154e2f84c1d3ad40163 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:10:08 +0000 Subject: [PATCH 01/14] 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 02/14] 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 07/14] 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 08/14] 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 09/14] 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. */ From ccca55c3a0d0e19afd102f5cf3bee1fbc60fa2da Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 23:19:41 -0400 Subject: [PATCH 10/14] fix(backup): include podcasts, episode progress and playlist resume; export via share sheet --- src/components/PlaylistsView.jsx | 26 ++++++++++++++-------- src/lib/db.js | 37 ++++++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 6cd91f9..affb850 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react' import { usePlaylists, useTracks } from '../state/useLibrary' -import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist, exportBackup, importBackup } from '../lib/db' +import { createPlaylist, renamePlaylist, deletePlaylist, exportBackup, importBackup } from '../lib/db' import { usePlayer } from '../state/PlayerProvider' import { useUI } from '../state/UIProvider' import { useLongPress } from '../lib/useLongPress' @@ -28,11 +28,23 @@ export default function PlaylistsView() { const onExport = async () => { try { const data = await exportBackup() + const name = `melody-backup-${new Date().toISOString().slice(0, 10)}.json` const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + // On iPhone the share sheet is the reliable way out ("Save to Files"); + // a home-screen app can't follow a download link. + const file = new File([blob], name, { type: 'application/json' }) + if (navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file] }) + } catch (err) { + if (err?.name !== 'AbortError') throw err + } + return + } const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `melody-backup-${new Date().toISOString().slice(0, 10)}.json` + a.download = name a.click() // Revoke once the download has had a moment to start — leaked object // URLs pin their Blobs in memory for the app's whole lifetime. @@ -50,8 +62,8 @@ export default function PlaylistsView() { // Parse here, outside importBackup's Dexie transaction — awaiting // file.text() inside the transaction zone would kill it. const parsed = JSON.parse(await file.text()) - const { tracks, playlists: pls } = await importBackup(parsed) - showToast(`${tracks} songs · ${pls} playlists restored`) + const { tracks, playlists: pls, podcasts } = await importBackup(parsed) + showToast(`${tracks} songs · ${pls} playlists${podcasts ? ` · ${podcasts} shows` : ''} restored`) } catch { showToast("Couldn't read that backup file.") } @@ -225,13 +237,9 @@ function PlaylistDetail({ playlist, onBack, onActions }) { )}
{tracks.map((t) => ( -
- - -
+ ))}
-

Tip: set the loop button to ⟳ all to loop this playlist.

) : (

Empty playlist. Add tracks from Library or Search using the ⋯ menu.

diff --git a/src/lib/db.js b/src/lib/db.js index f490e07..d8b691e 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -202,10 +202,14 @@ export async function deleteTrack(trackId) { export async function exportBackup() { return { - schema: 3, + schema: 4, exportedAt: new Date().toISOString(), tracks: await db.tracks.toArray(), playlists: await db.playlists.toArray(), + // v4: followed shows and episode progress. Downloads aren't in the file, + // so every episode comes back as not downloaded. + podcasts: await db.podcasts.toArray(), + episodes: (await db.episodes.toArray()).map((e) => ({ ...e, downloaded: 0 })), } } @@ -225,7 +229,7 @@ export async function importBackup(data) { } // 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) { + if (typeof data.schema === 'number' && data.schema > 4) { throw new Error('invalid-backup') } // Beyond the id check, coerce the fields the UI and dedup depend on — a @@ -243,8 +247,13 @@ export async function importBackup(data) { duration: typeof t.duration === 'number' ? t.duration : 0, })) const playlistRows = data.playlists.filter((p) => p && typeof p.name === 'string') + // Schema 3 backups have no podcasts; treat them as empty. + const podcastRows = (Array.isArray(data.podcasts) ? data.podcasts : []).filter((p) => p && typeof p.id === 'string') + const episodeRows = (Array.isArray(data.episodes) ? data.episodes : []).filter( + (e) => e && typeof e.id === 'string' && typeof e.podcastId === 'string' && typeof e.audioUrl === 'string', + ) - await db.transaction('rw', db.tracks, db.playlists, async () => { + await db.transaction('rw', db.tracks, db.playlists, db.podcasts, db.episodes, async () => { for (const row of trackRows) { const existing = await db.tracks.get(row.id) if (existing) { @@ -284,6 +293,7 @@ export async function importBackup(data) { for (const tid of pl.trackIds || []) { if (!p.trackIds.includes(tid)) p.trackIds.push(tid) } + if (!p.resume && pl.resume) p.resume = pl.resume }) } else { // NEVER restore the old id: playlist ids are ++id auto-increment @@ -293,12 +303,31 @@ export async function importBackup(data) { name: pl.name, trackIds: [...(pl.trackIds || [])], createdAt: pl.createdAt ?? Date.now(), + ...(pl.resume ? { resume: pl.resume } : {}), + }) + } + } + + // Shows merge by feed URL. For episodes the further-along progress wins, + // and a live download is never marked as missing. + for (const show of podcastRows) { + if (!(await db.podcasts.get(show.id))) await db.podcasts.add(show) + } + for (const ep of episodeRows) { + const existing = await db.episodes.get(ep.id) + if (!existing) { + await db.episodes.add({ ...ep, downloaded: 0 }) + } else { + await db.episodes.update(ep.id, { + played: existing.played || ep.played ? 1 : 0, + position: Math.max(existing.position || 0, ep.position || 0), + lastPlayedAt: Math.max(existing.lastPlayedAt || 0, ep.lastPlayedAt || 0) || null, }) } } }) - return { tracks: trackRows.length, playlists: playlistRows.length } + return { tracks: trackRows.length, playlists: playlistRows.length, podcasts: podcastRows.length } } // ── Playlist mutations ────────────────────────────────────────────────────── From 59e875d0d71d9bac2e285133d3649513f69d6283 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 23:19:42 -0400 Subject: [PATCH 11/14] fix(artwork): crop YouTube letterbox and use sharp art in Now Playing --- src/components/Artwork.jsx | 36 +++++++++++++++++++++++------------- src/lib/youtube.js | 16 ++++++++++++++++ src/state/PlayerProvider.jsx | 10 +++++++++- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/components/Artwork.jsx b/src/components/Artwork.jsx index 513b81d..e610370 100644 --- a/src/components/Artwork.jsx +++ b/src/components/Artwork.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { ytThumbId, ytArtworkChain } from '../lib/youtube' // Square artwork. Uses the YouTube thumbnail when present; otherwise renders a // deterministic gradient tile (same track → same colors) so the library still @@ -17,21 +18,30 @@ export function warmGlow(id) { export default function Artwork({ track, size = 48, radius = 8 }) { const style = { width: size, height: size, borderRadius: radius } - // A present-but-broken thumbnail URL (expired/offline/deleted video) should - // fall back to the gradient tile, not the browser's broken-image glyph. - // Reset the flag when the URL changes since this instance is reused in lists. - const [failed, setFailed] = useState(false) - useEffect(() => setFailed(false), [track?.thumbnailUrl]) + const url = track?.thumbnailUrl + const ytId = ytThumbId(url) + const chain = ytId ? ytArtworkChain(ytId, size) : url ? [url] : [] + // Walk down the chain on failure; past the end means the gradient tile. A + // present-but-broken URL (expired/offline/deleted video) should fall back, + // not show the browser's broken-image glyph. Reset when the track changes, + // since this instance is reused in lists. + const [step, setStep] = useState(0) + useEffect(() => setStep(0), [url, size]) - if (track?.thumbnailUrl && !failed) { + const src = chain[step] + if (src) { return ( - setFailed(true)} - /> + + setStep((n) => n + 1)} + // A missing maxres answers with a 120px grey placeholder, not an error. + onLoad={(e) => { if (src.includes('maxresdefault') && e.currentTarget.naturalWidth <= 120) setStep((n) => n + 1) }} + /> + ) } // Keep the hue in a warm band (deep amber → sienna → olive) and low-ish diff --git a/src/lib/youtube.js b/src/lib/youtube.js index 8338105..839a924 100644 --- a/src/lib/youtube.js +++ b/src/lib/youtube.js @@ -49,3 +49,19 @@ export async function fetchYouTubePreview(id) { return null } } + +// YouTube's hqdefault thumbnail is 4:3 with black bars baked in above and +// below the 16:9 frame, which shows as letterboxing in a square tile. +// mqdefault and maxresdefault are true 16:9, so a centered square crop is +// clean. Returns null for artwork that isn't a YouTube thumbnail. +export function ytThumbId(url) { + const m = /^https:\/\/i\d?\.ytimg\.com\/vi(?:_webp)?\/([\w-]{11})\//.exec(url || '') + return m ? m[1] : null +} + +/** Best-first artwork candidates for a display size in CSS px. */ +export function ytArtworkChain(id, size) { + const base = `https://i.ytimg.com/vi/${id}` + const sharp = size > 120 ? [`${base}/maxresdefault.jpg`] : [] + return [...sharp, `${base}/mqdefault.jpg`, `${base}/hqdefault.jpg`] +} diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index e69b4e2..098ba31 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react' import { bumpPlayCount, getAudioBlob, getPosition, savePosition, setPlayed, savePlaylistResume } from '../lib/db' import { isLongForm } from '../lib/podcasts' +import { ytThumbId } from '../lib/youtube' const PlayerContext = createContext(null) export const usePlayer = () => useContext(PlayerContext) @@ -358,8 +359,15 @@ export function PlayerProvider({ children }) { ms.metadata = new window.MediaMetadata({ title: current.title || 'Unknown', artist: current.artist || '', + // The lock screen crops to a square too, so skip the letterboxed hqdefault. artwork: current.thumbnailUrl - ? [{ src: current.thumbnailUrl, sizes: 'any', type: 'image/jpeg' }] + ? [{ + src: ytThumbId(current.thumbnailUrl) + ? `https://i.ytimg.com/vi/${ytThumbId(current.thumbnailUrl)}/mqdefault.jpg` + : current.thumbnailUrl, + sizes: 'any', + type: 'image/jpeg', + }] : [], }) From 30d313016a30171af84f48c50b7a0bc4d14b3296 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 23:19:42 -0400 Subject: [PATCH 12/14] fix(playlists): aligned rows, remove-from-playlist moves into the track menu --- src/components/AddToPlaylistSheet.jsx | 13 +++++++++++-- src/components/TrackRow.jsx | 4 ++-- src/state/UIProvider.jsx | 9 +++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/AddToPlaylistSheet.jsx b/src/components/AddToPlaylistSheet.jsx index 4d50cf2..71ce907 100644 --- a/src/components/AddToPlaylistSheet.jsx +++ b/src/components/AddToPlaylistSheet.jsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useUI } from '../state/UIProvider' import { usePlaylists } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' -import { addToPlaylist, createPlaylist, toggleStar, deleteTrack } from '../lib/db' +import { addToPlaylist, createPlaylist, toggleStar, deleteTrack, removeFromPlaylist } from '../lib/db' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' import { useDialog } from '../lib/useDialog' @@ -10,7 +10,7 @@ import { useDialog } from '../lib/useDialog' // Track actions sheet (opened from a TrackRow's ⋯). Quick playback actions on // top (Play next / Add to queue / Favorite), then the "add to playlist" picker. export default function AddToPlaylistSheet() { - const { addTarget, closeAddToPlaylist } = useUI() + const { addTarget, fromPlaylist, closeAddToPlaylist } = useUI() const playlists = usePlaylists() const { playNext, addToQueue } = usePlayer() const [creating, setCreating] = useState(false) @@ -52,6 +52,15 @@ export default function AddToPlaylistSheet() { + {fromPlaylist && ( + + )} +

Add to playlist

- diff --git a/src/state/UIProvider.jsx b/src/state/UIProvider.jsx index b97deb4..c22a121 100644 --- a/src/state/UIProvider.jsx +++ b/src/state/UIProvider.jsx @@ -11,7 +11,12 @@ 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), []) + // fromPlaylist: the playlist the row belongs to, so the sheet can offer removal. + const [fromPlaylist, setFromPlaylist] = useState(null) + const openAddToPlaylist = useCallback((track, playlist = null) => { + setAddTarget(track) + setFromPlaylist(playlist) + }, []) const closeAddToPlaylist = useCallback(() => setAddTarget(null), []) // id changes per call so App can key the Toast — remounting restarts the @@ -20,7 +25,7 @@ export function UIProvider({ children }) { const clearToast = useCallback(() => setToast(null), []) return ( - + {children} ) From ce171a83858a3f27256a7caa332087b2c33bfbb6 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 23:19:42 -0400 Subject: [PATCH 13/14] polish(library): one-tap sort beside the filter, no filters on an empty library --- src/components/LibraryView.jsx | 33 +++++++++++++++++++++------------ src/index.css | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index f4f587d..2e40fba 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -58,21 +58,27 @@ export default function LibraryView() {

Your music

Library

-

{summarize(tracks)}

+ {tracks.length > 0 &&

{summarize(tracks)}

}
- + {/* An empty library shows its own, bigger Import in the empty state. */} + {tracks.length > 0 && } -
-
- - -
-
- {SORTS.map(({ value, label }) => ( - - ))} + {tracks.length > 0 && ( +
+
+ + +
+ {/* One tap cycles the sort; a second segmented row cost a fifth of the screen. */} +
-
+ )}
{shown.length > 0 ? ( @@ -119,3 +125,6 @@ function NotesGlyph() { ) } +function SortGlyph() { + return +} diff --git a/src/index.css b/src/index.css index f05c310..aac3a51 100644 --- a/src/index.css +++ b/src/index.css @@ -595,3 +595,17 @@ body { .resume__label { font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .resume__title { color: var(--text); font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .resume__time { font-size: 13px; font-variant-numeric: tabular-nums; } + +/* ── polish ───────────────────────────────────────────────────── */ +/* One clear button: hide WebKit's own × inside type=search. */ +.searchbar__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; } +.artwork--img { display: block; overflow: hidden; flex-shrink: 0; background: var(--surface-2); } +.artwork--img img { width: 100%; height: 100%; object-fit: cover; display: block; } +.artwork__zoom { transform: scale(1.34); } +.segrow { justify-content: space-between; } +.sortbtn { + display: inline-flex; align-items: center; gap: 7px; + background: none; border: none; color: var(--text-dim); + font-family: var(--font); font-size: 14px; font-weight: 600; + padding: 8px 4px; cursor: pointer; +} From 0ad7bfbb0432946add90edfd37f57e1c9b35a833 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 23:19:42 -0400 Subject: [PATCH 14/14] test: update selectors for merged UI, add screen capture script --- scripts/shoot-audit.mjs | 101 ++++++++++++++++++++++++++++++++++++++ scripts/test-features.mjs | 2 +- scripts/test-series.mjs | 8 +-- 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 scripts/shoot-audit.mjs diff --git a/scripts/shoot-audit.mjs b/scripts/shoot-audit.mjs new file mode 100644 index 0000000..272f375 --- /dev/null +++ b/scripts/shoot-audit.mjs @@ -0,0 +1,101 @@ +// Screenshots of every screen on the production build, with sample songs. +// node scripts/shoot-audit.mjs +import { chromium, devices } from 'playwright' +import { preview } from 'vite' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const outDir = process.argv[2] +mkdirSync(outDir, { recursive: true }) +const server = await preview({ root: resolve(here, '..'), base: '/melody/', preview: { port: 5194, strictPort: true }, logLevel: 'error' }) +const base = 'http://localhost:5194/melody/' + +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }) +const ctx = await browser.newContext({ ...devices['iPhone 13'] }) +const page = await ctx.newPage() +const logs = [] +page.on('pageerror', (e) => logs.push('PAGEERROR ' + e.message)) +page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning') logs.push(m.type() + ' ' + m.text().slice(0, 200)) }) +let i = 0 +const shot = async (name) => { await page.waitForTimeout(700); await page.screenshot({ path: `${outDir}/${String(++i).padStart(2, '0')}-${name}.png` }) } + +function wav(seconds, hz) { + const rate = 8000, n = seconds * rate + const b = Buffer.alloc(44 + n) + b.write('RIFF', 0); b.writeUInt32LE(36 + n, 4); b.write('WAVEfmt ', 8) + b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22) + b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate, 28); b.writeUInt16LE(1, 32); b.writeUInt16LE(8, 34) + b.write('data', 36); b.writeUInt32LE(n, 40) + for (let k = 0; k < n; k++) b[44 + k] = 128 + Math.round(10 * Math.sin((2 * Math.PI * hz * k) / rate)) + return b +} +const songs = [ + ['Daft Punk - Get Lucky [5NV6Rdv1a3I]', 248], ['The Weeknd - Blinding Lights [4NRXx6U8ABQ]', 200], + ['Coldplay - Viva La Vida [dvgZkm1xWPE]', 242], ['Queen - Bohemian Rhapsody [fJ9rUzIMcZQ]', 355], + ['Adele - Rolling in the Deep [rYEDA3JcQqw]', 234], ['Kendrick Lamar - HUMBLE. [tvTRZJ-4EyI]', 177], + ['Tame Impala - The Less I Know The Better [2SUwOgmvzK4]', 216], ['Fleetwood Mac - Dreams [mrZRURcb1cM]', 257], + ['Long Talk - Deep Work Interview [dQw4w9WgXcQ]', 1500], +] + +try { + await page.goto(base, { waitUntil: 'networkidle' }) + await shot('library-first-run') + await page.getByRole('button', { name: 'Search' }).click(); await shot('search-empty') + await page.getByRole('button', { name: 'Playlists' }).click(); await shot('playlists-empty') + await page.getByRole('button', { name: 'Podcasts' }).click(); await shot('podcasts-empty') + + await page.getByRole('button', { name: 'Library' }).click() + await page.locator('input[type=file]').setInputFiles(songs.map(([n, s], k) => ({ name: n + '.wav', mimeType: 'audio/wav', buffer: wav(Math.min(s, 60), 220 + k * 30) }))) + await page.waitForFunction(() => document.querySelectorAll('.row').length >= 9, null, { timeout: 60000 }) + await page.waitForTimeout(2500) + await shot('library-imported') + await page.getByRole('button', { name: 'Favorites' }).click(); await shot('library-favorites-empty') + await page.getByRole('button', { name: 'All' }).click() + await page.locator('.row').nth(1).getByLabel('Favorite').click() + + await page.getByRole('button', { name: 'Search' }).click() + await page.locator('.searchbar__input').fill('queen'); await shot('search-results') + await page.locator('.searchbar__input').fill('zzz nothing'); await shot('search-no-match') + await page.locator('.searchbar__input').fill('https://youtu.be/kJQP7kiw5Fk?si=abc'); await page.waitForTimeout(2000); await shot('search-youtube-link') + + await page.getByRole('button', { name: 'Library' }).click() + await page.locator('.row__main').first().click() + await page.waitForTimeout(1500); await shot('library-playing-mini') + await page.locator('.mini').click(); await shot('now-playing') + await page.getByLabel('Lyrics').click(); await page.waitForTimeout(2500); await shot('lyrics') + await page.getByLabel('Lyrics').click() + await page.getByLabel('Minimize').click() + + await page.locator('.row').nth(2).getByLabel('Add to playlist').click(); await shot('add-to-playlist-sheet') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Playlists' }).click() + await page.getByRole('button', { name: '+ New' }).click(); await shot('new-playlist-modal') + await page.locator('.modal__input').fill('Road Trip'); await page.getByRole('button', { name: 'Create' }).click() + await shot('playlist-detail-empty') + await page.getByRole('button', { name: 'Library' }).click() + for (const k of [0, 2, 3, 5]) { + await page.locator('.row').nth(k).getByLabel('Add to playlist').click() + await page.getByRole('button', { name: /Road Trip/ }).click() + await page.waitForTimeout(300) + if (await page.locator('.sheet').count()) await page.keyboard.press('Escape') + } + await page.getByRole('button', { name: 'Playlists' }).click(); await shot('playlists-list') + await page.getByText('Road Trip').click(); await shot('playlist-detail') + await page.locator('.phead').getByLabel('Playlist options').click(); await shot('playlist-actions-sheet') + await page.keyboard.press('Escape') + + await page.getByRole('button', { name: 'Library' }).click() + await page.getByText('Deep Work Interview').click(); await page.waitForTimeout(1200) + await page.locator('.mini').click(); await shot('now-playing-longform') + await page.getByLabel('Minimize').click() + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await shot('library-bottom') +} catch (e) { + logs.push('SCRIPT ' + e.message.split('\n')[0]) +} finally { + console.log(logs.join('\n') || 'no console errors') + await browser.close() + server.httpServer.close() +} diff --git a/scripts/test-features.mjs b/scripts/test-features.mjs index 1abdfb6..7bce94a 100644 --- a/scripts/test-features.mjs +++ b/scripts/test-features.mjs @@ -22,7 +22,7 @@ await page.locator('input[type=file]').setInputFiles({ }) await page.waitForTimeout(1500) -const artSrc = await page.locator('.row__main img.artwork').first().getAttribute('src').catch(() => null) +const artSrc = await page.locator('.row__main .artwork img').first().getAttribute('src').catch(() => null) console.log('cover art src:', artSrc) // play it, open Now Playing diff --git a/scripts/test-series.mjs b/scripts/test-series.mjs index 32a24f6..bbe6311 100644 --- a/scripts/test-series.mjs +++ b/scripts/test-series.mjs @@ -46,7 +46,7 @@ try { check('import reports the series playlist', true) await page.getByRole('button', { name: 'Playlists' }).click() - await page.getByText('Morning Show').click() + await page.locator('.plcard', { hasText: 'Morning Show' }).click() const titles = await page.locator('.row__title').allTextContents() check('playlist is in episode order', titles.join(',') === '001 Welcome,002 Second,003 Third,010 Wrap Up', titles.join(',')) @@ -60,7 +60,7 @@ try { await page.reload({ waitUntil: 'networkidle' }) await page.getByRole('button', { name: 'Playlists' }).click() - await page.getByText('Morning Show').click() + await page.locator('.plcard', { hasText: 'Morning Show' }).click() const resume = page.locator('.resume') await resume.waitFor({ timeout: 5000 }) const label = await resume.textContent() @@ -87,7 +87,9 @@ try { await page.locator('input[type=file]').setInputFiles([file('Morning Show - 001 Welcome.wav', 340)]) await page.waitForTimeout(1500) await page.getByRole('button', { name: 'Playlists' }).click() - check('no duplicate playlist on re-import', await page.getByText('Morning Show').count() === 1) + await page.locator('.plcard').first().waitFor({ timeout: 5000 }) + const cards = await page.locator('.plcard').allTextContents() + check('no duplicate playlist on re-import', cards.filter((c) => c.includes('Morning Show')).length === 1, cards.join(' / ')) } catch (e) { console.log('ERROR:', e.message) failed++