diff --git a/README.md b/README.md index fec70e7..5849f3d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) diff --git a/index.html b/index.html index 858f74b..09aaf31 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ - + Melody diff --git a/scripts/shoot-overhaul.mjs b/scripts/shoot-overhaul.mjs new file mode 100644 index 0000000..e977100 --- /dev/null +++ b/scripts/shoot-overhaul.mjs @@ -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() diff --git a/scripts/shoot-speed.mjs b/scripts/shoot-speed.mjs new file mode 100644 index 0000000..4207e2a --- /dev/null +++ b/scripts/shoot-speed.mjs @@ -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() +} diff --git a/scripts/test-features.mjs b/scripts/test-features.mjs index 7bce94a..a078330 100644 --- a/scripts/test-features.mjs +++ b/scripts/test-features.mjs @@ -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) @@ -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() diff --git a/scripts/test-import.mjs b/scripts/test-import.mjs index c45310c..aa93033 100644 --- a/scripts/test-import.mjs +++ b/scripts/test-import.mjs @@ -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, diff --git a/scripts/test-series.mjs b/scripts/test-series.mjs index bbe6311..ddcbe02 100644 --- a/scripts/test-series.mjs +++ b/scripts/test-series.mjs @@ -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), @@ -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() @@ -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(' / ')) diff --git a/src/App.jsx b/src/App.jsx index ab374bb..369c234 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,25 +1,23 @@ -import { useEffect, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { PlayerProvider } from './state/PlayerProvider' 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' -import PodcastsView from './components/PodcastsView' import Player from './components/Player' import AddToPlaylistSheet from './components/AddToPlaylistSheet' +import Icon from './components/Icon' +// Podcasts stay in the codebase (and in backups) but are off the tab bar. const TABS = [ - { id: 'search', label: 'Search', icon: SearchIcon }, - { id: 'library', label: 'Library', icon: LibraryIcon }, - { id: 'playlists', label: 'Playlists', icon: PlaylistIcon }, - { id: 'podcasts', label: 'Podcasts', icon: PodcastIcon }, + { id: 'library', label: 'Library', icon: 'tab_library' }, + { id: 'search', label: 'Search', icon: 'tab_search' }, + { id: 'playlists', label: 'Playlists', icon: 'tab_playlists' }, ] -// 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. +// Hidden build readout: long-press the tab bar to see which deploy is running +// (commit + build time), to confirm the PWA auto-updated. Tap dismisses. function VersionPeek({ onDismiss }) { useEffect(() => { const t = setTimeout(onDismiss, 5000) @@ -35,11 +33,9 @@ function VersionPeek({ onDismiss }) { } // Transient status pill above the dock (import summaries, restore results). -// Same lifecycle as VersionPeek: auto-hides, tap dismisses. Keyed by toast id -// in ToastHost so a repeat of the same message restarts the timer. function Toast({ message, onDismiss }) { useEffect(() => { - const t = setTimeout(onDismiss, 4000) + const t = setTimeout(onDismiss, 5000) return () => clearTimeout(t) }, [onDismiss]) return ( @@ -49,70 +45,84 @@ function Toast({ message, onDismiss }) { ) } -// 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') +function Shell() { + const { tab, setTab, playlistId, setPlaylistId } = useUI() + const [query, setQuery] = useState('') // kept across tab switches const [showVersion, setShowVersion] = useState(false) const { handlers: versionPress, suppressClick } = useLongPress(() => setShowVersion(true)) + const appRef = useRef(null) + const dockRef = useRef(null) + const scrollRef = useRef(null) + const scrollTops = useRef({}) + + // Content scrolls under the translucent dock; pad it by the dock's height. + useLayoutEffect(() => { + const dock = dockRef.current + const set = () => appRef.current?.style.setProperty('--dock-h', `${dock.offsetHeight}px`) + set() + const ro = new ResizeObserver(set) + ro.observe(dock) + return () => ro.disconnect() + }, []) + + // Each tab comes back where you left it. + useLayoutEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollTops.current[tab] || 0 + }, [tab]) + + const pick = (id) => { + if (suppressClick()) return + const el = scrollRef.current + if (id === tab) { + // iOS convention: re-tapping the active tab pops to its root, then to the top. + if (id === 'playlists' && playlistId != null) setPlaylistId(null) + else el?.scrollTo({ top: 0, behavior: 'smooth' }) + return + } + if (el) scrollTops.current[tab] = el.scrollTop + setTab(id) + } return ( - - -
-
- {tab === 'search' && } - {tab === 'library' && } - {tab === 'playlists' && } - {tab === 'podcasts' && } -
+
+
+ {tab === 'library' && } + {tab === 'search' && } + {tab === 'playlists' && } +
- {/* Floating dock: mini-player card stacked above the tab bar */} -
- {showVersion && setShowVersion(false)} />} - - - {/* Long-press handlers live on the nav; suppressClick keeps the - long-press from also switching tabs on release. */} - -
+
+ {showVersion && setShowVersion(false)} />} + + + {/* Long-press handlers live on the nav; suppressClick keeps the + long-press from also switching tabs on release. */} + +
- -
- - + +
) } -function SearchIcon() { - return -} -function LibraryIcon() { - return -} -function PlaylistIcon() { - return -} -function PodcastIcon() { - return +export default function App() { + return ( + + + + + + ) } diff --git a/src/components/AddToPlaylistSheet.jsx b/src/components/AddToPlaylistSheet.jsx index 71ce907..0a7016d 100644 --- a/src/components/AddToPlaylistSheet.jsx +++ b/src/components/AddToPlaylistSheet.jsx @@ -3,101 +3,104 @@ import { useUI } from '../state/UIProvider' import { usePlaylists } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' import { addToPlaylist, createPlaylist, toggleStar, deleteTrack, removeFromPlaylist } from '../lib/db' +import { displayTitle } from '../lib/series' +import Sheet from './Sheet' +import Artwork from './Artwork' +import Icon from './Icon' 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. +// Track sheet (hold a row, or ⋯ → Add to playlist in Now Playing): quick +// playback actions, the playlist picker, and delete. Rendered once at the root. export default function AddToPlaylistSheet() { const { addTarget, fromPlaylist, closeAddToPlaylist } = useUI() const playlists = usePlaylists() - const { playNext, addToQueue } = usePlayer() + const { playNext, addToQueue, current } = usePlayer() const [creating, setCreating] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) - // This host is always mounted (returns null when closed), so key the focus on - // whether a track is open, not on mount. - const dialog = useDialog(closeAddToPlaylist, { active: !!addTarget }) - if (!addTarget) return null + // The sheet unmounts on close, but the modals it opens outlive it. + const [target, setTarget] = useState(null) + if (!addTarget && !target) return null + const track = addTarget || target - const close = closeAddToPlaylist const addToList = async (id) => { - await addToPlaylist(id, addTarget.id) - close() + await addToPlaylist(id, track.id) } return ( <> -
-
e.stopPropagation()} - > -
-

{addTarget.title}

+ {addTarget && ( + + {(close) => ( + <> +
+
+ + {displayTitle(track)} +
+ {current && current.id !== track.id && ( + <> + + + + )} + + {fromPlaylist && ( + + )} +
- - - +
+

Add to playlist

+ + {(playlists || []).map((p) => { + const has = p.trackIds.includes(track.id) + return ( + + ) + })} +
- {fromPlaylist && ( - - )} - -

Add to playlist

- - {(playlists || []).map((p) => { - const has = p.trackIds.includes(addTarget.id) - return ( - - ) - })} + {track.srcType === 'idb' && ( +
+ +
+ )} - {addTarget.srcType === 'idb' && ( - +
+ +
+ )} - - -
-
+ + )} {creating && ( setCreating(false)} + onClose={() => { setCreating(false); setTarget(null) }} onSubmit={async (name) => { const id = await createPlaylist(name) - setCreating(false) await addToList(id) + setCreating(false) + setTarget(null) }} /> )} @@ -105,24 +108,16 @@ export default function AddToPlaylistSheet() { {confirmDelete && ( { - await deleteTrack(addTarget.id) + await deleteTrack(track.id) setConfirmDelete(false) - close() + setTarget(null) }} - onClose={() => setConfirmDelete(false)} + onClose={() => { setConfirmDelete(false); setTarget(null) }} /> )} ) } - -function Glyph({ d, filled }) { - return ( - - - - ) -} diff --git a/src/components/Artwork.jsx b/src/components/Artwork.jsx index e610370..dd3c7b1 100644 --- a/src/components/Artwork.jsx +++ b/src/components/Artwork.jsx @@ -1,27 +1,23 @@ import { useState, useEffect } from 'react' import { ytThumbId, ytArtworkChain } from '../lib/youtube' +import { seriesTile } from '../lib/series' +import Icon from './Icon' -// Square artwork. Uses the YouTube thumbnail when present; otherwise renders a -// deterministic gradient tile (same track → same colors) so the library still -// looks intentional for the sample tones and any thumbnail-less tracks. +// Square artwork. YouTube thumbnail when present; a numbered series track gets +// its number tile; anything else a warm gradient tile (same track → same +// colors) so the library still looks intentional. function hueFromId(id = '') { let h = 0 for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360 return h } -// Warm glow color for the Now Playing backdrop, derived from the same id. -export function warmGlow(id) { - const h = 16 + (hueFromId(id) % 30) // 16°–46°: amber → orange → sienna - return `hsl(${h} 52% 22%)` -} - -export default function Artwork({ track, size = 48, radius = 8 }) { +export default function Artwork({ track, size = 44, radius = 6 }) { const style = { width: size, height: size, borderRadius: radius } 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 + // Walk down the chain on failure; past the end means the fallback 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. @@ -31,7 +27,7 @@ export default function Artwork({ track, size = 48, radius = 8 }) { const src = chain[step] if (src) { return ( - + ) } - // Keep the hue in a warm band (deep amber → sienna → olive) and low-ish - // saturation so placeholders stay cohesive with the palette — never rainbow. - const h = 16 + (hueFromId(track?.id) % 30) // 16°–46°: amber → orange → sienna + + const tile = seriesTile(track) + if (tile) { + return ( + + ) + } + + // Keep the hue in a warm band (amber → sienna) at low saturation so + // placeholders stay cohesive with the palette, never rainbow. + const h = 16 + (hueFromId(track?.id) % 30) return ( - + + ) } diff --git a/src/components/ConfirmModal.jsx b/src/components/ConfirmModal.jsx index cb0e67d..9825177 100644 --- a/src/components/ConfirmModal.jsx +++ b/src/components/ConfirmModal.jsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom' import { useDialog } from '../lib/useDialog' // Destructive confirm dialog (e.g. delete a playlist) — replaces window.confirm. @@ -10,7 +11,8 @@ export default function ConfirmModal({ }) { const dialog = useDialog(onClose) - return ( + // Portaled so it never inherits the page's Now Playing recede transform. + return createPortal(
{confirmLabel}
-
+ , + document.body, ) } diff --git a/src/components/Icon.jsx b/src/components/Icon.jsx new file mode 100644 index 0000000..f039133 --- /dev/null +++ b/src/components/Icon.jsx @@ -0,0 +1,58 @@ +// One SF-Symbols-like set: filled glyphs for transport, 2px round strokes +// elsewhere. +const S = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' +const NUM = 'text-anchor="middle" font-size="8.4" font-weight="750" style="font-stretch:75%;font-family:inherit" fill="currentColor"' +const NEXT = '' +const REPEAT = `` +const STAR = 'M12 2.9l2.75 5.6 6.15.9-4.45 4.35 1.05 6.15L12 17l-5.5 2.9 1.05-6.15L3.1 9.4l6.15-.9z' + +const ICONS = { + play: '', + pause: '', + next: NEXT, + prev: `${NEXT}`, + back15: `15`, + fwd30: `30`, + shuffle: ``, + repeat: REPEAT, + repeat1: `${REPEAT}`, + lyrics: ``, + star: ``, + starline: ``, + more: '', + search: ``, + plus: ``, + minus: ``, + sort: ``, + import: ``, + export: ``, + restore: ``, + tab_library: '', + tab_search: ``, + tab_playlists: ``, + check: ``, + chev: ``, + back: ``, + trash: ``, + pencil: ``, + playnext: ``, + queue: ``, + paste: ``, + xfill: '', + note: ``, +} + +export default function Icon({ name, size = 22, className, style }) { + return ( + + ) +} diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index 2e40fba..d4c1ca1 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -1,130 +1,137 @@ import { useState } from 'react' import { useTracks } from '../state/useLibrary' -import { usePlayer } from '../state/PlayerProvider' -import { shuffle } from '../lib/shuffle' -import { summarize } from '../lib/format' +import { useImporter } from '../lib/useImporter' +import { useBackup } from '../lib/useBackup' import TrackRow from './TrackRow' -import ImportButton from './ImportButton' +import Sheet from './Sheet' +import Icon from './Icon' const SORT_KEY = 'melody:librarySort' +const FAV_KEY = 'melody:libraryFavorites' const SORTS = [ - { value: 'recent', label: 'Recent' }, + { value: 'recent', label: 'Recently added' }, { value: 'plays', label: 'Most played' }, - { value: 'alpha', label: 'A–Z' }, + { value: 'alpha', label: 'Title' }, ] // Reads must be try/catch-wrapped (private-mode Safari throws) and live in a // pure lazy initializer so StrictMode's double render stays side-effect free. -function readSavedSort() { +function readSaved(key, fallback) { try { - const v = localStorage.getItem(SORT_KEY) - return v === 'plays' || v === 'alpha' ? v : 'recent' + return localStorage.getItem(key) ?? fallback } catch { - return 'recent' + return fallback + } +} +function save(key, value) { + try { + localStorage.setItem(key, value) + } catch { + // Private-mode Safari: the choice still applies for this session. } } export default function LibraryView() { const tracks = useTracks() - const { playQueue } = usePlayer() - const [showStarred, setShowStarred] = useState(false) - const [sort, setSort] = useState(readSavedSort) - - const pickSort = (value) => { - setSort(value) - try { - localStorage.setItem(SORT_KEY, value) - } catch { - // Private-mode Safari: the choice still applies for this session. - } - } + const importer = useImporter() + const backup = useBackup() + const [menu, setMenu] = useState(false) + const [sort, setSort] = useState(() => { + const v = readSaved(SORT_KEY, 'recent') + return SORTS.some((o) => o.value === v) ? v : 'recent' + }) + const [favOnly, setFavOnly] = useState(() => readSaved(FAV_KEY, '0') === '1') - if (tracks === undefined) return

Loading…

+ const pickSort = (value) => { setSort(value); save(SORT_KEY, value) } + const toggleFav = () => { setFavOnly((f) => { save(FAV_KEY, f ? '0' : '1'); return !f }) } - const filtered = showStarred ? tracks.filter((t) => t.starred) : tracks + const filtered = tracks && (favOnly ? tracks.filter((t) => t.starred) : tracks) // useTracks already returns dateAdded-desc, so 'recent' is the array as-is. - // The others must copy first — live-query arrays must never be sorted in place. - const shown = - sort === 'plays' + // The others must copy first: live-query arrays must never be sorted in place. + const shown = !filtered + ? [] + : sort === 'plays' ? [...filtered].sort((a, b) => (b.playCount || 0) - (a.playCount || 0) || b.dateAdded - a.dateAdded) : sort === 'alpha' - ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' })) + ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base', numeric: true })) : filtered + const source = { name: favOnly ? 'Favorites' : 'Library' } return (
-
-
-
-

Your music

-

Library

- {tracks.length > 0 &&

{summarize(tracks)}

} -
- {/* 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. */} - -
- )} +
+
+ {importer.progress && ( +

+ Importing {Math.min(importer.progress.done + 1, importer.progress.total)} of {importer.progress.total} + +

+ )} - {shown.length > 0 ? ( - <> -
- - -
-
- {shown.map((t) => ( - - ))} -
- - ) : showStarred ? ( -

No favorites yet — tap the star on any track.

+ {tracks === undefined ? null : shown.length > 0 ? ( +
+ {shown.map((t) => ( + + ))} +
+ ) : favOnly && tracks.length > 0 ? ( +
+ +

No favorites yet

+

Tap the star in Now Playing to keep a song here.

+ +
) : (
- -

Nothing here yet. Import songs from the Files app to build your library.

- + +

Your library is empty

+

Download songs in a-Shell, then import them from the Files app.

+
)} -
- ) -} -function PlayGlyph() { - return -} -function ShuffleGlyph() { - return -} -function NotesGlyph() { - return ( - + {importer.input} + {backup.input} + + {menu && ( + // Pickers and the share sheet need the tap's user activation, so each + // action runs first and the sheet animates away after. + setMenu(false)} label="Library options"> + {(close) => ( + <> +
+ + + +
+
+ {SORTS.map((o) => ( + + ))} + +
+
+ +
+ + )} +
+ )} + ) } -function SortGlyph() { - return -} diff --git a/src/components/Player.jsx b/src/components/Player.jsx index 7298a67..9d386e9 100644 --- a/src/components/Player.jsx +++ b/src/components/Player.jsx @@ -1,165 +1,436 @@ -import { useState, useEffect, useRef } from 'react' -import { usePlayer } from '../state/PlayerProvider' +import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { usePlayer, SPEEDS } from '../state/PlayerProvider' +import { useUI } from '../state/UIProvider' import { toggleStar } from '../lib/db' import { useLyrics, useTrack } from '../state/useLibrary' import { ensureLyrics, researchLyrics, activeLine } from '../lib/lyrics' -import Artwork, { warmGlow } from './Artwork' +import { ytThumbId } from '../lib/youtube' +import { seriesTile, displayTitle } from '../lib/series' +import { useDragDismiss } from '../lib/useDragDismiss' +import { motionMs, EASE_SHEET } from '../lib/motion' import { formatTime } from '../lib/format' +import Artwork from './Artwork' +import Icon from './Icon' +import Sheet from './Sheet' export default function Player() { const p = usePlayer() - const [expanded, setExpanded] = useState(false) + const [open, setOpen] = useState(false) + const mini = useRef(null) + useSwipeUp(mini, !!p.current, () => setOpen(true)) if (!p.current) return null // nothing playing → no bar return ( <> - {/* Mini bar — a button-like card, but a DIV so the inner play/next - - + + + + - - {expanded && setExpanded(false)} />} + {open && setOpen(false)} />} ) } -function NowPlaying({ p, onClose }) { - const loopLabel = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' }[p.loopMode] - const [showLyrics, setShowLyrics] = useState(false) +// Swipe up on the mini player opens Now Playing; a tap still works via click. +// Attached once per mount: progress re-renders must not reset a swipe midway. +function useSwipeUp(ref, mounted, onOpen) { + const cb = useRef(onOpen) + cb.current = onOpen + useEffect(() => { + const el = ref.current + if (!el) return + let start = null + const down = (e) => { start = { x: e.clientX, y: e.clientY } } + const move = (e) => { + if (!start) return + const dy = e.clientY - start.y + if (dy < -28 && Math.abs(dy) > Math.abs(e.clientX - start.x)) { + start = null + cb.current() + } + } + const up = () => { start = null } + el.addEventListener('pointerdown', down) + el.addEventListener('pointermove', move) + el.addEventListener('pointerup', up) + el.addEventListener('pointercancel', up) + return () => { + el.removeEventListener('pointerdown', down) + el.removeEventListener('pointermove', move) + el.removeEventListener('pointerup', up) + el.removeEventListener('pointercancel', up) + } + }, [ref, mounted]) +} + +// Page-behind recede: 1 = Now Playing fully up. Drives .app via --np. +function setBehind(v, animate) { + const root = document.documentElement + root.classList.toggle('np-anim', animate) + root.style.setProperty('--np', String(v)) +} + +function NowPlaying({ p, onClosed }) { + const sheet = useRef(null) + const closed = useRef(false) + const [lyricsOn, setLyricsOn] = useState(false) + const [menu, setMenu] = useState(false) + const { goToPlaylist, openAddToPlaylist } = useUI() const lyrics = useLyrics(p.current.id) - const live = useTrack(p.current.id) // live star state (snapshot can be stale) + const live = useTrack(p.current.id) // live star state (the queue snapshot can be stale) const starred = live ? live.starred : p.current.starred + const tile = seriesTile(p.current) + + const slide = (to, ms) => { + const el = sheet.current + if (!el) return + el.style.transition = ms ? `transform ${ms}ms ${EASE_SHEET}` : 'none' + el.style.transform = to + } + + useLayoutEffect(() => { + slide('translateY(100%)', 0) + sheet.current.getBoundingClientRect() + slide('', motionMs(420)) + setBehind(1, true) + return () => setBehind(0, false) + }, []) + + // Every close path animates down, then unmounts. + const close = useCallback((then) => { + if (closed.current) return + closed.current = true + const ms = motionMs(340) + slide('translateY(100%)', ms) + setBehind(0, true) + setTimeout(() => { + document.documentElement.classList.remove('np-anim') + onClosed() + if (typeof then === 'function') then() + }, ms) + }, [onClosed]) + + useDragDismiss(sheet, { + exclude: 'button, input, .scrub, [data-nodrag]', + onDrag: (d) => { + slide(`translateY(${d}px)`, 0) + setBehind(Math.max(0, 1 - d / (sheet.current.offsetHeight || 800)), false) + }, + onEnd: (d, v) => { + if (d > 140 || v > 0.55) close() + else { + slide('', motionMs(380)) + setBehind(1, true) + } + }, + }) + + // Escape closes (hardware keyboards), unless a sheet on top takes it. + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape' && !menu && !document.querySelector('.sheet')) close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [menu, close]) // Fetch + cache lyrics when the track changes (no-op if already cached). useEffect(() => { ensureLyrics(p.current).catch(() => {}) }, [p.current.id]) - const pct = p.duration ? (p.progress / p.duration) * 100 : 0 + const sourceName = p.source?.name || 'Library' + const loopIcon = p.loopMode === 'one' ? 'repeat1' : 'repeat' + const loopLabel = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' }[p.loopMode] - return ( -
-
- - Now Playing - -
+ return createPortal( +
+ +