August 25, 2025
I love podcasts, they're a great way to keep up with hobbies and entertainment without needing to watch a screen. I usually listen to them on my phone but often find swapping from bluetooth to using my desktop audio annoying.
So today, I thought it'd be bun to build a tiny but legit podcast desktop app with Next.js. We’ll keep it focused: search
for shows, list episodes
, and wire up an accessible audio player
with play
, stop
, and fast-forward/rewind
(plus a scrubber and keyboard shortcuts). You can use it in the browser as a PWA.
tldr; Scaffold a Next.js app, proxy iTunes Search API to find shows, parse each show’s RSS for episodes, and ship an accessible
<audio>
player with play/stop/skip and keyboard controls (Space/J/K/L).
We’ll use Next.js App Router
so we get file-based server routes for free, and we’ll keep dependencies light. For discovery we’ll hit the iTunes Search API
(no key required, great for demos). For episodes, we’ll fetch each podcast’s RSS feed
and extract enclosure URLs (the actual audio files) with fast-xml-parser
.
Why this setup?
search
and RSS fetch
on the server avoids CORS headaches, keeps feed URLs off the client, and lets us normalize shapes.<audio>
element is battle-tested, accessible, and good enough for a basic player.# 1) Create the app
npx create-next-app@latest deskcaster --typescript --eslint --app
cd deskcaster
# 2) Install RSS parser
pnpm add fast-xml-parser
# or: npm i fast-xml-parser
# or: yarn add fast-xml-parser
First, let's define small TypeScript types file for search results and episodes. Types make the API layer and UI wiring harder to mess up, and they document the contract we expect from third-party responses.
In types/podcast.ts
export type PodcastSearchItem = {
id: string // Stable identifier for a podcast (collectionId/trackId/feedUrl)
title: string // Human-readable show title
image: string // Square artwork URL (iTunes gives multiple sizes)
feedUrl: string // The podcast's RSS feed URL (used to fetch episodes)
}
export type Episode = {
id: string // Stable-ish ID (guid where possible)
title: string // Episode title
audioUrl: string // Direct enclosure URL (mp3/aac/etc.)
pubDate?: string // Publish date (optional; many feeds omit/format oddly)
duration?: string | number // itunes:duration can be "01:23:45" or seconds
}
Tip: keep these minimal. Real feeds vary wildly, so start small, extend later.
Instead of calling iTunes from the browser (CORS, shape drift), we create a server route
that accepts ?q=
and returns a normalized list of shows. Normalization = consistent keys, fewer surprises in the UI. Caching (revalidate
) keeps it snappy.
In app/api/search/route.ts
import { NextResponse } from 'next/server'
import type { PodcastSearchItem } from '@/types/podcast'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const q = (searchParams.get('q') || '').trim()
// Early return for empty search to avoid unnecessary network calls.
if (!q) {
return NextResponse.json({ results: [] })
}
// iTunes Search API: no auth required; "media=podcast" helps target shows.
const url = `https://itunes.apple.com/search?media=podcast&term=${encodeURIComponent(q)}`
// `next.revalidate` enables ISR-style caching in production.
const res = await fetch(url, { next: { revalidate: 60 } })
if (!res.ok) {
// Fail quiet with empty results; keeps the client simple.
return NextResponse.json({ results: [] })
}
const data = await res.json()
// Normalize result shape and drop items lacking a feed URL.
const results: PodcastSearchItem[] = (data?.results || [])
.filter((r: any) => !!r.feedUrl)
.map((r: any) => ({
// Prefer numeric IDs; fall back to feedUrl for stability if needed.
id: String(r.collectionId ?? r.trackId ?? r.feedUrl),
title: r.collectionName || r.trackName || 'Untitled',
// Prefer the larger artwork when available.
image: r.artworkUrl100 || r.artworkUrl600 || '',
feedUrl: r.feedUrl,
}))
return NextResponse.json({ results })
}
FYI:
media=podcast
nudges it to collections.feedUrl
so we filter them out.revalidate: 60
caches responses for a minute in production.Once a user picks a show, we need its episodes
. The source of truth is the RSS feed
. We fetch the XML server-side, parse it, and extract enclosures
(audio URLs). Doing this server-side avoids CORS and lets us handle weird feed formats in one place.
In app/api/episodes/route.ts
import { NextResponse } from 'next/server'
import { XMLParser } from 'fast-xml-parser'
import type { Episode } from '@/types/podcast'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const feedUrl = searchParams.get('feedUrl')
if (!feedUrl) return NextResponse.json({ episodes: [] })
// Many hosts expect a UA; sending one improves success rates.
const res = await fetch(feedUrl, {
headers: { 'User-Agent': 'NextPodcastDemo/1.0 (+https://example.com)' },
// Consider `next: { revalidate: 300 }` later for caching feeds.
})
if (!res.ok) return NextResponse.json({ episodes: [] })
const xml = await res.text()
// Configure parser to keep attribute values (e.g., enclosure url="...").
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '', // Make attributes readable without "@_"
})
const parsed = parser.parse(xml)
// Most podcasts are RSS 2.0: items live at rss.channel.item[]
let items: any[] = parsed?.rss?.channel?.item ?? []
// Atom fallback (rarer for podcasts). Entries may structure links differently.
if (!Array.isArray(items) || items.length === 0) {
const entries = parsed?.feed?.entry
if (Array.isArray(entries)) items = entries
}
const episodes: Episode[] = items
.map((item: any, idx: number) => {
// Title can be in <title> or itunes:title; default to "Episode N" if missing.
const title = item?.title || item?.['itunes:title'] || `Episode ${idx + 1}`
// Publish date fields vary across feeds.
const pubDate = item?.pubDate || item?.published || item?.updated
// The actual audio lives in an "enclosure" with a url attribute.
// Some Atom feeds provide a <link rel="enclosure" href="..."/>.
const enclosure = item?.enclosure
const audioUrl =
(typeof enclosure === 'object' ? enclosure?.url : undefined) ||
(Array.isArray(item?.link)
? item.link.find((l: any) => l?.rel === 'enclosure')?.href
: undefined) ||
''
const duration = item?.['itunes:duration']
// If there's no playable audio URL, skip the item.
if (!audioUrl) return null
return {
// Favor GUID text; fall back to index as last resort.
id: String(item?.guid?.['#text'] || item?.guid || idx),
title: String(title),
audioUrl: String(audioUrl),
pubDate: pubDate ? String(pubDate) : undefined,
// Keep duration raw; you can normalize later if needed.
duration: duration ?? undefined,
} as Episode
})
// Remove nulls from skipped items (no audio URL).
.filter(Boolean) as Episode[]
return NextResponse.json({ episodes })
}
Feeds are LAWLESS. Expect odd shapes, missing fields, HTML in titles, etc.
We’ll wrap <audio>
with controls for Play/Pause
, Stop
(pause + reset to 0), Rewind 15s
, Fast-forward 30s
, a scrubber
, time labels, and keyboard shortcuts
(Space
, J
/K
/L
—YouTube-style). This keeps the UI familiar and accessible.
In components/AudioPlayer.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
type Props = {
src: string | null // Current episode audio URL (null = nothing selected)
title?: string | null // Displayed above controls
skipBackSec?: number // How far to rewind (default 15)
skipFwdSec?: number // How far to jump forward (default 30)
}
export default function AudioPlayer({ src, title, skipBackSec = 15, skipFwdSec = 30 }: Props) {
// Keep a direct handle on the <audio> element.
const audioRef = useRef<HTMLAudioElement | null>(null)
// UI state derived from the audio element.
const [isPlaying, setIsPlaying] = useState(false)
const [cur, setCur] = useState(0) // currentTime (seconds)
const [dur, setDur] = useState(0) // duration (seconds)
// When `src` changes: load and auto-play the new episode.
useEffect(() => {
const audio = audioRef.current
if (!audio || !src) return
audio.src = src
audio.load()
// Attempt autoplay with a catch to avoid unhandled promise rejections on blocked autoplay.
audio
.play()
.then(() => setIsPlaying(true))
.catch(() => setIsPlaying(false))
// Reset progress back to 0 for the new track.
setCur(0)
}, [src])
// Wire up time/duration/ended listeners once.
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onTime = () => setCur(audio.currentTime)
const onLoaded = () => setDur(audio.duration || 0)
const onEnded = () => setIsPlaying(false)
audio.addEventListener('timeupdate', onTime)
audio.addEventListener('loadedmetadata', onLoaded)
audio.addEventListener('ended', onEnded)
return () => {
audio.removeEventListener('timeupdate', onTime)
audio.removeEventListener('loadedmetadata', onLoaded)
audio.removeEventListener('ended', onEnded)
}
}, [])
// Global keyboard shortcuts: Space (toggle), J (-), K (pause), L (+)
// We skip when the user is typing in inputs to avoid surprise behavior.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
// Add other tags here to prevent events from getting caught
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.code === 'Space') {
e.preventDefault() // Prevent page scroll on space
toggle()
} else if (e.key.toLowerCase() === 'j') {
e.preventDefault()
skip(-skipBackSec)
} else if (e.key.toLowerCase() === 'k') {
e.preventDefault()
pause()
} else if (e.key.toLowerCase() === 'l') {
e.preventDefault()
skip(skipFwdSec)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [skipBackSec, skipFwdSec, isPlaying])
// Control helpers
const play = () => {
const audio = audioRef.current
if (!audio) return
audio
.play()
.then(() => setIsPlaying(true))
.catch(() => setIsPlaying(false))
}
const pause = () => {
const audio = audioRef.current
if (!audio) return
audio.pause()
setIsPlaying(false)
}
// Stop = pause + reset progress back to 0
const stop = () => {
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.currentTime = 0
setIsPlaying(false)
}
const toggle = () => (isPlaying ? pause() : play())
// Jump forward/backward by delta seconds; clamp within [0, duration].
const skip = (delta: number) => {
const audio = audioRef.current
if (!audio) return
const max = dur || audio.duration || 0
const next = Math.min(Math.max(0, audio.currentTime + delta), max)
audio.currentTime = next
}
// Seek handler for the range input (scrubber).
const onScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current
if (!audio) return
const next = Number(e.target.value)
audio.currentTime = next
setCur(next)
}
// Format seconds as M:SS or H:MM:SS.
const fmt = (s: number) => {
if (!isFinite(s)) return '0:00'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
const ss = String(sec).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
}
return (
<div
role="region" // Landmark region for quick SR navigation
aria-label="Podcast audio player"
style={{ border: '1px solid #333', padding: 12, borderRadius: 12 }}
>
{/* Keep the <audio> element but hide native controls in favor of our UI */}
<audio ref={audioRef} aria-label="audio element" />
<div style={{ marginBottom: 8 }}>
<strong>{title || 'No episode selected'}</strong>
</div>
{/* Primary controls */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => skip(-skipBackSec)} aria-label={`Rewind ${skipBackSec} seconds`}>
↺ {skipBackSec}s
</button>
<button onClick={toggle} aria-label={isPlaying ? 'Pause' : 'Play'}>
{isPlaying ? '⏸ Pause' : '▶️ Play'}
</button>
<button onClick={stop} aria-label="Stop and reset">
⏹ Stop
</button>
<button onClick={() => skip(skipFwdSec)} aria-label={`Fast forward ${skipFwdSec} seconds`}>
{skipFwdSec}s ↻
</button>
</div>
{/* Scrubber + time labels */}
<div style={{ marginTop: 10 }}>
{/* Screen-reader-only label for the range input */}
<label htmlFor="scrub" className="sr-only">
Seek
</label>
<input
id="scrub"
type="range"
min={0}
max={dur || 0}
step={1}
value={Math.min(cur, dur || 0)}
onChange={onScrub}
aria-valuemin={0}
aria-valuemax={dur || 0}
aria-valuenow={Math.floor(cur)}
aria-label="Seek position"
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>{fmt(cur)}</span>
<span>{fmt(dur || 0)}</span>
</div>
</div>
<p style={{ marginTop: 8, fontSize: 12, opacity: 0.8 }}>
Tips: <kbd>Space</kbd> = Play/Pause, <kbd>J</kbd> = Rewind, <kbd>K</kbd> = Pause,{' '}
<kbd>L</kbd> = Fast-forward
</p>
</div>
)
}
Accessibility nits to care about:
aria-label
s; the player uses role="region"
with a label.In app/globals.css
button:focus,
input:focus {
outline: 2px solid #5b9aff;
outline-offset: 2px;
}
This is the glue: a search box calls /api/search
, clicking a show fetches /api/episodes
, and clicking an episode pipes audioUrl
into the player. We keep everything in one page for clarity; in a real app, you’d split into routes/components.
In app/page.tsx
'use client'
import { useState } from 'react'
import type { PodcastSearchItem, Episode } from '@/types/podcast'
import AudioPlayer from '@/components/AudioPlayer'
export default function HomePage() {
// Local UI state: search input + loading status
const [term, setTerm] = useState('')
const [loading, setLoading] = useState(false)
// Search results (podcasts), selected podcast, episodes, and currently-playing episode
const [results, setResults] = useState<PodcastSearchItem[]>([])
const [selected, setSelected] = useState<PodcastSearchItem | null>(null)
const [episodes, setEpisodes] = useState<Episode[]>([])
const [current, setCurrent] = useState<Episode | null>(null)
// Perform podcast search via our server proxy route.
const doSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
setLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`)
const data = await res.json()
setResults(data.results || [])
// Clear previous selection/episodes when a new search runs.
setSelected(null)
setEpisodes([])
setCurrent(null)
} finally {
setLoading(false)
}
}
// When a podcast is chosen, fetch its RSS episodes.
const loadEpisodes = async (pod: PodcastSearchItem) => {
setSelected(pod)
setEpisodes([])
setCurrent(null)
const res = await fetch(`/api/episodes?feedUrl=${encodeURIComponent(pod.feedUrl)}`)
const data = await res.json()
setEpisodes(data.episodes || [])
}
// Set the chosen episode (the player will auto-play on `src` change).
const pickEpisode = (ep: Episode) => setCurrent(ep)
return (
<main style={{ padding: 20, maxWidth: 1000, margin: '0 auto' }}>
<h1>Podcast Desktop (Next.js)</h1>
{/* SEARCH FORM */}
<form onSubmit={doSearch} aria-label="Search podcasts" style={{ marginBottom: 16 }}>
<label htmlFor="term" style={{ display: 'block', fontWeight: 600 }}>
Search for a podcast
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<input
id="term"
type="search"
placeholder="e.g. data engineering"
value={term}
onChange={(e) => setTerm(e.target.value)}
aria-label="Search term"
style={{ flex: 1, padding: 8 }}
/>
<button disabled={loading || term.trim().length === 0} type="submit">
{loading ? 'Searching...' : 'Search'}
</button>
</div>
</form>
{/* RESULTS GRID */}
{results.length > 0 && (
<section aria-label="Search results" style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 18 }}>Results</h2>
<ul
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 12,
listStyle: 'none',
padding: 0,
}}
>
{results.map((r) => (
<li key={r.id}>
{/* Button not link: this changes in-page UI state, not navigation */}
<button
onClick={() => loadEpisodes(r)}
style={{
width: '100%',
textAlign: 'left',
border: '1px solid #333',
borderRadius: 12,
padding: 10,
display: 'flex',
gap: 10,
background: 'transparent',
cursor: 'pointer',
}}
aria-label={`Open ${r.title}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={r.image}
alt="" // artwork is decorative; real alt text could repeat the title
width={64}
height={64}
style={{ borderRadius: 8, objectFit: 'cover' }}
/>
<div>
<div style={{ fontWeight: 600 }}>{r.title}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>{r.feedUrl}</div>
</div>
</button>
</li>
))}
</ul>
</section>
)}
{/* EPISODE LIST */}
{selected && (
<section aria-label="Episodes">
<h2 style={{ fontSize: 18, marginBottom: 8 }}>Episodes — {selected.title}</h2>
{/* Simple loading state while the RSS is being parsed */}
{episodes.length === 0 ? (
<p>Loading episodes…</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, marginBottom: 20 }}>
{episodes.map((ep) => (
<li key={ep.id} style={{ marginBottom: 8 }}>
<button
onClick={() => pickEpisode(ep)}
style={{
width: '100%',
textAlign: 'left',
border: '1px solid #333',
borderRadius: 10,
padding: 10,
// Highlight the currently selected episode
background: current?.id === ep.id ? '#1a1a1a' : 'transparent',
}}
aria-label={`Play ${ep.title}`}
>
<div style={{ fontWeight: 600 }}>{ep.title}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
{ep.pubDate ? new Date(ep.pubDate).toLocaleString() : '—'}
{ep.duration ? ` • ${ep.duration}` : ''}
</div>
</button>
</li>
))}
</ul>
)}
</section>
)}
{/* PLAYER */}
<AudioPlayer
src={current?.audioUrl ?? null}
title={current?.title ?? null}
skipBackSec={15}
skipFwdSec={30}
/>
</main>
)
}
Why these UX choices?
state
within the page, not navigation.A11y checklist for this app:
Tab
.aria-label
s exist where the visible text isn’t sufficient.region
so screen readers can jump to it.Make it a desktop app:
Wrap with Tauri
for a tiny native shell (fast, low RAM). In dev, point devPath
to http://localhost:3000
; in prod, point to your Next build output.PWA install:
Add a manifest and service worker so users can “Install app” on Windows/macOS/Linux.Persistence:
Save followed shows, last position per episode, and speed. Start with localStorage
, graduate to SQLite.Speed & volume controls:
0.75x/1x/1.5x/2x, volume slider, mute.OPML import/export:
Let users bring subscriptions from other apps.Offline caching:
Cache artwork and the current/next episode for commute-proof playback.Transcripts:
If feeds expose transcripts (or via a service), show them and sync highlights with timeupdate.Legal/common sense:
You’re streaming from public feed enclosures
, not rehosting. Respect robots.txt and show branding/art as the publisher intends.Even though it may look ugly, it's a clean baseline! Search shows, list episodes, and a friendly player with play/stop/skip and keyboard controls. Let me know what you think about it, and if you have any of your own tips or tricks for a desktop podcasting site.
As always I write these articles once a week on Mondays. Sometimes about code, other times about Magic: The Gathering! You can read more at The Glitched Goblet