diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 0000000..59073b1 --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,38 @@ +--- +description: Ship one or more code changes end to end via a multi-fleet pipeline - a research/investigation fleet, sequential implementers, an AI-slop-check fleet, a fresh unbiased review, then a fix + re-verify loop. Use when you want changes built and hardened to PR quality in one shot. +argument-hint: [what to build, plus target repo/branch] +allowed-tools: ["Workflow", "Bash", "Read", "Edit", "Write"] +--- + +# ship + +The team ship pipeline. It runs the reusable workflow at `.claude/workflows/ship.js` via the **Workflow** tool. + +## Phases +1. **Research fleet** (parallel, read-only) maps integration points, patterns to mirror, and encoded pitfalls (including past fix commits), then a synthesizer turns the draft tasks into precise implementation specs. +2. **Implement** (sequential, one builder per task) works on the target branch, runs the test command until green, commits locally per task. Sequential on purpose: the tasks in a batch usually share files, and a later task may build on an earlier one. +3. **AI-slop fleet** (parallel, read-only) sweeps the branch diff through three lenses: consistency + cross-contamination, over-engineering + dead code, comments/prose/style. +4. **Review** runs on a fresh agent that did not write the code: verifies each task against its spec, hunts bugs (a must-fix requires a concrete verified failure scenario), runs the test command, and triages the slop findings so only real ones survive. +5. **Fix + re-verify loop** applies the must-fixes, then re-reviews to confirm they landed without regressions (up to 2 rounds). + +Nothing is pushed. The result is local commits on the target branch to review before pushing/PR. + +## How to run +Call Workflow with `{scriptPath: ".claude/workflows/ship.js"}` and pass `args`: + +``` +args: { + tasks: [ // implemented strictly in order + { id: "sync-import", title: "...", spec: "" }, + ], + repo: "/abs/path/to/repo", + branch: "feat/", // reused if checked out, created from base if missing, never reset + base: "origin/main", // review diff base + testCmd: "npm run build", // the green gate for implementers and the reviewer + styleRules: "...", // optional override + researchAngles: ["...", "..."] // optional, has sensible defaults +} +``` + +## Style rules +The one non-negotiable default: **repo idiom beats any house rule.** Generic rules like "no em-dashes / no semicolons" are only applied where the surrounding code already agrees (this repo, for instance, uses em-dashes in comments and no semicolons — new code must match it, not a style memo). Always banned regardless: AI filler words and comments that narrate the diff. diff --git a/.claude/workflows/ship.js b/.claude/workflows/ship.js new file mode 100644 index 0000000..1d1ccfe --- /dev/null +++ b/.claude/workflows/ship.js @@ -0,0 +1,244 @@ +export const meta = { + name: 'ship', + description: 'Ship a batch of changes end to end: research fleet, sequential implementers, slop-check fleet, fresh review, fix loop. Local commits only.', + whenToUse: 'When one or more scoped changes should be built and hardened to PR quality in a single run.', + phases: [ + { title: 'Research', detail: 'parallel read-only mapping, then spec synthesis' }, + { title: 'Implement', detail: 'one sequential builder per task, local commit each' }, + { title: 'Slop check', detail: 'parallel consistency / over-engineering / style sweep' }, + { title: 'Review', detail: 'fresh unbiased agent reviews the full branch diff' }, + { title: 'Fix', detail: 'apply must-fixes and re-verify, up to 2 rounds' }, + ], +} + +// ── args ──────────────────────────────────────────────────────────────────── +// tasks: [{ id, title, spec }] — implemented strictly in array order, so a +// later task may depend on an earlier one (say so in its spec). +// repo: absolute path to the working copy. +// branch: the branch to commit on. Created from `base` if missing, reused as +// checked out if it already exists (never reset). +// base: merge base for review diffs (default origin/main). +// testCmd: the green-gate every implementer and the reviewer must run. +// Some invocation paths deliver args as a JSON-encoded string — accept both. +const input = typeof args === 'string' ? JSON.parse(args) : args + +const { + tasks = [], + repo, + branch, + base = 'origin/main', + testCmd = 'npm run build', + styleRules = 'Match the surrounding idiom exactly (comment density, naming, punctuation). Repo idiom always beats any general style preference. No AI filler words, no comments that narrate the diff.', + researchAngles = null, +} = input || {} + +if (!repo || !branch || !tasks.length) { + throw new Error('ship needs args.repo, args.branch and a non-empty args.tasks') +} + +const taskList = tasks.map((t, i) => `${i + 1}. [${t.id}] ${t.title}\n${t.spec}`).join('\n\n') + +// ── Phase 1: research fleet (barrier is correct: synthesis needs all maps) ── +phase('Research') +const angles = researchAngles || [ + 'Data layer: storage schema, transaction and query patterns, the invariants mutations protect', + 'UI composition: view/component structure, styling system, reusable primitives, stacking/layout constraints', + 'Encoded pitfalls: constraints explained in code comments, platform quirks, and the bug classes visible in past fix commits (read git log)', +] + +const maps = (await parallel(angles.map((angle, i) => () => + agent( + `Read-only research in the repo at ${repo} (do NOT edit anything).\n` + + `Angle: ${angle}\n\n` + + `This research feeds the implementation of these tasks:\n${taskList}\n\n` + + `Return a dense factual map for your angle: file paths, function/component names, ` + + `patterns to mirror (with a short verbatim example each), and traps an implementer ` + + `would hit. Facts only — no recommendations beyond "mirror this pattern".`, + { label: `research:${i + 1}`, phase: 'Research' }, + ) +))).filter(Boolean) + +const SPEC_SCHEMA = { + type: 'object', + required: ['specs', 'pitfalls'], + properties: { + specs: { + type: 'array', + items: { + type: 'object', + required: ['taskId', 'spec'], + properties: { taskId: { type: 'string' }, spec: { type: 'string' } }, + }, + }, + pitfalls: { type: 'string' }, + }, +} + +const synth = await agent( + `You are the spec synthesizer for a ship pipeline on the repo at ${repo}.\n\n` + + `Draft tasks:\n${taskList}\n\n` + + `Research maps from parallel read-only agents:\n\n${maps.map((m, i) => `--- MAP ${i + 1} ---\n${m}`).join('\n\n')}\n\n` + + `Refine each draft task into a precise implementation spec grounded in the maps: exact files to ` + + `touch, patterns to mirror, edge cases to handle, and what NOT to do. Keep each task's scope as ` + + `drafted — refine, don't expand. Also distill a shared "pitfalls" briefing every implementer must read. ` + + `Verify anything uncertain by reading the repo directly.`, + { label: 'synthesize-specs', phase: 'Research', schema: SPEC_SCHEMA }, +) +if (!synth) throw new Error('spec synthesis died') + +// ── Phase 2: implement (sequential ON PURPOSE: tasks share files) ─────────── +phase('Implement') +const IMPL_SCHEMA = { + type: 'object', + required: ['status', 'summary'], + properties: { + status: { type: 'string', enum: ['committed', 'failed'] }, + commit: { type: 'string' }, + summary: { type: 'string' }, + notes: { type: 'string' }, + }, +} + +const done = [] +for (const t of tasks) { + const spec = synth.specs.find((s) => s.taskId === t.id)?.spec || t.spec + const result = await agent( + `Implement one task in the repo at ${repo}, on the CURRENT branch.\n\n` + + `Branch rules: run \`git -C ${repo} rev-parse --abbrev-ref HEAD\`; if it is not "${branch}", ` + + `check out ${branch} (create from ${base} only if it does not exist). NEVER reset the branch, ` + + `NEVER push, NEVER commit to any other branch.\n\n` + + `TASK [${t.id}] ${t.title}\n${spec}\n\n` + + `SHARED PITFALLS BRIEFING\n${synth.pitfalls}\n\n` + + (done.length + ? `ALREADY LANDED THIS RUN (your task may build on these):\n${done.map((d) => `- ${d.summary}`).join('\n')}\n\n` + : '') + + `STYLE RULES\n${styleRules}\n\n` + + `Definition of done: the task's spec is implemented, \`${testCmd}\` passes, and the work is ` + + `committed locally with a clear conventional-commit message ending with these two trailer lines:\n` + + `Co-Authored-By: Claude Fable 5 \n` + + `Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL\n` + + `Commit ONLY files your task touched. If you cannot finish, commit nothing and report status "failed" with why.`, + { label: `implement:${t.id}`, phase: 'Implement', schema: IMPL_SCHEMA }, + ) + if (result) done.push({ id: t.id, ...result }) + else done.push({ id: t.id, status: 'failed', summary: `${t.id}: agent died` }) + log(`implement ${t.id}: ${result?.status || 'died'} — ${result?.summary || ''}`) +} + +// ── Phase 3: slop-check fleet (barrier: the reviewer triages all findings) ── +phase('Slop check') +const SLOP_SCHEMA = { + type: 'object', + required: ['findings'], + properties: { + findings: { + type: 'array', + items: { + type: 'object', + required: ['file', 'claim', 'severity'], + properties: { + file: { type: 'string' }, + line: { type: 'number' }, + severity: { type: 'string', enum: ['must-fix', 'nit'] }, + claim: { type: 'string' }, + }, + }, + }, + }, +} + +const lenses = [ + ['consistency', 'Consistency and cross-contamination: does new code mirror existing naming, structure, CSS tokens and component patterns? Any diff hunks that are cosmetic-only churn or touch code unrelated to the tasks?'], + ['over-engineering', 'Over-engineering: abstractions with one caller, config nobody set, dead code, defensive branches for impossible states, anything simpler the repo already had a primitive for.'], + ['prose-and-comments', `Comments and prose: redundant comments narrating the diff, AI filler words, tone drift from the repo's comment voice, and violations of: ${styleRules}`], +] + +const slop = (await parallel(lenses.map(([key, lens]) => () => + agent( + `Read-only slop check of the branch diff in ${repo} (do NOT edit).\n` + + `Get the diff: \`git -C ${repo} diff ${base}...HEAD\` and \`git -C ${repo} log ${base}..HEAD --oneline\`.\n` + + `The diff implements:\n${taskList}\n\n` + + `Your lens: ${lens}\n\n` + + `Report only findings you can point to in the diff (file + line). No praise, no generalities. ` + + `Judge against the REPO's own idiom (read neighboring code), not abstract taste.`, + { label: `slop:${key}`, phase: 'Slop check', schema: SLOP_SCHEMA, effort: 'low' }, + ) +))).filter(Boolean).flatMap((r) => r.findings) +log(`slop fleet: ${slop.length} candidate findings`) + +// ── Phase 4 + 5: fresh review, then fix + re-verify (≤2 rounds) ───────────── +const REVIEW_SCHEMA = { + type: 'object', + required: ['mustFix', 'nits', 'buildPassed'], + properties: { + mustFix: { + type: 'array', + items: { + type: 'object', + required: ['file', 'summary', 'failureScenario'], + properties: { + file: { type: 'string' }, + line: { type: 'number' }, + summary: { type: 'string' }, + failureScenario: { type: 'string' }, + }, + }, + }, + nits: { type: 'array', items: { type: 'string' } }, + buildPassed: { type: 'boolean' }, + }, +} + +const review = (extra) => + agent( + `You are a fresh, skeptical reviewer. You did not write this code; assume nothing.\n` + + `Repo: ${repo}. Review the FULL branch diff: \`git -C ${repo} diff ${base}...HEAD\` ` + + `(plus \`git -C ${repo} log ${base}..HEAD --oneline\`). It is meant to implement:\n${taskList}\n\n` + + `Do all of:\n` + + `1. Verify each task is actually implemented as specced (read the real files, not just the diff).\n` + + `2. Hunt real bugs: state machines, async races, transaction boundaries, mobile Safari quirks. ` + + `A must-fix needs a concrete failure scenario (inputs/state → wrong outcome) you have verified ` + + `against the code — if you cannot construct one, it is a nit.\n` + + `3. Run \`${testCmd}\` in ${repo} and report whether it passes.\n` + + `4. Triage these candidate findings from a style/slop fleet — verify each against the code and ` + + `keep only the real ones (as must-fix or nit):\n${JSON.stringify(slop, null, 2)}\n` + + (extra || ''), + { label: 'review-plus', phase: 'Review', schema: REVIEW_SCHEMA, effort: 'high' }, + ) + +phase('Review') +let verdict = await review() +if (!verdict) throw new Error('reviewer died') + +let round = 0 +while (verdict.mustFix.length && round < 2) { + round++ + phase('Fix') + log(`fix round ${round}: ${verdict.mustFix.length} must-fix findings`) + await agent( + `Apply review fixes in ${repo} on the current branch (verify it is "${branch}"; never push).\n\n` + + `Must-fix findings:\n${JSON.stringify(verdict.mustFix, null, 2)}\n\n` + + `STYLE RULES\n${styleRules}\n\n` + + `Fix every finding (or, if one is genuinely wrong, leave the code alone and say why in your ` + + `final report). Run \`${testCmd}\` until green, then commit with message ` + + `"fix: address review findings (round ${round})" ending with the trailers:\n` + + `Co-Authored-By: Claude Fable 5 \n` + + `Claude-Session: https://claude.ai/code/session_01NBLkqNYmSgVYiTwUDcdogL`, + { label: `fix:round-${round}`, phase: 'Fix' }, + ) + verdict = await review( + `\nThis is re-review round ${round}: a fixer just addressed the previous must-fix list. ` + + `Confirm each fix landed without regressions before clearing it.`, + ) + if (!verdict) throw new Error('re-reviewer died') +} + +return { + tasks: done, + review: { + buildPassed: verdict.buildPassed, + remainingMustFix: verdict.mustFix, + nits: verdict.nits, + fixRounds: round, + }, +} 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/src/App.jsx b/src/App.jsx index e1cff44..80176a0 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' @@ -13,8 +14,51 @@ 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 ( + + ) +} + +// 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 ( @@ -28,13 +72,20 @@ export default function App() { {/* Floating dock: mini-player card stacked above the tab bar */}
+ {showVersion && setShowVersion(false)} />} + -
-
- - +
+
+ + +
+
+ {SORTS.map(({ value, label }) => ( + + ))} +
@@ -46,8 +91,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 +110,12 @@ function PlayGlyph() { function ShuffleGlyph() { return } +function NotesGlyph() { + return ( + + ) +} diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 516799d..210ed27 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, 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 { shuffle } from '../lib/shuffle' +import Artwork from './Artwork' import TrackRow from './TrackRow' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' @@ -12,17 +14,56 @@ 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 blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + 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.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 } = await importBackup(parsed) + showToast(`${tracks} songs · ${pls} playlists 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 +80,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 +156,7 @@ export default function PlaylistsView() { ) } -function PlaylistCard({ playlist, onOpen, onLongPress }) { +function PlaylistCard({ playlist, tracks, onOpen, onLongPress }) { const lp = useLongPress(onLongPress) return ( ) } diff --git a/src/index.css b/src/index.css index f52171a..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,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); } diff --git a/src/lib/db.js b/src/lib/db.js index 7c9bed3..ef00f30 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,115 @@ 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') + } + // 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 + // 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 () => { + 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) + } + }) + } 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() }) diff --git a/src/lib/format.js b/src/lib/format.js index 4555e9d..48e8e1d 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/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/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} ) diff --git a/vite.config.js b/vite.config.js index d71abb9..98a3404 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' @@ -57,8 +58,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(),