Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Then, for each song:
1. In the YouTube app: **Share → Copy Link**.
2. In Melody → **Search** → tap the **paste** icon. It detects the link and **copies a download command** to your clipboard.
3. Switch to **a-Shell**, paste, and run. It downloads the `.m4a`.
4. Back in Melody → **Library → Import** → pick the file from **On My iPhone → a-Shell**.
4. Back in Melody → **Library → ⋯ → Import from Files** → pick the file from **On My iPhone → a-Shell**.

That's it: the song is now in your library with cover art and lyrics, playable offline forever.

Expand All @@ -104,8 +104,8 @@ That's it: the song is now in your library with cover art and lyrics, playable o
- **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.
- **Backup:** in Library, 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:** in Library, tap **⋯ → Restore backup** 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)

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Melody" />
<meta name="theme-color" content="#110f0c" />
<meta name="theme-color" content="#15120e" />

<title>Melody</title>
</head>
Expand Down
176 changes: 176 additions & 0 deletions scripts/shoot-overhaul.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Screens for the UI overhaul at iPhone sizes, with a seeded library.
// node scripts/shoot-overhaul.mjs [outDir]
import { chromium, devices } from 'playwright'
import { createServer } from 'vite'
import { mkdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const out = resolve(process.argv[2] || resolve(here, '../screenshots'))
mkdirSync(out, { recursive: true })
const shot = (page, name) => page.screenshot({ path: resolve(out, `${name}.png`) })
const PORT = 5211
const server = await createServer({ root: resolve(here, '..'), server: { port: PORT, strictPort: true }, logLevel: 'error' })
await server.listen()

const SONGS = [
['dQw4w9WgXcQ', 'Never Gonna Give You Up', 213, 1],
['fJ9rUzIMcZQ', 'Bohemian Rhapsody', 355, 1],
['hTWKbfoikeg', 'Smells Like Teen Spirit', 301, 0],
['YkgkThdzX-8', 'Imagine', 183, 0],
['btPJPFnesV4', 'Eye of the Tiger', 244, 0],
['kXYiU_JCYtU', 'Numb', 187, 1],
['1w7OgIMMRc4', "Sweet Child O' Mine", 356, 0],
['Zi_XLOBDo_Y', 'Billie Jean', 294, 0],
]
const SERIES = [['001', 'Welcome', 60], ['018', 'Long Talk', 3852], ['036', 'Interview', 1320], ['037', 'Wrap Up', 1651]]

async function seed(page) {
await page.evaluate(async ({ SONGS, SERIES }) => {
const { db } = await import('/src/lib/db.js')
await db.tracks.clear()
await db.playlists.clear()
const now = Date.now()
const rows = []
const sample = (i) => ({ src: `/samples/sample-${'abcde'[i % 5]}.wav`, srcType: 'url' })
SERIES.forEach(([n, name, d], i) => rows.push({
id: `q-${n}`, title: `${n} ${name}`, artist: 'Imported', duration: d, thumbnailUrl: null,
starred: 0, playCount: 0, dateAdded: now - (n === '037' ? 0 : n === '018' ? 3000 : 90000 + i), ...sample(i),
}))
SONGS.forEach(([id, title, d, star], i) => rows.push({
id: `yt-${id}`, title, artist: 'Imported', duration: d, youtubeId: id,
thumbnailUrl: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`, starred: star, playCount: 0,
dateAdded: now - (i < 2 ? 1000 + i : 4000 + i), ...sample(i),
}))
await db.tracks.bulkAdd(rows)
await db.playlists.add({ name: 'Morning Show', trackIds: SERIES.map(([n]) => `q-${n}`), createdAt: now })
await db.playlists.add({ name: 'Gym', trackIds: SONGS.slice(2, 7).map(([id]) => `yt-${id}`), createdAt: now - 5 })
await db.playlists.add({ name: 'Road trip', trackIds: [`yt-${SONGS[0][0]}`], createdAt: now - 10 })
}, { SONGS, SERIES })
}

async function open(size) {
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] })
const ctx = await browser.newContext(size)
// Emulate the notch and home indicator the headless browser doesn't have.
await ctx.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.style.setProperty('--sat', '47px')
document.documentElement.style.setProperty('--sab', '34px')
})
})
const page = await ctx.newPage()
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message))
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
await seed(page)
await page.waitForTimeout(800)
return { browser, page }
}

// Mouse drag of `dy` px starting at the element's center.
async function drag(page, sel, dy) {
const b = await page.locator(sel).first().boundingBox()
const x = b.x + b.width / 2
const y = b.y + Math.min(b.height / 2, 200)
await page.mouse.move(x, y)
await page.mouse.down()
for (let i = 1; i <= 8; i++) await page.mouse.move(x, y + (dy * i) / 8)
await page.mouse.up()
}

const tab = (page, name) => page.locator('.tabs').getByRole('button', { name }).click()

const { browser, page } = await open({ ...devices['iPhone 13'] })
try {
// Play episode 037 from its playlist so "Playing from" names it.
await tab(page, 'Playlists')
await page.waitForTimeout(400)
await shot(page, '06-playlists')
await page.locator('.plcard', { hasText: 'Morning Show' }).click()
await page.waitForTimeout(500)
await page.locator('.row__title', { hasText: 'Wrap Up' }).click()
await page.waitForTimeout(900)
await shot(page, '07-playlist-detail')

await tab(page, 'Library')
await page.waitForTimeout(600)
await shot(page, '01-library')

await page.locator('.mini').click()
await page.waitForTimeout(900)
await shot(page, '03-np-series')

await page.getByLabel('More').click()
await page.waitForTimeout(600)
await page.getByRole('radio', { name: '1.5x speed' }).click()
await page.waitForTimeout(300)
await shot(page, '05-np-menu')
console.log('rate after 1.5:', await page.evaluate(() => document.querySelector('audio').playbackRate))
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForTimeout(600)

// Drag down past the threshold closes Now Playing.
await drag(page, '.now__art', 260)
await page.waitForTimeout(700)
console.log('now open after drag-down:', await page.locator('.now').count())

// A short drag springs back.
await page.locator('.mini').click()
await page.waitForTimeout(700)
await drag(page, '.now__art', 60)
await page.waitForTimeout(700)
console.log('now open after short drag:', await page.locator('.now').count())
await page.keyboard.press('Escape')
await page.waitForTimeout(700)

// A regular song, played from the library.
await page.locator('.row__title', { hasText: 'Never Gonna' }).click()
await page.waitForTimeout(1500)
// swipe up on the mini player opens Now Playing
await drag(page, '.mini__t', -80)
await page.waitForTimeout(1200)
console.log('now open after mini swipe-up:', await page.locator('.now').count())
console.log('rate on next track:', await page.evaluate(() => document.querySelector('audio').playbackRate))
await shot(page, '02-np-song')
await page.keyboard.press('Escape')
await page.waitForTimeout(700)

await page.getByLabel('Library options').click()
await page.waitForTimeout(600)
await shot(page, '04-library-menu')
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForTimeout(500)

// Hold a row for the track sheet.
await page.locator('.row__title', { hasText: 'Imagine' }).click({ button: 'right' })
await page.waitForTimeout(600)
await shot(page, '09-track-sheet')
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForTimeout(500)

await tab(page, 'Search')
await page.waitForTimeout(300)
await shot(page, '08b-search-idle')
await page.locator('.searchbar__input').fill('ne')
await page.waitForTimeout(500)
await shot(page, '08-search')
} finally {
await browser.close()
}

// iPhone SE: the long-form Now Playing has the most to fit.
{
const { browser, page } = await open({ ...devices['iPhone SE (3rd gen)'] })
try {
await page.locator('.row__title', { hasText: 'Wrap Up' }).click()
await page.waitForTimeout(900)
await page.locator('.mini').click()
await page.waitForTimeout(900)
await shot(page, '10-se-np-series')
} finally {
await browser.close()
}
}
await server.close()
41 changes: 41 additions & 0 deletions scripts/shoot-speed.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Now Playing's speed control applies to every track and survives a reload.
// node scripts/shoot-speed.mjs
import { chromium, devices } from 'playwright'
import { createServer } from 'vite'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const shot = (name) => resolve(here, `../screenshots/${name}.png`)
const server = await createServer({ root: resolve(here, '..'), server: { port: 5193, strictPort: true }, logLevel: 'error' })
await server.listen()

const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] })
const page = await (await browser.newContext({ ...devices['iPhone 13'] })).newPage()
try {
await page.goto('http://localhost:5193', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
await page.locator('.row__main').first().click()
await page.waitForTimeout(600)
await page.locator('.mini').click()
await page.waitForTimeout(600)
await page.getByLabel('More').click()
await page.waitForTimeout(500)
await page.screenshot({ path: shot('15-speed-1x') })

await page.getByRole('radio', { name: '1.5x speed' }).click()
await page.waitForTimeout(400)
const rate = await page.evaluate(() => document.querySelector('audio').playbackRate)
console.log('playbackRate on a regular track:', rate)
await page.screenshot({ path: shot('16-speed-1.5x') })

await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(800)
await page.locator('.row__main').nth(1).click()
await page.waitForTimeout(800)
console.log('after reload, saved:', await page.evaluate(() => localStorage.getItem('melody.speed')),
'rate:', await page.evaluate(() => document.querySelector('audio').playbackRate))
} finally {
await browser.close()
await server.close()
}
6 changes: 3 additions & 3 deletions scripts/test-features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ await page.waitForTimeout(700)

// import a file whose name embeds a real video id (as our yt-dlp cmd produces)
const buf = readFileSync(resolve(here, '../public/samples/sample-d.wav'))
await page.locator('input[type=file]').setInputFiles({
await page.locator('input[type=file][multiple]').setInputFiles({
name: 'Test Song [dQw4w9WgXcQ].wav', mimeType: 'audio/wav', buffer: buf,
})
await page.waitForTimeout(1500)
Expand All @@ -31,8 +31,8 @@ await page.waitForTimeout(1500)
await page.locator('.mini').click()
await page.waitForTimeout(800)

const fill = await page.locator(".scrub input[type='range']").evaluate((el) => el.style.background)
console.log('scrubber fill:', fill ? fill.slice(0, 60) : '(none)')
const fill = await page.locator('.scrub__track i').evaluate((el) => el.style.transform)
console.log('scrubber fill:', fill || '(none)')

// toggle lyrics
await page.getByLabel('Lyrics').click()
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-import.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ await page.waitForTimeout(800)

// Import a file — reuse a sample WAV but give it a clean display name.
const buf = readFileSync(resolve(here, '../public/samples/sample-d.wav'))
await page.locator('input[type=file]').setInputFiles({
await page.locator('input[type=file][multiple]').setInputFiles({
name: 'Imported Test.wav',
mimeType: 'audio/wav',
buffer: buf,
Expand Down
26 changes: 15 additions & 11 deletions scripts/test-series.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const file = (name, hz) => ({ name, mimeType: 'audio/wav', buffer: wav(90, hz) }
try {
await page.goto('http://localhost:5193', { waitUntil: 'networkidle' })
// Picked out of order, and 10 sorts after 2 only when compared as numbers.
await page.locator('input[type=file]').setInputFiles([
await page.locator('input[type=file][multiple]').setInputFiles([
file('Morning Show - 010 Wrap Up.wav', 300),
file('Morning Show - 002 Second.wav', 320),
file('Morning Show - 001 Welcome.wav', 340),
Expand All @@ -45,10 +45,12 @@ try {
await page.getByText('is ready, in order').waitFor({ timeout: 20000 })
check('import reports the series playlist', true)

await page.getByRole('button', { name: 'Playlists' }).click()
await page.getByRole('button', { name: 'Playlists', exact: true }).click()
await page.locator('.plcard', { hasText: 'Morning Show' }).click()
// Numbered tracks show their number as the artwork tile, the title without it.
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(','))
const tiles = await page.locator('.row .tile b').allTextContents()
check('playlist is in episode order', titles.join(',') === 'Welcome,Second,Third,Wrap Up' && tiles.join(',') === '001,002,003,010', `${tiles.join(',')} / ${titles.join(',')}`)

// Play the second episode, move 40s in, pause.
await page.locator('.row__main').nth(1).click()
Expand All @@ -59,34 +61,36 @@ try {
await page.waitForTimeout(500)

await page.reload({ waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Playlists' }).click()
await page.getByRole('button', { name: 'Playlists', exact: true }).click()
await page.locator('.plcard', { hasText: 'Morning Show' }).click()
const resume = page.locator('.resume')
await resume.waitFor({ timeout: 5000 })
const label = await resume.textContent()
check('Resume shows the right episode and time', label.includes('002 Second') && label.includes('0:40'), label)
check('Resume shows the right episode and time', label.includes('Second') && label.includes('0:40'), label)

await resume.click()
await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 39, null, { timeout: 10000 }).catch(() => {})
const t = await page.evaluate(() => document.querySelector('audio').currentTime)
const title = await page.locator('.mini__title').textContent()
check('Resume plays that episode from that spot', title === '002 Second' && t >= 39, `${title} @ ${t.toFixed(1)}`)
check('Resume plays that episode from that spot', title === 'Second' && t >= 39, `${title} @ ${t.toFixed(1)}`)

// Finishing an episode moves the bookmark to the next one.
await page.evaluate(() => { const a = document.querySelector('audio'); a.currentTime = a.duration - 0.5 })
await page.waitForFunction(() => document.querySelector('.mini__title')?.textContent === '003 Third', null, { timeout: 10000 }).catch(() => {})
await page.waitForFunction(() => document.querySelector('.mini__title')?.textContent === 'Third', null, { timeout: 10000 }).catch(() => {})
await page.waitForTimeout(800)
await page.locator('.mini').getByLabel('Pause').click()
await page.waitForTimeout(500)
const next = await page.locator('.resume').textContent()
check('bookmark follows playback to the next episode', next.includes('003 Third'), next)
check('bookmark follows playback to the next episode', next.includes('Third'), next)
await page.screenshot({ path: resolve(here, '../screenshots/14-series-resume.png') })

// Re-importing the same files adds nothing and keeps one playlist.
await page.getByRole('button', { name: 'Library' }).click()
await page.locator('input[type=file]').setInputFiles([file('Morning Show - 001 Welcome.wav', 340)])
await page.getByRole('button', { name: 'Library', exact: true }).click()
await page.locator('input[type=file][multiple]').setInputFiles([file('Morning Show - 001 Welcome.wav', 340)])
await page.waitForTimeout(1500)
await page.getByRole('button', { name: 'Playlists' }).click()
// The tab keeps its place (the open playlist); step back to the list.
await page.getByRole('button', { name: 'Playlists', exact: true }).click()
await page.getByLabel('Back').click()
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(' / '))
Expand Down
Loading
Loading