diff --git a/scripts/test-series.mjs b/scripts/test-series.mjs new file mode 100644 index 0000000..32a24f6 --- /dev/null +++ b/scripts/test-series.mjs @@ -0,0 +1,99 @@ +// Numbered imports become an ordered playlist, and a playlist resumes on the +// track and spot where you left it, across a reload. +// Starts its own Vite dev server: node scripts/test-series.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 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() +page.on('pageerror', (e) => console.log('PAGEERROR:', e.message)) + +let failed = 0 +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) + if (!ok) failed++ +} + +// A quiet 90-second 8 kHz WAV with a faint tone so each file is distinct. +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 i = 0; i < n; i++) b[44 + i] = 128 + Math.round(8 * Math.sin((2 * Math.PI * hz * i) / rate)) + return b +} +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([ + file('Morning Show - 010 Wrap Up.wav', 300), + file('Morning Show - 002 Second.wav', 320), + file('Morning Show - 001 Welcome.wav', 340), + file('Morning Show - 003 Third.wav', 360), + ]) + 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.getByText('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(',')) + + // Play the second episode, move 40s in, pause. + await page.locator('.row__main').nth(1).click() + await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 0.3, null, { timeout: 10000 }) + await page.evaluate(() => { document.querySelector('audio').currentTime = 40 }) + await page.waitForTimeout(600) + await page.locator('.mini').getByLabel('Pause').click() + await page.waitForTimeout(500) + + await page.reload({ waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Playlists' }).click() + await page.getByText('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) + + 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)}`) + + // 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.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) + 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.waitForTimeout(1500) + await page.getByRole('button', { name: 'Playlists' }).click() + check('no duplicate playlist on re-import', await page.getByText('Morning Show').count() === 1) +} catch (e) { + console.log('ERROR:', e.message) + failed++ +} finally { + await browser.close() + await server.close() +} +console.log(failed ? `${failed} check(s) failed` : 'all checks passed') +process.exit(failed ? 1 : 0) diff --git a/src/components/ImportButton.jsx b/src/components/ImportButton.jsx index 6b80ddc..c6e066d 100644 --- a/src/components/ImportButton.jsx +++ b/src/components/ImportButton.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { addLocalTrack, requestPersistentStorage, storageEstimate } from '../lib/db' +import { addLocalTrack, requestPersistentStorage, storageEstimate, seriesNumber, upsertSeriesPlaylist } from '../lib/db' import { readDuration, parseFilename } from '../lib/audio' import { ensureLyrics } from '../lib/lyrics' import { fetchYouTubePreview } from '../lib/youtube' @@ -13,7 +13,10 @@ export default function ImportButton() { const [notice, setNotice] = useState(null) // user-facing result/errors const onPick = async (e) => { - const files = [...e.target.files] + // Name order with numbers compared as numbers, so a numbered series lands + // in the library (and its playlist) in episode order. + const files = [...e.target.files].sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })) e.target.value = '' // reset so the same file can be re-picked later if (!files.length) return setNotice(null) @@ -34,6 +37,7 @@ export default function ImportButton() { let skipped = 0 let failed = 0 let outOfSpace = false + const series = new Map() // artist → ids of numbered tracks from this import for (const file of files) { try { const { title, youtubeId } = parseFilename(file.name) @@ -52,6 +56,10 @@ export default function ImportButton() { } const { id, duplicate } = await addLocalTrack({ title, artist, duration, blob: file, thumbnailUrl, youtubeId }) + if (artist !== 'Imported' && seriesNumber(title) != null) { + if (!series.has(artist)) series.set(artist, []) + series.get(artist).push(id) + } if (duplicate) { skipped++ continue @@ -71,6 +79,14 @@ export default function ImportButton() { } setRemaining(0) + // Three or more numbered files from one show make (or extend) a playlist. + const made = [] + for (const [name, ids] of series) { + if (ids.length < 3) continue + await upsertSeriesPlaylist(name, ids).catch(() => {}) + made.push(name) + } + // Only surface something when it's worth telling the user about. if (outOfSpace) { setNotice("Ran out of storage — not all tracks were imported.") @@ -78,6 +94,7 @@ export default function ImportButton() { const parts = [] if (skipped) parts.push(`${skipped} already in your library`) if (failed) parts.push(`${failed} couldn't be imported`) + for (const name of made) parts.push(`Playlist “${name}” is ready, in order`) setNotice(parts.length ? parts.join(' · ') : null) } } diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 516799d..1b8c6be 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -3,7 +3,7 @@ import { usePlaylists, useTracks } from '../state/useLibrary' import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist } from '../lib/db' import { usePlayer } from '../state/PlayerProvider' import { useLongPress } from '../lib/useLongPress' -import { summarize } from '../lib/format' +import { summarize, formatTime } from '../lib/format' import { shuffle } from '../lib/shuffle' import TrackRow from './TrackRow' import PromptModal from './PromptModal' @@ -118,6 +118,10 @@ function PlaylistDetail({ playlist, onBack, onActions }) { const byId = new Map(allTracks.map((t) => [t.id, t])) const tracks = playlist.trackIds.map((id) => byId.get(id)).filter(Boolean) const shuffled = () => shuffle(tracks) + // Shuffle doesn't bookmark: the next Resume should follow playlist order. + const inOrder = { playlistId: playlist.id } + const resumeIdx = playlist.resume ? tracks.findIndex((t) => t.id === playlist.resume.trackId) : -1 + const resumeTrack = resumeIdx >= 0 ? tracks[resumeIdx] : null return ( <> @@ -131,13 +135,26 @@ function PlaylistDetail({ playlist, onBack, onActions }) { <>

{summarize(tracks)}

- +
+ {resumeTrack && ( + + )}
{tracks.map((t) => (
- +
))} diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index 120d3de..af850cb 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -7,14 +7,14 @@ import { useUI } from '../state/UIProvider' // A single tappable track. Tap the row → play (within `list` as the queue). // Star button toggles favorite; the ⋯ button opens the add-to-playlist sheet. -export default function TrackRow({ track, list }) { +export default function TrackRow({ track, list, playOpts }) { const { current, isPlaying, playTrack } = usePlayer() const { openAddToPlaylist } = useUI() const isCurrent = current?.id === track.id return (
-