diff --git a/README.md b/README.md index 914640a..fec70e7 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,18 @@ That's it: the song is now in your library with cover art and lyrics, playable o > yt-dlp occasionally breaks when YouTube changes things. Fix it with `pip install -U yt-dlp`. +## Updating & data safety + +- **The app updates itself.** Every time you open it, a foreground service-worker check looks for a new build (and again every hour while it stays open) and applies it in place. You never need to reinstall anything. +- **Never delete the home-screen icon.** On iOS the app's storage belongs to that icon, so removing it can wipe your whole library along with it. There is nothing a reinstall fixes that reopening does not. +- **Safe force-refresh:** swipe the app away in the app switcher and reopen it. +- **Backup:** on the Playlists tab, tap **Export backup** to save a JSON file of your catalog and playlists (titles, stars, play counts, playlist contents). Audio bytes are not included, so the file stays tiny. +- **Restore:** on the Playlists tab, tap **Restore** and pick that JSON, then re-import the same audio files from Files. The bytes reattach to the same tracks, so your playlists and stars come back intact. + ## Known iOS limitations (by design) - Background audio stops if you **force-quit** the PWA (fine for screen-off-in-pocket listening). -- The home-screen icon is cached by iOS. To refresh it after changes, remove and re-add the app. +- The home-screen icon artwork is cached by iOS and can lag behind a new build. Do **not** remove and re-add the app to refresh it: that can delete the app's storage and your library with it. The stale artwork is cosmetic and clears on its own; the app itself always updates in place (see above). ## Project layout 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++ diff --git a/src/App.jsx b/src/App.jsx index 93e3ff9..ab374bb 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 { UIProvider, useUI } from './state/UIProvider' +import { useLongPress } from './lib/useLongPress' import SearchView from './components/SearchView' import LibraryView from './components/LibraryView' import PlaylistsView from './components/PlaylistsView' @@ -15,8 +16,51 @@ const TABS = [ { id: 'podcasts', label: 'Podcasts', icon: PodcastIcon }, ] +// 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 ( + + ) +} + +// 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) + const { handlers: versionPress, suppressClick } = useLongPress(() => setShowVersion(true)) return ( @@ -31,13 +75,20 @@ export default function App() { {/* Floating dock: mini-player card stacked above the tab bar */}
+ {showVersion && setShowVersion(false)} />} + -
-
- - + {/* An empty library shows its own, bigger Import in the empty state. */} + {tracks.length > 0 && }
+ {tracks.length > 0 && ( +
+
+ + +
+ {/* One tap cycles the sort; a second segmented row cost a fifth of the screen. */} + +
+ )} {shown.length > 0 ? ( @@ -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,15 @@ function PlayGlyph() { function ShuffleGlyph() { return } +function NotesGlyph() { + return ( + + ) +} +function SortGlyph() { + return +} diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 1b8c6be..affb850 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -1,10 +1,12 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { usePlaylists, useTracks } from '../state/useLibrary' -import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist } 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' import { summarize, formatTime } 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,17 +14,68 @@ 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 restoreRef = useRef(null) const open = playlists?.find((p) => p.id === openId) + 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 = 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. + setTimeout(() => URL.revokeObjectURL(url), 5000) + } catch { + showToast("Couldn't export the backup.") + } + } + + const onRestore = async (e) => { + const file = e.target.files[0] + e.target.value = '' // reset so the same file can be re-picked later + if (!file) return + try { + // 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, podcasts } = await importBackup(parsed) + showToast(`${tracks} songs · ${pls} playlists${podcasts ? ` · ${podcasts} shows` : ''} restored`) + } catch { + 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)} /> @@ -39,10 +92,30 @@ 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)} + /> ))}
)} + + {/* Quiet backup corner. The file input lives here (not in App) so the + Library tab keeps exactly one file input for the test harnesses. */} +
+ + +
+ )} @@ -95,7 +168,7 @@ export default function PlaylistsView() { ) } -function PlaylistCard({ playlist, onOpen, onLongPress }) { +function PlaylistCard({ playlist, tracks, onOpen, onLongPress }) { const lp = useLongPress(onLongPress) return ( ) } @@ -153,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/components/TrackRow.jsx b/src/components/TrackRow.jsx index af850cb..b2fc90e 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -7,7 +7,7 @@ import { useUI } from '../state/UIProvider' // A single tappable track. Tap the row → play (within `list` as the queue). // Star button toggles favorite; the ⋯ button opens the add-to-playlist sheet. -export default function TrackRow({ track, list, playOpts }) { +export default function TrackRow({ track, list, playOpts, playlist }) { const { current, isPlaying, playTrack } = usePlayer() const { openAddToPlaylist } = useUI() const isCurrent = current?.id === track.id @@ -35,7 +35,7 @@ export default function TrackRow({ track, list, playOpts }) { - diff --git a/src/index.css b/src/index.css index 35ef8f3..aac3a51 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,16 +276,36 @@ 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; } +/* 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; @@ -352,10 +381,51 @@ body { /* ── dock (mini-player + tab bar) — in normal flow at the bottom of the flex column, so it's deterministically pinned to the screen bottom ── */ .dock { + position: relative; flex-shrink: 0; background: var(--bg); padding-bottom: env(safe-area-inset-bottom); } + +/* Hidden build readout (long-press the tab bar). Deliberately tiny and muted — + it should feel like a debug peek, not a feature. */ +.versionpeek { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 5px 11px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + color: var(--text-dim); + font-family: var(--font); + font-size: 11px; + white-space: nowrap; + 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); } @@ -525,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; +} diff --git a/src/lib/db.js b/src/lib/db.js index daa40a8..d8b691e 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -135,7 +135,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 } @@ -181,6 +192,144 @@ 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: 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 })), + } +} + +/** + * 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') + } + // 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 > 4) { + 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 + // 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') + // 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, db.podcasts, db.episodes, 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({ + 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) + } + if (!p.resume && pl.resume) p.resume = pl.resume + }) + } 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(), + ...(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, podcasts: podcastRows.length } +} + // ── Playlist mutations ────────────────────────────────────────────────────── export async function createPlaylist(name) { return db.playlists.add({ name: name.trim() || 'Untitled', trackIds: [], createdAt: Date.now() }) diff --git a/src/lib/format.js b/src/lib/format.js index 6f6b08d..be67019 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -1,11 +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 (NaN is falsy) so callers can render the bare count. */ +export function formatTotalDuration(totalSeconds) { + if (!totalSeconds) return '' + const s = Math.floor(totalSeconds) + 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. */ 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/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/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( 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', + }] : [], }) diff --git a/src/state/UIProvider.jsx b/src/state/UIProvider.jsx index 51ee66d..c22a121 100644 --- a/src/state/UIProvider.jsx +++ b/src/state/UIProvider.jsx @@ -2,17 +2,30 @@ 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), []) + // 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 + // 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} ) diff --git a/vite.config.js b/vite.config.js index 1d9ff84..523150e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,5 @@ import { rm } from 'node:fs/promises' +import { execSync } from 'node:child_process' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' @@ -59,8 +60,23 @@ const cspPlugin = () => ({ // preserves the repo name: https://enesyilmazcode.github.io/Melody/ — you'd // need base '/Melody/' (capital M), or rename the repo to lowercase. Don't // change this to '/Melody/' while deploying to Firebase — that would break it. +// Build stamp shown by the hidden version readout (long-press the tab bar). +// Frozen at build time, so on the phone it identifies exactly which deploy the +// service worker has actually activated — the way to confirm auto-update worked +// without any visible UI. +const commit = (() => { + try { + return execSync('git rev-parse --short HEAD').toString().trim() + } catch { + return 'unknown' + } +})() + export default defineConfig(({ command }) => ({ base: command === 'build' ? '/melody/' : '/', + define: { + __BUILD_INFO__: JSON.stringify({ commit, builtAt: new Date().toISOString() }), + }, plugins: [ // CSP only in the built HTML — injecting it in dev would break Vite HMR. command === 'build' && cspPlugin(),