YouTube Kids isn't it. The algorithm is loud, the recommendations drift, and the whole interface is engineered to keep a seven-year-old clicking. We wanted something quieter: a TV app where the channels in the library are the ones a parent actually chose, videos play without sidebars full of "up next," and the hardest decision a kid makes is which show, not which of a thousand thumbnails.
QuietPlay is that app. A self-hosted Node server pulls uploads from a curated list of YouTube channels via yt-dlp, stores metadata in Postgres, and resolves playable stream URLs on demand. A SwiftUI tvOS client renders the library — channels on the left, videos on the right — with no search, no algorithm, no comments, no ads, and no autoplay. When a video ends or the kid swipes right, a three-card picker offers the next videos in the same channel. That's it.
It was built for one specific seven-year-old (hi, Henry) but the architecture is generic. If you've got an Apple TV, a spare Mac or Linux box, and a list of channels you trust, you can run your own.
- Library-first, no algorithm. You curate the channels; QuietPlay never recommends anything you didn't pick.
- Calm playback. Fullscreen video, generous margins, a single play indicator. Swipe right for the next video, menu for back to library.
- "Who's watching?" Multi-profile picker on cold launch, with per-family profile photos.
- Resume where you left off. Playback position saved every 5 seconds, surfaced as a progress bar on each thumbnail plus a pinned "Continue watching" row.
- Favorites. Press Play/Pause on any video card to star it; starred videos appear in a gold "Favorites" row pinned above everything else.
- Continue-browsing strip. After the last video in a channel, a horizontal row of other channels so a kid can hop without going back to the sidebar.
- No Shorts. Ingest filters out Shorts automatically.
- Warm loading screen. Rotating deadpan quips ("Asking YouTube nicely…", "Buttering the popcorn…") while the stream resolves.
- Admin web UI (LAN-only) for adding channels, tweaking profiles, and flipping channels active/inactive — no SSH into Postgres required.
- Self-contained. No YouTube Data API key. No Google OAuth. Just
yt-dlpunder the hood.
┌────────────────────────┐ ┌──────────────────────────┐
│ tvOS SwiftUI client │ HTTP │ Fastify server │
│ AVPlayer + Library │ ────────▶ │ /library /resolve /admin│
└────────────────────────┘ │ Postgres · Redis (cache)│
│ yt-dlp subprocess │
└───────────┬──────────────┘
│
▼
┌────────────────┐
│ YouTube (RSS │
│ & yt-dlp flat │
│ playlist) │
└────────────────┘
- Ingest — hourly cron. For each active channel,
yt-dlp --flat-playlist --playlist-end 500lists the 500 newest uploads. Rows upsert into Postgres withis_shortflagged by duration (≤60s). Shorts are hidden from the client. - Resolve — on each tap, the tvOS client POSTs the YouTube video ID to
/resolve. The server shells toyt-dlpto get a fresh stream URL, caches it in Redis for ~5 hours, and returns it. AVPlayer plays the URL directly. - Admin — Fastify serves a single-file HTML admin UI at
/admin(no auth — LAN boundary is the security model). Channel URL → metadata lookup happens server-side by scraping the channel page forog:titleand theUC…ID. - Storage — Postgres is the source of truth. Redis is only used for short-lived resolver caching.
- macOS with Xcode 16+ (tvOS 18 SDK)
- Node.js 20+
- Docker (simplest path for Postgres + Redis) or native installs of both
yt-dlpon the server host's PATH (Homebrew:brew install yt-dlp)- An Apple TV for the full experience — the tvOS Simulator works for development
git clone https://github.com/adampickering/quietplay.git
cd quietplay
npm installdocker compose up -dcp .env.example server/.env
# adjust DATABASE_URL / REDIS_URL if you're not using the docker-compose defaultsnpm run -w @quietplay/server migratenpm run -w @quietplay/server devServer listens on http://localhost:8787. Admin UI at http://localhost:8787/admin. No auth — assume LAN-only access.
Open the admin UI, paste any YouTube channel URL (works with @handle, channel/UC…, and custom URLs), and save. Then create a profile and tick the channels you want to include.
Open ios/QuietPlay/QuietPlay.xcodeproj in Xcode, pick the tvOS Simulator, and hit Run. You should land on the "Who's watching?" screen.
The tvOS Simulator talks to localhost:8787 out of the box, but an Apple TV on your network needs your Mac's LAN IP. In ios/QuietPlay/Info.plist, change:
<key>QuietPlayAPIBaseURL</key>
<string>http://localhost:8787</string>to your Mac's IP (e.g. http://192.168.0.143:8787). To avoid committing your personal LAN IP to your fork, after editing run:
git update-index --skip-worktree ios/QuietPlay/Info.plistThen pair the Apple TV in Xcode via Window → Devices and Simulators, pick it as the run destination, and Cmd-R. First launch takes a few minutes while Xcode downloads symbols for the device's tvOS version.
Note on Xcode free signing. Apps signed with a Personal Team expire after 7 days. For longer installs you'll need an Apple Developer account ($99/yr).
Profile photos are matched to profile names by asset lookup:
- Profile named "Henry" → looks for
ProfileHenryinAssets.xcassets - Profile named "Dad" → looks for
ProfileDad
To add a photo:
mkdir -p ios/QuietPlay/QuietPlay/Assets.xcassets/ProfileYourName.imageset
cp ~/Downloads/myface.png ios/QuietPlay/QuietPlay/Assets.xcassets/ProfileYourName.imageset/yourname.pngThen add a Contents.json alongside the PNG:
{
"images": [
{ "filename": "yourname.png", "idiom": "universal", "scale": "1x" },
{ "idiom": "universal", "scale": "2x" },
{ "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}If no asset is found, QuietPlay falls back to rendering the profile's initials in a circle. The Profile*.imageset folders are gitignored so personal photos stay off your public fork.
The scripts/ folder has two pieces worth knowing:
scripts/install-cron.sh— sets up three user crontab entries: hourly ingest, weeklyyt-dlpupgrade (Sundays 03:00), and nightly Postgres dump (04:00). Idempotent.scripts/backup-postgres.sh—pg_dump | gzipto~/backups/quietplaywith a 14-day retention.
Health check endpoint: GET /healthz returns {status, db, redis, uptime_seconds}. Returns 503 if any dependency is down.
On a focused video card in the library:
- Click the clickpad → play
- Play/Pause → toggle favorite (pins a gold star to the thumbnail and adds it to the Favorites row)
- Menu → back to the sidebar
During playback:
- Click → show/hide overlay (title + progress)
- Swipe right → picker for next video
- Swipe left → previous video in the channel
- Play/Pause → pause/resume
- Menu → back to library
PRs welcome. The test suite:
# Swift (PickerBuilder logic)
cd ios && swift test
# Server
npm test -w @quietplay/server
# Typecheck
npm run -w @quietplay/server typecheckMIT. See LICENSE.
The QuietPlay wordmark and logo in docs/logo.png and the app icon assets under ios/QuietPlay/QuietPlay/Assets.xcassets/App Icon & Top Shelf Image.brandassets/ are not MIT-licensed — please swap them for your own branding on a fork.
Built for Henry. The loading-screen quip "Reticulating splines…" is borrowed with gratitude from SimCity.
