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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 101 additions & 0 deletions scripts/shoot-audit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Screenshots of every screen on the production build, with sample songs.
// node scripts/shoot-audit.mjs <output-folder>
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()
}
2 changes: 1 addition & 1 deletion scripts/test-features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions scripts/test-series.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(','))

Expand All @@ -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()
Expand All @@ -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++
Expand Down
59 changes: 55 additions & 4 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<button className="versionpeek" onClick={onDismiss}>
build {commit} · {built.toLocaleDateString()} {built.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</button>
)
}

// 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 (
<button className="toast" role="status" onClick={onDismiss}>
{message}
</button>
)
}

// 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 && <Toast key={toast.id} message={toast.message} onDismiss={clearToast} />
}

export default function App() {
const [tab, setTab] = useState('library')
const [showVersion, setShowVersion] = useState(false)
const { handlers: versionPress, suppressClick } = useLongPress(() => setShowVersion(true))

return (
<UIProvider>
Expand All @@ -31,13 +75,20 @@ export default function App() {

{/* Floating dock: mini-player card stacked above the tab bar */}
<div className="dock">
{showVersion && <VersionPeek onDismiss={() => setShowVersion(false)} />}
<ToastHost />
<Player />
<nav className="tabbar">
{/* Long-press handlers live on the nav; suppressClick keeps the
long-press from also switching tabs on release. */}
<nav className="tabbar" {...versionPress}>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
className={`tab ${tab === id ? 'tab--active' : ''}`}
onClick={() => setTab(id)}
onClick={() => {
if (suppressClick()) return
setTab(id)
}}
>
<Icon />
<span>{label}</span>
Expand Down
13 changes: 11 additions & 2 deletions src/components/AddToPlaylistSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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'

// 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)
Expand Down Expand Up @@ -52,6 +52,15 @@ export default function AddToPlaylistSheet() {
<Glyph filled={!!addTarget.starred} d="M9 1.5l2.2 4.5 5 .7-3.6 3.5.85 5L9 12.9 4.7 15.2l.85-5L2 6.7l5-.7z" />
</button>

{fromPlaylist && (
<button
className="sheet__item"
onClick={() => { removeFromPlaylist(fromPlaylist.id, addTarget.id).catch(() => {}); close() }}
>
<span>Remove from “{fromPlaylist.name}”</span><Glyph d="M4 9h10" />
</button>
)}

<p className="sheet__label">Add to playlist</p>
<button className="sheet__item sheet__item--new" onClick={() => setCreating(true)}>
+ New playlist
Expand Down
36 changes: 23 additions & 13 deletions src/components/Artwork.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<img
className="artwork"
src={track.thumbnailUrl}
alt=""
style={style}
onError={() => setFailed(true)}
/>
<span className="artwork artwork--img" style={style}>
<img
src={src}
alt=""
// hqdefault still carries its letterbox; zoom past the bars.
className={src.endsWith('/hqdefault.jpg') ? 'artwork__zoom' : undefined}
onError={() => 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) }}
/>
</span>
)
}
// Keep the hue in a warm band (deep amber → sienna → olive) and low-ish
Expand Down
Loading
Loading