Skip to content
Merged
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
5 changes: 5 additions & 0 deletions firetv/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.gradle/
app/build/
build/
dist/
local.properties
51 changes: 51 additions & 0 deletions firetv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# podcasters (p0dcasters.com) for Fire TV

A small native Android app (`com.p0dcasters.tv`) that puts https://p0dcasters.com on a Fire TV
and makes it work with a remote. No dependencies beyond the Android SDK.

- **WebView** on p0dcasters.com. Remote play/pause, fast-forward 30 s and rewind 15 s drive the page's player (`pitMedia`). Results open in the app; `mailto:`/`intent:` links go to the system.
- **Remote navigation** (`app/src/main/assets/tv.js`): Chromium's WebView scrolls on arrow
keys instead of moving between links, so every D-pad press runs `pitNav(dir)` in the page:
spatial focus to the nearest link/button, scrolling across long gaps, and a large focus
ring. The ring is set through `element.style` because strict CSPs (`style-src 'self'`)
blocks an injected `<style>`.
- **Typing**: OK on a text field (or Menu/Search anywhere) opens a native text box, the one
thing that reliably raises the Fire TV keyboard and its microphone. The text goes back into
the page and a search box submits.
- **Back** walks WebView history, then exits. Full-screen video and an offline screen are native.
- Leanback launcher + banner, touchscreen not required, so the Appstore lists it for Fire TV.

## Build

Needs only Docker. The SDK caches in `~/scratch/android-sdk`.

```sh
# release key (once, ever): vault profullstack/p0dcasters-firetv--signing
logicsrc teams pull profullstack p0dcasters-firetv signing --env keys/release.env
sed -n 's/^PIT_TV_KEYSTORE_B64=//p' keys/release.env | tr -d '"' | base64 -d > keys/release.jks

docker run --rm -e OWNER=$(id -u):$(id -g) -e GRADLE_USER_HOME=/gh \
-v ~/scratch/gradle-home:/gh -v ~/scratch/android-sdk:/sdk -v "$PWD/keys:/keys:ro" \
-v "$PWD/firetv:/w" -w /w gradle:8.7-jdk17 sh build.sh
# -> firetv/dist/podcasters-firetv.apk (lint + signed release)
```

Without `/keys` the APK is built unsigned. Bump `versionCode`/`versionName` in
`app/build.gradle` for every Appstore upload. `new-key.sh` minted the key; never run it
again or installed copies cannot update.

## Test on an Android TV emulator

```sh
docker run --rm --device /dev/kvm -e OWNER=$(id -u):$(id -g) -v ~/scratch/android-sdk:/sdk \
-v "$PWD/firetv:/w" -w /w gradle:8.7-jdk17 bash emulator-test.sh
```

Boots API 30 Android TV (the base of Fire OS 8), installs the APK, drives it with key
events (OK -> keyboard -> search, arrows through results, Back, Menu search
) and writes 1920x1080 screenshots to `store/shots/`.

## Appstore

Listing copy and the art map are in `store/listing.md`; `store/render.sh` regenerates the
icons/banner from `store/src/*.svg`.
46 changes: 46 additions & 0 deletions firetv/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
plugins { id 'com.android.application' }

def env = System.getenv()

android {
namespace 'com.p0dcasters.tv'
compileSdk 34

defaultConfig {
applicationId 'com.p0dcasters.tv'
minSdk 22 // Fire OS 5: every Fire TV still in service
targetSdk 34
versionCode 1
versionName '1.0.0'
}

// The release key lives in the vault, never in the repo. scripts/build.sh
// writes it to a temp file and points these variables at it.
signingConfigs {
release {
if (env.PIT_TV_KEYSTORE) {
storeFile file(env.PIT_TV_KEYSTORE)
storePassword env.PIT_TV_STORE_PASSWORD
keyAlias env.PIT_TV_KEY_ALIAS ?: 'podcasters'
keyPassword env.PIT_TV_KEY_PASSWORD ?: env.PIT_TV_STORE_PASSWORD
}
}
}

buildTypes {
release {
minifyEnabled false
if (env.PIT_TV_KEYSTORE) signingConfig signingConfigs.release
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

lint {
abortOnError true
checkReleaseBuilds true
}
}
40 changes: 40 additions & 0 deletions firetv/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />

<!-- A TV has no touchscreen; without these the Appstore hides the app from Fire TV. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />

<application
android:allowBackup="false"
android:banner="@drawable/banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/PitTheme"
android:usesCleartextTraffic="false">

<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
24 changes: 24 additions & 0 deletions firetv/app/src/main/assets/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>podcasters is out of reach</title>
<style>
html, body { margin: 0; height: 100%; background: #14140f; color: #f4f1ea;
font: 28px/1.4 system-ui, sans-serif; }
main { height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; padding: 0 10%; box-sizing: border-box; }
h1 { color: #e27a55; font-size: 48px; margin: 0 0 16px; }
a { margin-top: 32px; padding: 16px 40px; border-radius: 12px; background: #e27a55;
color: #14140f; font-weight: 700; text-decoration: none; }
</style>
</head>
<body>
<main>
<h1>podcasters is out of reach</h1>
<p>Check this TV's network connection, then press OK to try again.</p>
<a href="https://p0dcasters.com/" autofocus>Try again</a>
</main>
</body>
</html>
174 changes: 174 additions & 0 deletions firetv/app/src/main/assets/tv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* Podcasters (p0dcasters.com) for Fire TV: remote-control navigation for any page in the WebView.
*
* Chromium's WebView scrolls on arrow keys instead of moving between links, so
* the activity hands every D-pad press to pitNav() and OK to pitEnter(). Both
* return a short string the activity acts on. Written in ES5 on purpose: Fire
* OS 5 devices still ship WebView 70-something.
*/
(function () {
if (window.__pitTV) return;
window.__pitTV = true;

var SEL = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),' +
'select:not([disabled]),textarea:not([disabled]),summary,[tabindex]:not([tabindex="-1"])';
var TEXT = /^(search|text|email|url|tel|number|password|)$/;
var field = null; // the text field OK was pressed on, while the keyboard is up

// The focus ring is set through CSSOM on the element itself: p0dcasters.com (and
// many sites) ship style-src 'self', which blocks an injected <style> but not
// element.style. A TV needs a ring you can see from the couch.
var RING = [['outline', '4px solid #e27a55'], ['outline-offset', '3px'],
['box-shadow', '0 0 0 8px rgba(5,5,5,.85)']];
function ring(el, on) {
if (!el || !el.style) return;
for (var i = 0; i < RING.length; i++) {
if (on) el.style.setProperty(RING[i][0], RING[i][1], 'important');
else el.style.removeProperty(RING[i][0]);
}
}
var styled = false;
function addStyle() {
if (styled) return;
styled = true;
document.addEventListener('focusin', function (e) { ring(e.target, true); }, true);
document.addEventListener('focusout', function (e) { ring(e.target, false); }, true);
var a = document.activeElement;
if (a && a !== document.body && a !== document.documentElement) ring(a, true);
}

function shown(el) {
var r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return false;
var cs = window.getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false;
for (var p = el; p && p.nodeType === 1; p = p.parentNode) {
if (p.hidden || p.getAttribute('aria-hidden') === 'true' || p.inert) return false;
}
return true;
}

function isText(el) {
if (!el) return false;
if (el.tagName === 'TEXTAREA') return !el.readOnly;
return el.tagName === 'INPUT' && TEXT.test((el.getAttribute('type') || '').toLowerCase()) && !el.readOnly;
}

function go(el) {
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
var r = el.getBoundingClientRect();
if (r.top < 60 || r.bottom > window.innerHeight - 60) {
window.scrollBy(0, r.top + r.height / 2 - window.innerHeight / 2);
}
return 'moved';
}

function scroll(dir) {
var step = Math.round(window.innerHeight * 0.6);
var before = window.pageYOffset;
if (dir === 'down') window.scrollBy(0, step);
else if (dir === 'up') window.scrollBy(0, -step);
else return 'edge';
return window.pageYOffset === before ? 'edge' : 'scrolled';
}

// Distance along the press direction, plus how far off-axis the target sits.
// Overlapping ranges on the cross axis count as perfectly aligned.
function gap(a1, a2, b1, b2) {
if (b2 < a1) return a1 - b2;
if (b1 > a2) return b1 - a2;
return 0;
}

window.pitNav = function (dir) {
addStyle();
var cur = document.activeElement;
var all = document.querySelectorAll(SEL);
var vh = window.innerHeight, i, el, r;

if (!cur || cur === document.body || cur === document.documentElement || !shown(cur)) {
for (i = 0; i < all.length; i++) {
el = all[i];
if (!shown(el)) continue;
r = el.getBoundingClientRect();
if (r.bottom > 0 && r.top < vh) return go(el);
}
return scroll(dir);
}

var c = cur.getBoundingClientRect();
var best = null, bestScore = Infinity, main, cross;
for (i = 0; i < all.length; i++) {
el = all[i];
if (el === cur || cur.contains(el) || el.contains(cur) || !shown(el)) continue;
r = el.getBoundingClientRect();
if (dir === 'down') { if (r.top < c.bottom - 4) continue; main = r.top - c.bottom; cross = gap(c.left, c.right, r.left, r.right); }
else if (dir === 'up') { if (r.bottom > c.top + 4) continue; main = c.top - r.bottom; cross = gap(c.left, c.right, r.left, r.right); }
else if (dir === 'right') { if (r.left < c.right - 4) continue; main = r.left - c.right; cross = gap(c.top, c.bottom, r.top, r.bottom); }
else { if (r.right > c.left + 4) continue; main = c.left - r.right; cross = gap(c.top, c.bottom, r.top, r.bottom); }
// Sideways moves only go to things on roughly the same row.
if ((dir === 'left' || dir === 'right') && cross > c.height * 2) continue;
var score = Math.max(main, 0) + cross * 3;
if (score < bestScore) { bestScore = score; best = el; }
}

// Long gaps (a block of prose, a big image) are read by scrolling, not skipped.
if ((dir === 'up' || dir === 'down') && (!best || bestScore > vh * 0.8)) {
var moved = scroll(dir);
if (moved === 'scrolled') return moved;
}
return best ? go(best) : 'edge';
};

// OK on the remote. Text fields go to the native Fire TV keyboard, which a
// WebView never raises reliably; everything else is clicked here.
window.pitEnter = function () {
addStyle();
var el = document.activeElement;
if (!el || el === document.body) return 'none';
if (isText(el)) {
field = el;
return 'text:' + JSON.stringify({
value: el.value || '',
hint: el.getAttribute('placeholder') || el.getAttribute('aria-label') || '',
type: (el.getAttribute('type') || 'text').toLowerCase()
});
}
if (el.tagName === 'SELECT') return 'native';
el.click();
return 'clicked';
};

// The keyboard's answer goes back into the field; a search box submits.
window.pitFill = function (value) {
var el = field && document.contains(field) ? field : document.activeElement;
field = null;
if (!isText(el)) return 'gone';
el.value = value;
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
var type = (el.getAttribute('type') || '').toLowerCase();
if (el.form && (type === 'search' || el.name === 'q')) {
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
return 'submitted';
}
return 'filled';
};

// The remote's play/pause, fast-forward and rewind keys drive whichever
// <audio>/<video> on the page is playing (or the first one that has a source).
window.pitMedia = function (action) {
var all = document.querySelectorAll('audio,video'), m = null, i;
for (i = 0; i < all.length; i++) { if (!all[i].paused) { m = all[i]; break; } }
if (!m) for (i = 0; i < all.length; i++) { if (all[i].currentSrc || all[i].src) { m = all[i]; break; } }
if (!m) return 'none';
if (action === 'playpause') { if (m.paused) m.play(); else m.pause(); }
else if (action === 'play') m.play();
else if (action === 'pause') m.pause();
else if (action === 'ff') m.currentTime = Math.min((m.duration || Infinity), m.currentTime + 30);
else if (action === 'rew') m.currentTime = Math.max(0, m.currentTime - 15);
return 'ok';
};

addStyle();
})();
Loading
Loading