Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions firetv/emulator-test.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
#!/bin/bash
# Boots an Android TV emulator (API 30, the base of Fire OS 8), installs the APK,
# drives it with remote-control keys and saves 1920x1080 screenshots to store/shots.
#
# THIS IS A FUNCTIONAL TEST, NOT THE SOURCE OF STORE ART. It searches the live
# directory, so 04-results, 05-results-nav, 05b-results-right and 08-menu-search
# are full of other people's cover art and show names. Uploading those is what
# got 1.0.0 rejected on 2026-11-04 under Amazon's IP and Copyright policy.
# Store screenshots come from store/capture.sh (/tv-preview), never from here.
# Runs inside gradle:8.7-jdk17 with --device /dev/kvm and the SDK cache at /sdk:
# docker run --rm --device /dev/kvm -v ~/scratch/android-sdk:/sdk -v "$PWD/firetv:/w" -w /w \
# gradle:8.7-jdk17 bash emulator-test.sh
Expand Down
51 changes: 51 additions & 0 deletions firetv/store/capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/sh
# Store screenshots for the Amazon Appstore listing, 1920x1080.
#
# These must carry NO third-party cover art and NO real show names. Amazon
# rejected 1.0.0 on 2026-11-04 because the screenshots were live captures of
# /search: real artwork and real titles (Mighty Oaks Daily Devotional, The
# Harry Sisson Show) were in the listing, and we hold no licence to any of it.
#
# Both shots therefore come from /tv-preview, which renders the real card grid
# with invented shows and no image URLs, so <Art> draws its own typographic
# tile for every square. The app is a WebView of this site at this size, so a
# headless capture is what the TV shows, without needing the Android SDK.
#
# Three things that are not obvious, each found the hard way:
#
# * NOT the home page. Below its hero it shows the "deep catalogues" row,
# which is real cover art - and two of those shows are the ones Amazon
# named. /tv-preview?hero=1 gives the same hero with our own grid instead.
# * NOT /search either, even with no query: it carries a sponsored ad and
# names a real third-party domain in its help text.
# * Point BASE at the app container, not at the public hostname. p0dcasters
# answers a headless browser with its own x402 paywall ("You have used up
# the free allowance") after 20 requests an hour, and you will screenshot
# that instead. A browser user agent is still needed: the default
# HeadlessChrome string is treated as a crawler.
#
# Usage:
# sh firetv/store/capture.sh # the dev2 app container
# sh firetv/store/capture.sh http://127.0.0.1:PORT
set -eu
BASE=${1:-http://127.0.0.1:3235}
OUT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/shots
UA="Mozilla/5.0 (X11; CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
mkdir -p "$OUT"

shot() { # shot <name> <path>
docker run --rm --memory 3g --shm-size 1g --network host \
-v "$OUT:/out" -w /out --entrypoint chromium-browser \
zenika/alpine-chrome:124 \
--headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
--hide-scrollbars --window-size=1920,1080 --virtual-time-budget=15000 \
--user-agent="$UA" --screenshot="/out/$1.png" "$BASE$2"
echo "$1.png <- $BASE$2"
}

shot 01-hero "/tv-preview?hero=1"
shot 02-grid /tv-preview

echo
echo "Look at both before uploading: no cover art that is not ours, no real show titles."
ls -l "$OUT"
20 changes: 18 additions & 2 deletions firetv/store/listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Every podcast with a feed, plus live radio and TV. Free, no account.

podcasters puts p0dcasters.com on your TV: every podcast with a public feed, on any host, plus live radio and TV channels.

Over 390,000 live shows read straight from their RSS feeds: shows on their own domain, shows on Anchor, Buzzsprout, Libsyn and every other host, and the big libraries like TWiT, NPR and the BBC. Free, no account, nothing to sign up for.
Over 390,000 live shows read straight from their RSS feeds: shows on their own domain, shows on every hosting platform, and the big network libraries. Free, no account, nothing to sign up for.

podcasters is a podcast reader. It lists publicly published RSS feeds and plays them from the publisher's own server. It does not host, rehost or alter anyone's audio, and every show remains the property of whoever publishes it. Publishers can have a feed removed at any time from the contact page.

Made for the remote:
- Press Menu to search, and type with the Fire TV keyboard or your voice.
Expand Down Expand Up @@ -47,4 +49,18 @@ podcasts, podcast player, radio, live tv, p0dcasters, podcasters, rss, free podc
| Icon 512x512 / 114x114 | out/icon-512.png, out/icon-114.png |
| Fire TV icon 1280x720 | out/firetv-icon-1280x720.png |
| Fire TV background 1920x1080 | out/firetv-background-1920x1080.png |
| Screenshots 1920x1080 | shots/01-home.png, 05-results-nav.png, 04-results.png, 08-menu-search.png |
| Screenshots 1920x1080 | shots/01-hero.png, shots/02-grid.png |

### Screenshots must not carry anyone else's artwork

Amazon rejected version 1.0.0 on 2026-11-04 under the Intellectual Property
and Copyright policy. The screenshots were live captures of `/search`, so real
cover art and real show names — Mighty Oaks Daily Devotional, The Harry Sisson
Show and others — were sitting in the store listing. We have no licence to
anyone's cover art; a feed being public does not grant one.

Capture the grid from **`/tv-preview`**, never from `/search`, `/podcasts` or
the home page's "deep catalogues" row. That page renders the real card grid
with invented shows and no image URLs, so `<Art>` draws its own typographic
tile for every square. The search screenshot must be taken with the keyboard
open and **no results behind it**.
Binary file added firetv/store/shots/01-hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed firetv/store/shots/01-home.png
Binary file not shown.
Binary file added firetv/store/shots/02-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed firetv/store/shots/03-keyboard.png
Binary file not shown.
Binary file removed firetv/store/shots/04-results.png
Binary file not shown.
Binary file removed firetv/store/shots/05-results-nav.png
Binary file not shown.
Binary file removed firetv/store/shots/05b-results-right.png
Binary file not shown.
Binary file removed firetv/store/shots/06-back.png
Binary file not shown.
Binary file removed firetv/store/shots/07-menu-search.png
Binary file not shown.
Binary file removed firetv/store/shots/08-menu-search.png
Binary file not shown.
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const metadata: Metadata = {
// 146 characters. Kept short: a wording of 176 once lost its last clause to
// truncation in exactly the snippet it was written for.
description:
"A free directory of every live podcast with an RSS feed — on any host, plus networks like TWiT, NPR and the BBC. Listen in the browser.",
"A free directory of every live podcast with an RSS feed — on any host, plus the big network libraries. Listen in the browser.",
openGraph: {
type: "website",
siteName: "p0dcasters",
Expand Down
2 changes: 1 addition & 1 deletion src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default async function Image() {
marginTop: 22,
}}
>
Self-hosted shows, every hosting platform, and networks like TWiT, NPR and the BBC.
Self-hosted shows, every hosting platform, and the big network libraries.
Listen in the browser and follow your favourites.
</div>
</div>
Expand Down
7 changes: 3 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ export default async function Home() {
<h1>Every podcast with a feed — on any host.</h1>
<p className="lede">
<span className="count">{total.toLocaleString()}</span> live shows, read from
their RSS feeds: shows on their own domain, shows on Anchor, Buzzsprout, Libsyn
and every other host, and the big libraries — TWiT, NPR, the BBC and the rest of
the <Link href="/networks">networks</Link>. Free, no account, listen in the
browser. Only the shows on their own domain?{" "}
their RSS feeds: shows on their own domain, shows on every hosting platform,
and the big library <Link href="/networks">networks</Link>. Free, no account,
listen in the browser. Only the shows on their own domain?{" "}
<Link href="/self-hosted">Self-hosted</Link>. Missing one?{" "}
<Link href="/submit">Add it</Link>.
</p>
Expand Down
106 changes: 106 additions & 0 deletions src/app/tv-preview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Metadata } from "next";
import type { Podcast } from "@/lib/db";
import Card from "@/components/Card";

/**
* Store-capture page. Renders the real grid with invented shows and no
* artwork, so a screenshot carries none of the cover art or show names that
* belong to the people who publish the feeds we list.
*
* It exists because the Amazon Appstore rejected com.p0dcasters.tv on
* 2026-11-04: the screenshots were live captures of /search, so real third-party
* cover art and titles sat in the store listing, which is exactly what their IP
* policy forbids. Capture Fire TV screenshots HERE, never from /search.
*
* `image_url: ""` is the point — <Art> falls back to its own typographic tile,
* so every square is drawn by us. Keep the names invented, and keep them
* obviously generic so nobody reads them as a real show.
*/
export const metadata: Metadata = {
title: "Preview",
robots: { index: false, follow: false },
};

const SAMPLE: Array<Pick<Podcast, "title" | "host" | "episode_count"> & { days: number }> = [
{ title: "The Sample Show", host: "example.com", episode_count: 412, days: 1 },
{ title: "Morning Build Notes", host: "example.org", episode_count: 196, days: 2 },
{ title: "Field Recordings Weekly", host: "example.net", episode_count: 88, days: 1 },
{ title: "Two Founders Talking", host: "example.com", episode_count: 523, days: 3 },
{ title: "The Long Commute", host: "example.org", episode_count: 274, days: 1 },
{ title: "Kitchen Table History", host: "example.net", episode_count: 331, days: 4 },
{ title: "Nightshift Radio Hour", host: "example.com", episode_count: 605, days: 2 },
{ title: "Questions for Later", host: "example.org", episode_count: 147, days: 1 },
{ title: "A Walk and a Microphone", host: "example.net", episode_count: 239, days: 5 },
{ title: "Slow News Sunday", host: "example.com", episode_count: 164, days: 2 },
{ title: "The Back Catalogue", host: "example.org", episode_count: 718, days: 1 },
{ title: "Notes from the Workshop", host: "example.net", episode_count: 205, days: 3 },
];

const DAY = 86_400;

export default async function TvPreviewPage({
searchParams,
}: {
searchParams: Promise<{ hero?: string }>;
}) {
// ?hero=1 adds the real hero above the grid, for the first store screenshot.
// The home page cannot be used for one: below its hero it shows the "deep
// catalogues" row, which is real cover art, and two of those shows are the
// ones Amazon named in the rejection.
const hero = (await searchParams).hero === "1";
const now = Math.floor(Date.now() / 1000);
const shows = SAMPLE.map((s, i) => ({
id: i + 1,
slug: `sample-${i + 1}`,
guid: null,
feed_url: "",
title: s.title,
description: "",
// Empty on purpose: <Art> draws our own letter tile instead.
image_url: "",
link: null,
host: s.host,
author: null,
owner: null,
explicit: 0,
language: "en",
lang_base: "en",
category: null,
categories: null,
episode_count: s.episode_count,
newest_pubdate: now - s.days * DAY,
oldest_pubdate: null,
created_on: null,
})) as unknown as Podcast[];

return (
<>
{hero && (
<div className="hero">
<div className="wrap">
<h1>Every podcast with a feed — on any host.</h1>
<p className="lede">
390,000 live shows, read from their RSS feeds: shows on their own domain,
shows on every hosting platform, and the big library networks. Free, no
account, listen on your TV.
</p>
<form className="searchbar" action="/search">
<input name="q" placeholder="Search podcasts…" aria-label="Search podcasts" />
<button type="submit">Search</button>
</form>
</div>
</div>
)}
<main className="wrap">
<section>
<h2 className="sec">Browse every show</h2>
<div className="grid">
{shows.map((p) => (
<Card key={p.id} p={p} />
))}
</div>
</section>
</main>
</>
);
}