The Glitched Goblet Logo

The Glitched Goblet

Where Magic Meets Technology

Build a Desktop-Friendly Podcast App with Next.js

August 25, 2025

Intro

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).

Setting Up / Prerequisites

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?

  • Putting search and RSS fetch on the server avoids CORS headaches, keeps feed URLs off the client, and lets us normalize shapes.
  • The native <audio> element is battle-tested, accessible, and good enough for a basic player.
  • You can swap providers later (e.g., PodcastIndex) with minimal code changes.
# 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

Implementation Steps

Add Types

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.

Search API (server) — proxy iTunes Search

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:

  • iTunes returns both shows and episodes; media=podcast nudges it to collections.
  • Some items don’t include feedUrl so we filter them out.
  • revalidate: 60 caches responses for a minute in production.

Episodes API (server) — fetch & parse RSS

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.

Audio Player component (play/stop/skip + a11y & keys)

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:

  • Buttons have aria-labels; the player uses role="region" with a label.
  • The range input is labelled and keyboard-operable.
  • Focus rings: add a tiny global style so users can see where they are.

In app/globals.css

button:focus,
input:focus {
  outline: 2px solid #5b9aff;
  outline-offset: 2px;
}

Hook it up in the main page (search -> show -> episodes -> play)

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?

  • Search resets selection to prevent stale episodes.
  • Episode selection autoplays—fast feedback.
  • Results use buttons (not links) because they change state within the page, not navigation.

A11y checklist for this app:

  • Every interactive element is reachable by Tab.
  • Labels/aria-labels exist where the visible text isn’t sufficient.
  • The player is a named region so screen readers can jump to it.

Next Steps

  • 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.

Outro

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