diff --git a/scripts/shoot-podcasts.mjs b/scripts/shoot-podcasts.mjs new file mode 100644 index 0000000..4151712 --- /dev/null +++ b/scripts/shoot-podcasts.mjs @@ -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() +} diff --git a/scripts/test-podcasts.mjs b/scripts/test-podcasts.mjs new file mode 100644 index 0000000..a99535a --- /dev/null +++ b/scripts/test-podcasts.mjs @@ -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) diff --git a/src/App.jsx b/src/App.jsx index e1cff44..93e3ff9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ 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' @@ -11,6 +12,7 @@ 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() { @@ -24,6 +26,7 @@ export default function App() { {tab === 'search' && } {tab === 'library' && } {tab === 'playlists' && } + {tab === 'podcasts' && } {/* Floating dock: mini-player card stacked above the tab bar */} @@ -59,3 +62,6 @@ function LibraryIcon() { function PlaylistIcon() { return } +function PodcastIcon() { + return +} diff --git a/src/components/Player.jsx b/src/components/Player.jsx index 6bf58e5..7298a67 100644 --- a/src/components/Player.jsx +++ b/src/components/Player.jsx @@ -31,7 +31,7 @@ export default function Player() { {p.current.title} - {p.missing ? 'Audio unavailable — re-import' : p.current.artist} + {p.missing ? missingShort(p.current) : p.current.artist} e.stopPropagation()}> @@ -97,9 +97,7 @@ function NowPlaying({ p, onClose }) { {p.missing ? ( -

- Audio unavailable — the file for this track is missing. Re-import it to play. -

+

{missingLong(p.current)}

) : (
- + {p.longForm ? ( + + ) : ( + + )} - + {p.longForm ? ( + + ) : ( + + )}
- + {p.longForm ? ( + + ) : ( + + )} ) } @@ -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 } @@ -199,3 +221,14 @@ function PrevIcon() { return } function ChevronDown() { return } function StarIcon({ filled }) { return } +function SkipIcon({ back, n }) { + return ( + + + + + + {n} + + ) +} diff --git a/src/components/PodcastsView.jsx b/src/components/PodcastsView.jsx new file mode 100644 index 0000000..c17b0a5 --- /dev/null +++ b/src/components/PodcastsView.jsx @@ -0,0 +1,279 @@ +import { useState, useEffect } from 'react' +import { usePodcasts, useEpisodes, useInProgress } from '../state/useLibrary' +import { usePlayer } from '../state/PlayerProvider' +import { unsubscribePodcast, removeEpisodeAudio, setPlayed } from '../lib/db' +import { searchPodcasts, subscribe, refreshStale, toPlayable, downloadEpisode, useDownloads } from '../lib/podcasts' +import { formatTime, timeLeft } from '../lib/format' +import { useDialog } from '../lib/useDialog' +import Artwork from './Artwork' +import PromptModal from './PromptModal' +import ConfirmModal from './ConfirmModal' + +export default function PodcastsView() { + const shows = usePodcasts() + const [openId, setOpenId] = useState(null) + const [adding, setAdding] = useState(false) + + // Pick up new episodes when the tab opens (feeds older than an hour). + useEffect(() => { + refreshStale() + }, []) + + const open = shows?.find((s) => s.id === openId) + + return ( +
+ {shows === undefined ? ( +

Loading…

+ ) : adding ? ( + setAdding(false)} onOpen={(id) => { setAdding(false); setOpenId(id) }} /> + ) : open ? ( + setOpenId(null)} /> + ) : ( + <> +
+

Podcasts

+ +
+ + {shows.length === 0 ? ( +

No shows yet. Tap + Add to search for a podcast or paste its RSS link.

+ ) : ( +
+ {shows.map((s) => ( + + ))} +
+ )} + + )} +
+ ) +} + +function ContinueListening({ shows }) { + const eps = useInProgress() + if (!eps?.length) return null + const byId = new Map(shows.map((s) => [s.id, s])) + return ( + <> +

Continue listening

+
+ {eps.map((ep) => )} +
+ + ) +} + +function AddPodcast({ subscribed, onBack, onOpen }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState(null) + const [status, setStatus] = useState('') // search / subscribe errors + const [busy, setBusy] = useState(null) // feed id being subscribed + const [pasting, setPasting] = useState(false) + const have = new Set(subscribed.map((s) => s.id)) + + // Search as you type, debounced so each keystroke isn't a request. + useEffect(() => { + const q = query.trim() + if (!q) { setResults(null); setStatus(''); return } + let stale = false + const t = setTimeout(() => { + searchPodcasts(q) + .then((r) => { if (!stale) { setResults(r); setStatus('') } }) + .catch(() => { if (!stale) setStatus('Search needs a connection. Try again when you are online.') }) + }, 400) + return () => { stale = true; clearTimeout(t) } + }, [query]) + + const add = async (feedUrl) => { + setBusy(feedUrl) + setStatus('') + try { + const show = await subscribe(feedUrl) + onOpen(show.id) + } catch { + setStatus("Couldn't read that feed. Check the link, or try again online.") + } finally { + setBusy(null) + } + } + + return ( + <> +
+ +

Add a show

+
+
+ setQuery(e.target.value)} + autoCapitalize="none" + autoCorrect="off" + autoFocus + /> +
+ + {status &&

{status}

} + {results && results.length === 0 &&

No shows match “{query.trim()}”.

} + {results && ( +
+ {results.map((r) => ( +
+ + + + {r.title} + {r.author} + + + {have.has(r.id) ? ( + + ) : ( + + )} +
+ ))} +
+ )} + {pasting && ( + setPasting(false)} + onSubmit={(url) => { setPasting(false); add(url) }} + /> + )} + + ) +} + +function ShowDetail({ show, onBack }) { + const eps = useEpisodes(show.id) + const [confirm, setConfirm] = useState(false) + + return ( + <> +
+ +

{show.title}

+
+
+ +
+

{show.author}

+ +
+
+ {eps === undefined ? ( +

Loading…

+ ) : eps.length === 0 ? ( +

This feed has no playable episodes.

+ ) : ( +
+ {eps.map((ep) => )} +
+ )} + {confirm && ( + setConfirm(false)} + onConfirm={async () => { await unsubscribePodcast(show.id); setConfirm(false); onBack() }} + /> + )} + + ) +} + +function EpisodeRow({ ep, show, showName = false }) { + const { current, isPlaying, playTrack } = usePlayer() + const downloads = useDownloads() + const [menu, setMenu] = useState(false) + const [error, setError] = useState('') + const isCurrent = current?.id === ep.id + const dl = downloads[ep.id] + const pct = ep.duration && ep.position ? Math.min(100, (ep.position / ep.duration) * 100) : 0 + + const meta = [ + showName ? show?.title : ep.pubDate ? new Date(ep.pubDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : null, + ep.played ? 'Played' : ep.position > 0 && ep.duration ? timeLeft(ep.duration, ep.position) : ep.duration ? formatTime(ep.duration) : null, + ep.downloaded ? 'Downloaded' : null, + ].filter(Boolean).join(' · ') + + const download = () => { + setError('') + downloadEpisode(ep).catch(() => setError("This show doesn't allow downloads here. It will still stream.")) + } + + return ( +
+ + {dl !== undefined ? ( + {dl < 0 ? '…' : `${Math.round(dl * 100)}%`} + ) : !ep.downloaded ? ( + + ) : null} + + {menu && ( + setMenu(false)} + onDownload={() => { setMenu(false); download() }} + /> + )} +
+ ) +} + +function EpisodeSheet({ ep, onClose, onDownload }) { + const dialog = useDialog(onClose) + const act = (fn) => async () => { await fn().catch(() => {}); onClose() } + return ( +
+
e.stopPropagation()}> +
+

{ep.title}

+ {ep.summary &&

{ep.summary}

} + + {ep.downloaded ? ( + + ) : ( + + )} + +
+
+ ) +} + +function ChevronLeft() { return } +function Dots() { return } +function DownloadIcon() { return } diff --git a/src/components/PromptModal.jsx b/src/components/PromptModal.jsx index 2dacf85..56b8df7 100644 --- a/src/components/PromptModal.jsx +++ b/src/components/PromptModal.jsx @@ -9,6 +9,9 @@ export default function PromptModal({ placeholder = '', confirmLabel = 'Create', initialValue = '', + maxLength = 60, + inputMode = 'text', + autoCapitalize = 'words', onSubmit, onClose, }) { @@ -46,10 +49,11 @@ export default function PromptModal({ value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} - autoCapitalize="words" + inputMode={inputMode} + autoCapitalize={autoCapitalize} autoCorrect="off" enterKeyHint="done" - maxLength={60} + maxLength={maxLength} />
diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index e68388a..120d3de 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -1,5 +1,6 @@ import Artwork from './Artwork' -import { formatTime } from '../lib/format' +import { formatTime, timeLeft } from '../lib/format' +import { isLongForm } from '../lib/podcasts' import { toggleStar } from '../lib/db' import { usePlayer } from '../state/PlayerProvider' import { useUI } from '../state/UIProvider' @@ -22,7 +23,9 @@ export default function TrackRow({ track, list }) { {isCurrent && isPlaying && } - {formatTime(track.duration)} + + {isLongForm(track) && track.position > 0 ? timeLeft(track.duration, track.position) : formatTime(track.duration)} +