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
40 changes: 40 additions & 0 deletions scripts/shoot-podcasts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Screenshots of the Podcasts tab: search results, a show's episode list,
// and the tab with Continue listening. node scripts/shoot-podcasts.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: 5192, 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:5192', { waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Podcasts' }).click()
await page.getByRole('button', { name: '+ Add' }).click()
await page.getByPlaceholder('Search podcasts').fill('huberman')
await page.locator('.subbtn').first().waitFor({ timeout: 15000 })
await page.waitForTimeout(1500)
await page.screenshot({ path: shot('11-podcast-search') })

await page.locator('.subbtn').first().click()
await page.locator('.eprow').first().waitFor({ timeout: 30000 })
await page.locator('.eprow .row__main').nth(1).click()
await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 0.5, null, { timeout: 30000 })
await page.evaluate(() => { const a = document.querySelector('audio'); a.currentTime = 600 })
await page.waitForTimeout(1500)
await page.locator('.mini').getByLabel('Pause').click()
await page.waitForTimeout(800)
await page.screenshot({ path: shot('12-podcast-show') })

await page.getByLabel('Back').click()
await page.waitForTimeout(800)
await page.screenshot({ path: shot('13-podcasts-tab') })
} finally {
await browser.close()
await server.close()
}
121 changes: 121 additions & 0 deletions scripts/test-podcasts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// End-to-end check of podcasts against the real network: search → follow →
// play → speed → position saved → resume after reload → download → offline
// play. Also checks that a long imported track gets podcast controls.
// Starts its own Vite dev server, so just: node scripts/test-podcasts.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: 5191, strictPort: true }, logLevel: 'error' })
await server.listen()
const base = 'http://localhost:5191'

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()
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++
}
const audio = (fn) => page.evaluate(fn)
const readEpisodes = () => page.evaluate(() => new Promise((res) => {
const r = indexedDB.open('melody')
r.onsuccess = () => {
const q = r.result.transaction('episodes').objectStore('episodes').getAll()
q.onsuccess = () => res(q.result)
}
}))

try {
await page.goto(base, { waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Podcasts' }).click()
await page.getByRole('button', { name: '+ Add' }).click()
await page.getByPlaceholder('Search podcasts').fill('the daily new york times')
await page.locator('.subbtn').first().waitFor({ timeout: 15000 })
const firstResult = await page.locator('.row__title').first().textContent()
check('iTunes search returns shows', !!firstResult, firstResult)

await page.locator('.subbtn').first().click()
await page.locator('.eprow').first().waitFor({ timeout: 30000 })
const epCount = await page.locator('.eprow').count()
check('follow loads episodes from the RSS feed', epCount > 5, `${epCount} episodes`)

// Play the newest episode and open Now Playing.
await page.locator('.eprow .row__main').first().click()
await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.currentTime > 0.5 }, null, { timeout: 30000 })
check('episode streams', true)
await page.locator('.mini').click()
check('Now Playing shows skip buttons', await page.getByLabel('Forward 30 seconds').isVisible())
check('Now Playing shows speed pill instead of repeat', await page.locator('.speedbtn').isVisible())

await page.locator('.speedbtn').click()
const rate = await audio(() => document.querySelector('audio').playbackRate)
check('speed pill sets playbackRate', rate === 1.25, `rate ${rate}`)

await page.getByLabel('Forward 30 seconds').click()
await page.getByLabel('Forward 30 seconds').click()
await page.getByLabel('Forward 30 seconds').click()
const t = await audio(() => document.querySelector('audio').currentTime)
check('skip forward moves 30s per tap', t >= 90, `t=${t.toFixed(1)}`)

await page.locator('.now .playbtn').click()
await page.waitForTimeout(500)
const saved = (await readEpisodes()).find((e) => e.position > 0)
check('pausing saves the position', saved && Math.abs(saved.position - t) < 5, saved ? `saved ${saved.position.toFixed(1)}` : 'none saved')

// Reload: the episode should be in Continue listening and resume in place.
await page.reload({ waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Podcasts' }).click()
const cont = page.locator('.continue .eprow')
await cont.first().waitFor({ timeout: 5000 })
check('Continue listening lists it after reload', await cont.count() === 1, await cont.first().locator('.row__artist').textContent())
await cont.first().locator('.row__main').click()
await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.currentTime > 60 }, null, { timeout: 30000 }).catch(() => {})
const resumed = await audio(() => document.querySelector('audio').currentTime)
check('playback resumes at the saved spot', resumed >= saved.position - 2, `t=${resumed.toFixed(1)}`)
const rate2 = await audio(() => document.querySelector('audio').playbackRate)
check('speed survives reload', rate2 === 1.25, `rate ${rate2}`)
await page.locator('.mini').getByLabel('Pause').click()

// Download the newest episode, then play it with the network cut.
await page.locator('.showrow').first().click()
const row = page.locator('.eprow').first()
await row.getByLabel('Download').click()
await page.waitForFunction(() => document.querySelector('.eprow .row__artist')?.textContent.includes('Downloaded'), null, { timeout: 180000 })
check('download completes', true)
await ctx.setOffline(true)
await row.locator('.row__main').click()
await page.waitForFunction(() => { const a = document.querySelector('audio'); return a && a.src.startsWith('blob:') && !a.paused }, null, { timeout: 15000 }).catch(() => {})
const src = await audio(() => document.querySelector('audio').src)
check('downloaded episode plays offline from local storage', src.startsWith('blob:'), src.slice(0, 30))
await ctx.setOffline(false)

// A long imported track (a 21-minute silent WAV) gets podcast controls.
await page.getByRole('button', { name: 'Library' }).click()
const sec = 21 * 60, rateHz = 8000
const wav = Buffer.alloc(44 + sec * rateHz)
wav.write('RIFF', 0); wav.writeUInt32LE(36 + sec * rateHz, 4); wav.write('WAVEfmt ', 8)
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22)
wav.writeUInt32LE(rateHz, 24); wav.writeUInt32LE(rateHz, 28); wav.writeUInt16LE(1, 32); wav.writeUInt16LE(8, 34)
wav.write('data', 36); wav.writeUInt32LE(sec * rateHz, 40); wav.fill(128, 44)
await page.locator('input[type=file]').setInputFiles({ name: 'Long Talk - Interview.wav', mimeType: 'audio/wav', buffer: wav })
await page.getByText('Interview').first().waitFor({ timeout: 15000 })
await page.getByText('Interview').first().click()
await page.locator('.mini').click()
check('long imported track gets skip buttons', await page.getByLabel('Back 15 seconds').isVisible())

await page.screenshot({ path: resolve(here, '../screenshots/10-podcast-now-playing.png') })
} 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)
6 changes: 6 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { UIProvider } from './state/UIProvider'
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'

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 },
]

export default function App() {
Expand All @@ -24,6 +26,7 @@ export default function App() {
{tab === 'search' && <SearchView />}
{tab === 'library' && <LibraryView />}
{tab === 'playlists' && <PlaylistsView />}
{tab === 'podcasts' && <PodcastsView />}
</main>

{/* Floating dock: mini-player card stacked above the tab bar */}
Expand Down Expand Up @@ -59,3 +62,6 @@ function LibraryIcon() {
function PlaylistIcon() {
return <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h9" /><path d="M16 13v6" /><circle cx="19" cy="19" r="2.5" /></svg>
}
function PodcastIcon() {
return <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3" /></svg>
}
61 changes: 47 additions & 14 deletions src/components/Player.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function Player() {
<span className="mini__meta">
<span className="mini__title">{p.current.title}</span>
<span className="mini__artist">
{p.missing ? 'Audio unavailable — re-import' : p.current.artist}
{p.missing ? missingShort(p.current) : p.current.artist}
</span>
</span>
<span className="mini__controls" onClick={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -97,9 +97,7 @@ function NowPlaying({ p, onClose }) {
</div>

{p.missing ? (
<p className="now__missing" role="status">
Audio unavailable — the file for this track is missing. Re-import it to play.
</p>
<p className="now__missing" role="status">{missingLong(p.current)}</p>
) : (
<div className="scrub">
<input
Expand All @@ -119,21 +117,35 @@ function NowPlaying({ p, onClose }) {
)}

<div className="transport">
<button className="iconbtn" onClick={p.prev} aria-label="Previous"><PrevIcon /></button>
{p.longForm ? (
<button className="iconbtn skipbtn" onClick={() => p.skip(-15)} aria-label="Back 15 seconds"><SkipIcon back n={15} /></button>
) : (
<button className="iconbtn" onClick={p.prev} aria-label="Previous"><PrevIcon /></button>
)}
<button className="playbtn" onClick={p.toggle} aria-label={p.isPlaying ? 'Pause' : 'Play'} disabled={p.missing}>
{p.isPlaying ? <PauseIcon big /> : <PlayIcon big />}
</button>
<button className="iconbtn" onClick={p.next} aria-label="Next"><NextIcon /></button>
{p.longForm ? (
<button className="iconbtn skipbtn" onClick={() => p.skip(30)} aria-label="Forward 30 seconds"><SkipIcon n={30} /></button>
) : (
<button className="iconbtn" onClick={p.next} aria-label="Next"><NextIcon /></button>
)}
</div>

<button
className={`loopbtn loopbtn--${p.loopMode}`}
onClick={p.cycleLoop}
aria-label={loopLabel}
>
<RepeatIcon /> <span>{loopLabel}</span>
{p.loopMode === 'one' && <em className="loopbtn__one">1</em>}
</button>
{p.longForm ? (
<button className="loopbtn speedbtn" onClick={p.cycleSpeed} aria-label={`Playback speed ${p.speed}x`}>
<span>{p.speed}×</span>
</button>
) : (
<button
className={`loopbtn loopbtn--${p.loopMode}`}
onClick={p.cycleLoop}
aria-label={loopLabel}
>
<RepeatIcon /> <span>{loopLabel}</span>
{p.loopMode === 'one' && <em className="loopbtn__one">1</em>}
</button>
)}
</div>
)
}
Expand Down Expand Up @@ -189,6 +201,16 @@ function LyricsView({ lyrics, progress, onSeek, onResearch }) {
)
}

// An episode that fails to load is usually just offline, not missing bytes.
function missingShort(item) {
return item.kind === 'episode' ? "Can't stream right now" : 'Audio unavailable — re-import'
}
function missingLong(item) {
return item.kind === 'episode'
? "Can't stream this episode right now. Download it while you have a connection to play it offline."
: 'Audio unavailable — the file for this track is missing. Re-import it to play.'
}

/* icons */
const s = { fill: 'currentColor' }
function LyricsIcon() { return <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6h11M4 12h9M4 18h7" /><path d="M16 17V9l4-1.5V15" /><circle cx="14.5" cy="17" r="1.6" fill="currentColor" stroke="none" /><circle cx="18.5" cy="15" r="1.6" fill="currentColor" stroke="none" /></svg> }
Expand All @@ -199,3 +221,14 @@ function PrevIcon() { return <svg viewBox="0 0 24 24" width="22" height="22" {..
function RepeatIcon() { return <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 2l4 4-4 4" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><path d="M7 22l-4-4 4-4" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /></svg> }
function ChevronDown() { return <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg> }
function StarIcon({ filled }) { return <svg viewBox="0 0 24 24" width="24" height="24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinejoin="round"><path d="M12 3.5l2.7 5.5 6 .9-4.3 4.2 1 6-5.4-2.8L6.6 20l1-6L3.3 9.9l6-.9z" /></svg> }
function SkipIcon({ back, n }) {
return (
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<g transform={back ? undefined : 'translate(24 0) scale(-1 1)'}>
<path d="M4 12a8 8 0 1 0 2.3-5.7" />
<path d="M4 3v4h4" />
</g>
<text x="12" y="15.6" textAnchor="middle" fontSize="7.5" fontWeight="700" fill="currentColor" stroke="none">{n}</text>
</svg>
)
}
Loading
Loading