A web application that plays two podcasts simultaneously — one in each ear. DualPod leverages the Web Audio API's stereo panning to send separate podcast streams to your left and right audio channels, letting you train your brain to absorb information from two sources at once.
- Dual-channel playback — Play two podcast episodes at the same time, one per ear, using Web Audio API
StereoPannerNode - Podcast discovery — Search any podcast on Apple Podcasts via the iTunes Search API
- RSS feed integration — Subscribe to podcasts and browse episodes fetched from standard RSS feeds
- Playback controls — Independent play/pause, seek, volume, and speed (0.5x, 0.75x, 1x, 1.25x, 1.5x, 2x) per channel
- Authentication — Sign up with email/password (Auth.js v5)
- Stripe subscription — $5/month plan with promotion code support at checkout
- Marketing homepage — Landing page with feature highlights and pricing
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 + shadcn/ui |
| Database | PostgreSQL + Prisma 7 |
| Auth | Auth.js v5 (next-auth beta) |
| Payments | Stripe (subscriptions + promotion codes) |
| Audio | Web Audio API (StereoPannerNode, MediaElementSource) |
| Podcast data | iTunes Search API + RSS feeds via rss-parser |
| Testing | Vitest + React Testing Library |
src/
├── app/
│ ├── page.tsx # Marketing homepage
│ ├── layout.tsx # Root layout (providers, navbar)
│ ├── (auth)/
│ │ ├── login/page.tsx # Login page
│ │ └── signup/page.tsx # Signup page
│ ├── (app)/ # Authenticated + subscribed routes
│ │ ├── layout.tsx # Subscription gate
│ │ ├── dashboard/page.tsx # Dual player page
│ │ ├── search/page.tsx # Podcast search
│ │ ├── podcast/[id]/page.tsx # Podcast episodes
│ │ └── settings/page.tsx # Account & billing
│ └── api/
│ ├── auth/[...nextauth]/route.ts # Auth.js handler
│ ├── auth/signup/route.ts # User registration
│ ├── podcasts/search/route.ts # iTunes search proxy
│ ├── podcasts/feed/route.ts # RSS feed parser proxy
│ ├── subscriptions/route.ts # Podcast subscription CRUD
│ ├── stripe/checkout/route.ts # Create Stripe checkout session
│ ├── stripe/webhook/route.ts # Stripe event webhook
│ ├── stripe/portal/route.ts # Stripe customer portal
│ └── user/subscription/route.ts # Check subscription status
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── auth/ # Login/signup forms
│ ├── layout/ # Navbar
│ ├── marketing/ # Hero, features, pricing
│ ├── player/ # Dual player, channel player, episode selector, speed control
│ └── podcast/ # Search bar, podcast card, episode list
├── hooks/
│ ├── use-audio-engine.ts # React hook wrapping AudioEngine
│ └── use-subscription.ts # Subscription status hook
├── lib/
│ ├── audio-engine.ts # Web Audio API stereo playback engine
│ ├── auth.ts # Auth.js configuration
│ ├── itunes.ts # iTunes Search API client
│ ├── prisma.ts # Prisma client singleton
│ ├── rss.ts # RSS feed parser
│ ├── stripe.ts # Stripe client
│ └── utils.ts # Tailwind merge utility
└── types/
└── index.ts # Shared TypeScript types
- Node.js 18+
- PostgreSQL (or Docker)
- Stripe account with a configured product/price
git clone <repo-url>
cd dualpod
npm installUsing Docker:
docker run -d \
--name dualpod-postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=dualpod \
-p 5432:5432 \
postgres:16Or connect to an existing PostgreSQL instance.
Copy the example and fill in your values:
cp .env.example .env| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
AUTH_SECRET |
Random secret for Auth.js — generate with openssl rand -base64 32 |
AUTH_URL |
Application URL (http://localhost:3000 in dev) |
STRIPE_SECRET_KEY |
Stripe secret API key (sk_test_...) |
STRIPE_PUBLISHABLE_KEY |
Stripe publishable key (pk_test_...) |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret (whsec_...) |
STRIPE_PRICE_ID |
ID of the $5/month Stripe Price object (price_...) |
NEXT_PUBLIC_APP_URL |
Public URL of the app |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
Stripe publishable key (client-side) |
npx prisma migrate devThis runs migrations and generates the Prisma client.
- Create a product in the Stripe Dashboard
- Add a recurring price of $5.00/month
- Copy the Price ID into
STRIPE_PRICE_ID - For local development, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook- Copy the webhook signing secret into
STRIPE_WEBHOOK_SECRET
To create promotion codes:
- Go to Coupons in the Stripe Dashboard
- Create a coupon (e.g., 100% off for 1 month)
- Create a Promotion Code linked to that coupon
- Users enter the code at Stripe Checkout (enabled via
allow_promotion_codes: true)
npm run devOpen http://localhost:3000.
The core of DualPod is the AudioEngine class (src/lib/audio-engine.ts) which uses the Web Audio API:
HTMLAudioElement -> MediaElementSource -> GainNode -> StereoPannerNode -> AudioDestination
Each channel has its own audio graph. The left channel's StereoPannerNode is set to pan: -1 (full left) and the right channel to pan: 1 (full right).
Key design decisions:
createMediaElementSourceis used instead ofdecodeAudioDataso the browser handles streaming and buffering for large podcast files (50-100MB+)AudioContextis created lazily on first user interaction, as browsers require a user gesture before allowing audio playbackcrossOrigin = "anonymous"is set on audio elements for CORS compatibility with podcast CDNs
Podcasts are discovered via the iTunes Search API. The API response includes a feedUrl field — the podcast's RSS feed URL. Episodes are fetched and parsed from these RSS feeds server-side to avoid CORS issues.
- Stripe Checkout handles payment with
mode: "subscription"andallow_promotion_codes: true - A webhook endpoint processes
customer.subscription.created,updated, anddeletedevents - Subscription status is stored on the User model (
stripeCurrentPeriodEnd) - Access is gated by checking
stripeCurrentPeriodEnd > now()
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Production build |
npm run start |
Start production server |
npm run lint |
Run ESLint |
npm test |
Run test suite |
npm run test:coverage |
Run tests with coverage report |
npx prisma studio |
Open Prisma database GUI |
npx prisma migrate dev |
Run database migrations |
The application uses five tables:
- User — Account info, hashed password, Stripe fields (
stripeCustomerId,stripeSubscriptionId,stripeCurrentPeriodEnd) - Account — OAuth provider accounts (Auth.js)
- Session — User sessions (Auth.js)
- VerificationToken — Email verification tokens (Auth.js)
- PodcastSubscription — User's subscribed podcasts (iTunes ID, title, feed URL, artwork)
Episodes are not stored in the database. They are fetched on demand from RSS feeds to ensure data is always current.
- Next.js App Router with route groups:
(auth)for public auth pages,(app)for authenticated/subscribed pages - Middleware protects
/dashboard,/search,/podcast, and/settingsroutes — unauthenticated users are redirected to/login (app)/layout.tsxacts as a subscription gate — users without an active subscription see a paywall- API routes proxy external services (iTunes, RSS feeds) to avoid browser CORS restrictions
- RSS feed responses are cached for 5 minutes (
Cache-Control: max-age=300) - Stripe webhook reads raw request body via
request.text()for signature verification — it must not be pre-parsed as JSON
# Run all tests
npm test
# Run with coverage
npm run test:coverage
# Run specific test file
npx vitest run src/lib/__tests__/itunes.test.ts
# Watch mode
npx vitestTests are organized alongside the code they test in __tests__ directories. The suite covers:
- Lib modules —
AudioEngine, iTunes API client, RSS parser - API routes — Signup, podcast search/feed, subscriptions CRUD, Stripe checkout/webhook/portal, subscription status
- React components — Player controls, auth forms, podcast cards, episode lists
Private — all rights reserved.