Skip to content

Repository files navigation

DualPod

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.

Features

  • 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

Tech Stack

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

Project Structure

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

Prerequisites

  • Node.js 18+
  • PostgreSQL (or Docker)
  • Stripe account with a configured product/price

Getting Started

1. Clone and install

git clone <repo-url>
cd dualpod
npm install

2. Set up PostgreSQL

Using Docker:

docker run -d \
  --name dualpod-postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=dualpod \
  -p 5432:5432 \
  postgres:16

Or connect to an existing PostgreSQL instance.

3. Configure environment variables

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)

4. Set up the database

npx prisma migrate dev

This runs migrations and generates the Prisma client.

5. Set up Stripe

  1. Create a product in the Stripe Dashboard
  2. Add a recurring price of $5.00/month
  3. Copy the Price ID into STRIPE_PRICE_ID
  4. For local development, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook
  1. Copy the webhook signing secret into STRIPE_WEBHOOK_SECRET

To create promotion codes:

  1. Go to Coupons in the Stripe Dashboard
  2. Create a coupon (e.g., 100% off for 1 month)
  3. Create a Promotion Code linked to that coupon
  4. Users enter the code at Stripe Checkout (enabled via allow_promotion_codes: true)

6. Start the dev server

npm run dev

Open http://localhost:3000.

How It Works

Audio Engine

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:

  • createMediaElementSource is used instead of decodeAudioData so the browser handles streaming and buffering for large podcast files (50-100MB+)
  • AudioContext is created lazily on first user interaction, as browsers require a user gesture before allowing audio playback
  • crossOrigin = "anonymous" is set on audio elements for CORS compatibility with podcast CDNs

Podcast Discovery

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.

Subscription Model

  • Stripe Checkout handles payment with mode: "subscription" and allow_promotion_codes: true
  • A webhook endpoint processes customer.subscription.created, updated, and deleted events
  • Subscription status is stored on the User model (stripeCurrentPeriodEnd)
  • Access is gated by checking stripeCurrentPeriodEnd > now()

Scripts

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

Database Schema

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.

Architecture Notes

  • Next.js App Router with route groups: (auth) for public auth pages, (app) for authenticated/subscribed pages
  • Middleware protects /dashboard, /search, /podcast, and /settings routes — unauthenticated users are redirected to /login
  • (app)/layout.tsx acts 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

Testing

# 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 vitest

Tests are organized alongside the code they test in __tests__ directories. The suite covers:

  • Lib modulesAudioEngine, 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

License

Private — all rights reserved.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages