Bootstrap Castarr from NodeCast prototype

Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR
pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and
the Dispatcharr integration follow as tracked issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-25 00:09:00 +02:00
commit a11b5a68c0
44 changed files with 2418 additions and 0 deletions

27
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Build TV app
on:
push:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- name: Build debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: castarr-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/

70
CONTEXT.md Normal file
View File

@@ -0,0 +1,70 @@
# Castarr
Native Kotlin app for Google TV that lets every user of a Dispatcharr backend
watch live IPTV comfortably on their own TV: full D-pad UI, login via OIDC
Device Flow (Authentik), plus an optional phone Remote paired via QR code.
## Language
**TV-App (Castarr)**:
The native Kotlin app running on Google TV; full TV UI, plays streams, serves
the Remote.
_Avoid_: receiver, player app, NodeCast (old name)
**Device Flow**:
OIDC device authorization grant: TV shows QR/code, user confirms on the phone
at Authentik, app receives Bearer tokens for the Backend API.
_Avoid_: pairing (that word is reserved for the Remote)
**Backend (Dispatcharr)**:
The self-hosted stream manager owning sources, aggregation, failover, EPG and
stream profiles; forked at be-nj with native OIDC.
_Avoid_: server (ambiguous), nodecast (dropped, see ADR-0003)
**Quelle (Source)**:
A configured backend the TV-App reads channels from — primary type Dispatcharr
fork (Bearer API), fallback type generic M3U+XMLTV without login (Threadfin,
Tunarr, ErsatzTV).
**Remote**:
The phone web UI served by the TV-App over HTTP; talks to the TV-App via
WebSocket. Never talks to the Backend directly.
_Avoid_: app (it is not an installed app), controller
**Pairing**:
Trusting a Remote via the QR token (128-bit) or the rate-limited 4-digit code.
**Channel**:
A playable live entry the Backend exposes via Xtream API or M3U.
_Avoid_: station, sender (in code/docs)
**Now/Next**:
The EPG scope of the Remote in phase 1: current + upcoming programme per
Channel, no full guide timeline.
## Relationships
- The **TV-App** is a client of the **Backend**; the **Remote** only ever
talks to the TV-App.
- One **User** logs into one **TV-App** via **Device Flow**; favorites belong
to the User on the Backend, not to the device.
- A **Remote** controls exactly one **TV-App**; a TV-App accepts multiple
paired Remotes.
- Aggregation, failover and stream shaping happen in the **Backend**, not in
the TV-App.
## Example dialogue
> **Dev:** "Does the **Remote** need Backend credentials?"
> **Domain expert:** "No — the user enters the Xtream credentials of a
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
> one talking to the **Backend**."
## Flagged ambiguities
- "nativ" means: no Flutter/WebView wrapper — Kotlin app. It does not mean
"no embedded web content" (the Remote is deliberately a web page).
- "Passthrough" means the fork's `raw` stream profile (ffmpeg copy), selected
per request via profile parameter — not a bypass of the Backend proxy.
- Favorites are per **User** in the Dispatcharr fork (resolved; app-local and
channel-group approaches were rejected).

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# Castarr
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr)
backend: live IPTV with a full D-pad UI, login via OIDC device flow
(Authentik), per-user favorites, and an optional phone remote paired by QR
code (served by the TV itself, no phone app install).
Status: bootstrap. The code base was imported from the NodeCast prototype
(phone-remote-first, M3U standalone) and is being reshaped per the decisions
in [CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/) — see the issue tracker
for the phase-1 slices.
## Building
Requires JDK 17+ and the Android SDK (platform 35).
```
./gradlew assembleDebug
```
APK output: `app/build/outputs/apk/debug/app-debug.apk`.

51
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,51 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.nodecast.tv"
compileSdk = 35
defaultConfig {
applicationId = "com.nodecast.tv"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
allWarningsAsErrors = true
}
lint {
// Media3's UI classes are @UnstableApi; the opt-in lint check would
// otherwise fail release builds.
disable += "UnsafeOptInUsageError"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-session:1.4.1")
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3")
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# NanoHTTPD loads its mime-type tables reflectively.
-keep class fi.iki.elonen.** { *; }
-dontwarn fi.iki.elonen.**

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.NodeCast">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,502 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a0b0d">
<title>NodeCast Remote</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600&display=swap">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
body {
font-family: 'Space Grotesk', system-ui, sans-serif;
background: #0a0b0d; color: #f2f3f5;
display: flex; flex-direction: column;
padding-top: env(safe-area-inset-top);
user-select: none; -webkit-user-select: none;
}
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
input { font: inherit; color: #f2f3f5; }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 24px 0;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-size: 14px; font-weight: 500;
letter-spacing: .2em; text-transform: uppercase; }
.tvchip { display: flex; align-items: center; gap: 8px; border: 1px solid #1e2126;
border-radius: 999px; padding: 8px 14px; font-size: 12px; color: #9aa0a8; letter-spacing: .03em; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: #5fd4c4; }
.dot.off { background: #e5484d; }
main { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; }
.view { flex: 1 1 auto; display: none; flex-direction: column; }
.view.active { display: flex; }
/* --- Steuerung --- */
#view-remote { justify-content: center; gap: 44px; padding: 24px; }
.nowcard {
position: relative; height: 200px; border-radius: 24px; overflow: hidden;
background: radial-gradient(130% 110% at 70% 20%, #16324a 0%, #0d1b2e 55%, #080c14 100%);
box-shadow: 0 0 0 1px rgba(255,255,255,.06);
}
.nowcard .glow { position: absolute; top: -40px; right: -20px; width: 220px; height: 220px;
border-radius: 50%; background: radial-gradient(circle, rgba(95,212,196,.18) 0%, rgba(95,212,196,0) 60%); }
.nowcard .stop {
position: absolute; top: 14px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
display: none; align-items: center; justify-content: center;
background: rgba(10,11,13,.45); border: 1px solid rgba(255,255,255,.1);
}
.nowcard.playing .stop { display: flex; }
.nowcard .info {
position: absolute; left: 0; right: 0; bottom: 0; padding: 44px 22px 20px;
display: flex; flex-direction: column; gap: 5px;
background: linear-gradient(to top, rgba(8,12,20,.85) 0%, rgba(8,12,20,0) 100%);
}
.liverow { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500;
letter-spacing: .16em; color: #b8bdc4; }
.livedot { width: 6px; height: 6px; border-radius: 50%; background: #e5484d; }
#now-title { font-size: 24px; font-weight: 500; letter-spacing: -.01em; }
#now-sub { font-size: 13px; font-weight: 300; color: #9aa0a8; }
.transport { display: flex; align-items: center; justify-content: center; gap: 34px; }
.btn-side { width: 56px; height: 56px; border-radius: 50%; border: 1px solid #1e2126;
display: flex; align-items: center; justify-content: center; }
.btn-side:active { background: #16181c; }
.btn-main { width: 88px; height: 88px; border-radius: 50%; background: #5fd4c4;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 12px 44px rgba(95,212,196,.28); transition: transform .1s; }
.btn-main:active { transform: scale(.94); }
.btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
width: 22px; height: 22px; border-radius: 50%; background: #f2f3f5;
box-shadow: 0 2px 8px rgba(0,0,0,.5); }
/* --- Sender --- */
#view-channels { padding: 18px 12px 0; gap: 14px; }
.chead { display: flex; align-items: center; gap: 10px; padding: 0 12px; }
.chead h1 { font-size: 26px; font-weight: 500; letter-spacing: -.01em; flex: 1; }
.iconbtn { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; }
.iconbtn:active { background: #16181c; }
.searchrow { display: flex; align-items: center; gap: 10px; background: #121418;
border-radius: 12px; padding: 0 16px; margin: 0 12px; }
.searchrow input { flex: 1; background: none; border: none; outline: none; height: 48px;
font-size: 15px; font-weight: 300; }
.searchrow input::placeholder { color: #6b717a; }
#setup { display: none; flex-direction: column; gap: 10px; margin: 0 12px; padding: 16px;
background: #101216; border-radius: 14px; }
#setup.open { display: flex; }
#setup label { font-size: 12px; color: #9aa0a8; letter-spacing: .04em; }
#setup input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 10px;
height: 46px; padding: 0 14px; font-size: 14px; outline: none; width: 100%; }
#setup input:focus { border-color: #5fd4c4; }
.btn-accent { height: 46px; border-radius: 10px; background: #5fd4c4; color: #07110f;
font-weight: 500; font-size: 14px; }
.btn-accent:active { opacity: .85; }
#channel-list { display: flex; flex-direction: column; padding-bottom: 12px; }
.chan { display: flex; align-items: center; gap: 14px; padding: 12px; border-radius: 14px;
text-align: left; width: 100%; }
.chan:active { background: #121418; }
.chan.current { background: rgba(95,212,196,.07); }
.chan .num { width: 44px; height: 44px; border-radius: 12px; background: #17191e; flex: none;
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 500;
color: #7d838c; overflow: hidden; }
.chan.current .num { background: #16324a; color: #9fc3d8; }
.chan .num img { width: 100%; height: 100%; object-fit: contain; }
.chan .meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.chan .name { font-size: 16px; font-weight: 400; color: #e6e8ea; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.chan.current .name { color: #5fd4c4; font-weight: 500; }
.chan .grp { font-size: 12px; font-weight: 300; color: #6b717a; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.empty { padding: 40px 24px; text-align: center; color: #6b717a; font-size: 14px;
font-weight: 300; line-height: 1.5; }
/* --- Tabs --- */
nav { display: flex; border-top: 1px solid #16181c; padding: 10px 24px;
padding-bottom: calc(14px + env(safe-area-inset-bottom)); gap: 12px; flex: none; }
nav button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 6px 0; font-size: 11px; color: #6b717a; letter-spacing: .04em; }
nav button.active { color: #5fd4c4; font-weight: 500; }
nav button svg { stroke: #6b717a; }
nav button.active svg { stroke: #5fd4c4; }
/* --- Pairing --- */
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
#view-pair h1 { font-size: 24px; font-weight: 500; }
#view-pair p { font-size: 14px; color: #9aa0a8; font-weight: 300; max-width: 280px; line-height: 1.5; }
#code-input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 12px; height: 56px;
width: 180px; text-align: center; font-size: 26px; letter-spacing: .3em; outline: none; }
#code-input:focus { border-color: #5fd4c4; }
#pair-btn { width: 180px; }
#pair-error { color: #e5484d; font-size: 13px; min-height: 18px; }
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%) translateY(20px);
background: #1a1d22; border: 1px solid #23262b; color: #e6e8ea; font-size: 13px;
padding: 12px 20px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: all .25s; white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<header>
<div class="wordmark">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#5fd4c4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="13" rx="2"/><path d="M8.5 22h7"/></svg>
NodeCast
</div>
<div class="tvchip"><span class="dot off" id="conn-dot"></span><span id="tv-name">Getrennt</span></div>
</header>
<main>
<!-- Pairing -->
<section class="view" id="view-pair">
<h1>Mit dem TV koppeln</h1>
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
<button class="btn-accent" id="pair-btn">Verbinden</button>
<div id="pair-error"></div>
</section>
<!-- Steuerung -->
<section class="view" id="view-remote">
<div class="nowcard" id="nowcard">
<div class="glow"></div>
<button class="stop" id="btn-stop" aria-label="Stopp">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#e6e8ea"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<div class="info">
<div class="liverow" id="liverow" style="visibility:hidden"><span class="livedot"></span><span id="live-label">LIVE</span></div>
<div id="now-title">Nichts läuft</div>
<div id="now-sub">Wähle einen Sender oder starte eine URL</div>
</div>
</div>
<div class="transport">
<button class="btn-side" id="btn-prev" aria-label="Vorheriger Sender">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20L9 12l10-8v16z"/><path d="M5 19V5"/></svg>
</button>
<button class="btn-main" id="btn-play" aria-label="Wiedergabe/Pause">
<svg id="ic-play" width="30" height="30" viewBox="0 0 24 24" fill="#07110f" style="display:none"><path d="M8 5v14l11-7L8 5z"/></svg>
<svg id="ic-pause" width="30" height="30" viewBox="0 0 24 24" fill="#07110f"><path d="M7 4.5h3.4v15H7zM13.6 4.5H17v15h-3.4z"/></svg>
</button>
<button class="btn-side" id="btn-next" aria-label="Nächster Sender">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l10 8-10 8V4z"/><path d="M19 5v14"/></svg>
</button>
</div>
<div class="volrow">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/></svg>
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg>
</div>
</section>
<!-- Sender -->
<section class="view" id="view-channels">
<div class="chead">
<h1>Sender</h1>
<button class="iconbtn" id="btn-setup" aria-label="Playlist einrichten">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#9aa0a8" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h.09A1.65 1.65 0 0010 3.09V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h.09a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v.09a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</button>
</div>
<div id="setup">
<label for="playlist-input">M3U-Playlist-URL</label>
<input id="playlist-input" type="url" placeholder="https://…/playlist.m3u" autocapitalize="off" autocorrect="off">
<button class="btn-accent" id="btn-load-playlist">Playlist laden</button>
<label for="url-input" style="margin-top:6px">Oder direkte Stream-URL</label>
<input id="url-input" type="url" placeholder="https://…/stream.m3u8" autocapitalize="off" autocorrect="off">
<button class="btn-accent" id="btn-play-url">Auf TV abspielen</button>
</div>
<div class="searchrow">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
<input id="search" type="search" placeholder="Sender suchen" autocapitalize="off">
</div>
<div id="channel-list"></div>
</section>
</main>
<nav id="tabs" style="display:none">
<button data-view="view-remote" class="active">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18.5h2"/></svg>
Steuerung
</button>
<button data-view="view-channels">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg>
Sender
</button>
</nav>
<div id="toast"></div>
<script>
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const state = {
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '',
retryDelay: 1000, volumeDragging: false, searchTerm: '',
};
// --- pairing credentials ---
const params = new URLSearchParams(location.search);
if (params.get('t')) {
localStorage.setItem('nodecast_token', params.get('t'));
history.replaceState(null, '', location.pathname);
}
const getToken = () => localStorage.getItem('nodecast_token') || '';
let manualCode = '';
const deviceName = (() => {
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return 'iPhone';
if (/iPad/.test(ua)) return 'iPad';
if (/Android/.test(ua)) return 'Android-Handy';
return 'Browser';
})();
// --- views ---
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === id));
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('active', b.dataset.view === id));
$('tabs').style.display = id === 'view-pair' ? 'none' : 'flex';
}
document.querySelectorAll('nav button').forEach(b =>
b.addEventListener('click', () => showView(b.dataset.view)));
// --- websocket ---
function connect() {
if (state.ws && (state.ws.readyState === 0 || state.ws.readyState === 1)) return;
const ws = new WebSocket('ws://' + location.host + '/');
state.ws = ws;
ws.onopen = () => {
state.connected = true;
const hello = { type: 'hello', name: deviceName };
if (getToken()) hello.token = getToken();
else if (manualCode) hello.code = manualCode;
else { showView('view-pair'); ws.close(); return; }
ws.send(JSON.stringify(hello));
};
ws.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
handle(msg);
};
ws.onclose = () => {
state.connected = false; state.authorized = false;
renderConnection();
setTimeout(connect, state.retryDelay);
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
};
ws.onerror = () => ws.close();
}
function send(obj) {
if (state.ws && state.ws.readyState === 1 && state.authorized) {
state.ws.send(JSON.stringify(obj));
}
}
function handle(msg) {
switch (msg.type) {
case 'welcome':
state.authorized = true;
state.retryDelay = 1000;
if (msg.token) localStorage.setItem('nodecast_token', msg.token);
$('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; }
state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = '';
showView('view-remote');
renderAll();
break;
case 'status':
state.status = msg;
renderStatus();
renderChannels();
break;
case 'channels':
state.channels = msg.channels || [];
state.playlistUrl = msg.playlistUrl || '';
renderChannels();
break;
case 'toast':
toast(msg.message);
break;
case 'error':
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
localStorage.removeItem('nodecast_token');
manualCode = '';
$('pair-error').textContent = msg.error === 'rate_limited'
? 'Zu viele Versuche — kurz warten.' : 'Falscher Code.';
showView('view-pair');
}
break;
}
}
// --- rendering ---
function renderConnection() {
$('conn-dot').classList.toggle('off', !state.authorized);
if (!state.authorized) $('tv-name').textContent = 'Getrennt';
}
function renderStatus() {
renderConnection();
const s = state.status;
const playing = s.state === 'playing' || s.state === 'buffering';
const hasMedia = s.state !== 'idle' && s.channel;
$('nowcard').classList.toggle('playing', !!hasMedia);
$('now-title').textContent = hasMedia ? s.channel : 'Nichts läuft';
$('now-sub').textContent = hasMedia
? (s.state === 'buffering' ? 'Lädt…' : s.state === 'paused' ? 'Pausiert'
: s.state === 'error' ? 'Wiedergabefehler' : (s.group || 'Wird abgespielt'))
: 'Wähle einen Sender oder starte eine URL';
$('liverow').style.visibility = s.live ? 'visible' : 'hidden';
$('ic-play').style.display = playing ? 'none' : 'block';
$('ic-pause').style.display = playing ? 'block' : 'none';
$('btn-play').disabled = !hasMedia;
if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100);
}
}
function renderChannels() {
const list = $('channel-list');
const term = state.searchTerm.toLowerCase();
const filtered = term
? state.channels.filter(c => (c.name + ' ' + c.group).toLowerCase().includes(term))
: state.channels;
if (!state.channels.length) {
list.innerHTML = '<div class="empty">Noch keine Sender.<br>Lade oben über das Zahnrad eine M3U-Playlist.</div>';
$('setup').classList.add('open');
return;
}
const frag = document.createDocumentFragment();
filtered.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c);
const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
const num = document.createElement('span');
num.className = 'num';
if (c.logo) {
const img = document.createElement('img');
img.src = c.logo; img.loading = 'lazy'; img.alt = '';
img.onerror = () => { img.remove(); num.textContent = idx + 1; };
num.appendChild(img);
} else {
num.textContent = idx + 1;
}
const meta = document.createElement('span');
meta.className = 'meta';
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
if (c.group) {
const grp = document.createElement('span');
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
btn.appendChild(num); btn.appendChild(meta);
btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn);
});
list.innerHTML = '';
list.appendChild(frag);
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl;
}
function renderAll() { renderStatus(); renderChannels(); }
// --- actions ---
function playChannel(c) {
send({ type: 'play', url: c.url, name: c.name, group: c.group });
showView('view-remote');
toast(c.name);
}
function step(dir) {
if (!state.channels.length) return;
const cur = state.channels.findIndex(c => c.name === state.status.channel);
const next = cur < 0 ? 0 : (cur + dir + state.channels.length) % state.channels.length;
playChannel(state.channels[next]);
}
$('btn-play').addEventListener('click', () => send({ type: 'toggle' }));
$('btn-stop').addEventListener('click', () => send({ type: 'stop' }));
$('btn-prev').addEventListener('click', () => step(-1));
$('btn-next').addEventListener('click', () => step(1));
let volTimer = null;
const vol = $('volume');
vol.addEventListener('input', () => {
state.volumeDragging = true;
clearTimeout(volTimer);
volTimer = setTimeout(() => {
send({ type: 'volume', value: vol.value / 100 });
state.volumeDragging = false;
}, 120);
});
$('btn-setup').addEventListener('click', () => $('setup').classList.toggle('open'));
$('btn-load-playlist').addEventListener('click', () => {
const url = $('playlist-input').value.trim();
if (!url) return;
send({ type: 'set_playlist', url });
toast('Playlist wird geladen…');
});
$('btn-play-url').addEventListener('click', () => {
const url = $('url-input').value.trim();
if (!url) return;
send({ type: 'play', url, name: url.split('/').pop() || url, group: '' });
showView('view-remote');
});
$('search').addEventListener('input', (e) => {
state.searchTerm = e.target.value;
renderChannels();
});
$('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
manualCode = code;
$('pair-error').textContent = '';
if (state.ws) state.ws.close(); else connect();
});
let toastTimer = null;
function toast(text) {
const t = $('toast');
t.textContent = text;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
state.retryDelay = 1000;
connect();
send({ type: 'get_state' });
}
});
// --- boot ---
if (getToken()) { connect(); showView('view-remote'); }
else { showView('view-pair'); }
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,281 @@
package com.nodecast.tv
import android.app.Activity
import android.graphics.Color
import android.media.AudioManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.media3.ui.PlayerView
import com.nodecast.tv.pairing.Pairing
import com.nodecast.tv.pairing.Qr
import com.nodecast.tv.player.PlayerController
import com.nodecast.tv.playlist.Channel
import com.nodecast.tv.playlist.PlaylistRepository
import com.nodecast.tv.server.ControlServer
import org.json.JSONObject
class MainActivity : Activity(), ControlServer.Listener {
private lateinit var playerController: PlayerController
private lateinit var server: ControlServer
private lateinit var playlist: PlaylistRepository
private lateinit var audioManager: AudioManager
private lateinit var pairingScreen: View
private lateinit var playerScreen: View
private lateinit var playerView: PlayerView
private lateinit var overlay: View
private lateinit var overlayChannel: TextView
private lateinit var overlayState: TextView
private lateinit var overlayLiveDot: View
private lateinit var overlayLiveLabel: TextView
private lateinit var overlayProgress: View
private lateinit var deviceChip: View
private lateinit var deviceName: TextView
private lateinit var pairingStatus: TextView
private val mainHandler = Handler(Looper.getMainLooper())
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
private val ticker = object : Runnable {
override fun run() {
updateProgress()
server.broadcastStatus()
mainHandler.postDelayed(this, TICK_INTERVAL_MS)
}
}
private var connectedName: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
playlist = PlaylistRepository(this)
playerController = PlayerController(this) { onPlaybackChanged() }
bindViews()
setupPairingScreen()
server = ControlServer(this, this)
server.startServer()
}
private fun bindViews() {
pairingScreen = findViewById(R.id.pairing_screen)
playerScreen = findViewById(R.id.player_screen)
playerView = findViewById(R.id.player_view)
overlay = findViewById(R.id.overlay)
overlayChannel = findViewById(R.id.overlay_channel)
overlayState = findViewById(R.id.overlay_state)
overlayLiveDot = findViewById(R.id.overlay_live_dot)
overlayLiveLabel = findViewById(R.id.overlay_live_label)
overlayProgress = findViewById(R.id.overlay_progress)
deviceChip = findViewById(R.id.device_chip)
deviceName = findViewById(R.id.device_name)
pairingStatus = findViewById(R.id.pairing_status)
playerView.useController = false
playerView.player = playerController.player
overlayProgress.pivotX = 0f
}
private fun setupPairingScreen() {
val address = Pairing.lanAddress()
val qrImage = findViewById<ImageView>(R.id.qr_image)
val urlText = findViewById<TextView>(R.id.pairing_url)
val codeText = findViewById<TextView>(R.id.pairing_code)
codeText.text = getString(R.string.pairing_code, Pairing.code(this))
if (address != null) {
urlText.text = Pairing.remoteUrl(address)
qrImage.setImageBitmap(
Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216"))
)
} else {
urlText.text = getString(R.string.no_network)
}
}
// --- playback state → UI + remote ---
private fun onPlaybackChanged() {
val showPlayer = playerController.hasMedia
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE
val active = playerController.state == "playing" || playerController.state == "buffering"
playerScreen.keepScreenOn = active
mainHandler.removeCallbacks(ticker)
if (showPlayer) {
overlayChannel.text = playerController.channelName
val live = playerController.player.isCurrentMediaItemLive
overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE
overlayLiveLabel.text = if (live) getString(R.string.live) else ""
overlayState.text = when (playerController.state) {
"paused" -> getString(R.string.paused)
"buffering" -> getString(R.string.buffering)
"error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty())
else -> getString(R.string.playing)
}
updateProgress()
showOverlay(autoHide = playerController.state == "playing")
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
}
onClientsChanged(-1, null)
server.broadcastStatus()
}
private fun updateProgress() {
val player = playerController.player
val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) {
1f
} else {
(player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f)
}
overlayProgress.scaleX = fraction
}
private fun showOverlay(autoHide: Boolean) {
mainHandler.removeCallbacks(hideOverlay)
overlay.animate().alpha(1f).setDuration(200).start()
if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS)
}
// --- ControlServer.Listener (called on main thread) ---
override fun onPlay(url: String, name: String, group: String) {
playerController.play(url, name, group)
}
override fun onTogglePlay() = playerController.toggle()
override fun onPause() = playerController.pause()
override fun onResume() = playerController.resume()
override fun onStopCast() = playerController.stop()
override fun onSeek(deltaSeconds: Long) {
playerController.seekBy(deltaSeconds)
showOverlay(autoHide = true)
}
override fun onVolume(value: Float) {
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt().coerceIn(0, max), 0)
server.broadcastStatus()
}
override fun onSetPlaylist(url: String) {
playlist.refresh(url) { result ->
mainHandler.post {
result
.onSuccess { server.broadcastChannels() }
.onFailure { server.broadcastToast(getString(R.string.playlist_error)) }
}
}
}
override fun onClientsChanged(count: Int, newestName: String?) {
if (newestName != null) connectedName = newestName
if (count >= 0) {
pairingStatus.text = if (count > 0) {
getString(R.string.paired_with, connectedName.orEmpty())
} else {
getString(R.string.ready_to_pair)
}
}
val chipVisible = connectedName != null && playerController.hasMedia
deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE
deviceName.text = connectedName.orEmpty()
}
override fun currentStatus(): JSONObject {
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val player = playerController.player
return JSONObject()
.put("state", playerController.state)
.put("channel", playerController.channelName)
.put("group", playerController.channelGroup)
.put("live", player.isCurrentMediaItemLive)
.put("seekable", player.isCurrentMediaItemSeekable)
.put("position", player.currentPosition.coerceAtLeast(0))
.put("duration", player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", playerController.errorMessage ?: "")
}
override fun currentChannels(): List<Channel> = playlist.channels
override fun currentPlaylistUrl(): String = playlist.playlistUrl
// --- TV remote keys (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (playerController.hasMedia) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
playerController.toggle()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
playerController.resume()
return true
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
playerController.pause()
return true
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerController.stop()
return true
}
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS)
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS)
return true
}
KeyEvent.KEYCODE_BACK -> {
playerController.stop()
return true
}
else -> Unit
}
}
return super.onKeyDown(keyCode, event)
}
// --- lifecycle ---
override fun onStop() {
super.onStop()
// TV-NP: video must not keep playing when the user leaves the app.
playerController.pause()
}
override fun onDestroy() {
super.onDestroy()
mainHandler.removeCallbacksAndMessages(null)
server.stopServer()
playerController.release()
}
private companion object {
const val QR_SIZE_PX = 512
const val OVERLAY_HIDE_DELAY_MS = 4_000L
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
}
}

View File

@@ -0,0 +1,54 @@
package com.nodecast.tv.pairing
import android.content.Context
import java.net.Inet4Address
import java.net.NetworkInterface
import java.security.SecureRandom
object Pairing {
const val PORT = 8765
/**
* Four-digit pairing code, generated once and kept stable so an already
* paired phone survives app restarts. Human fallback only — the QR code
* carries the long token below.
*/
fun code(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
prefs.getString("code", null)?.let { return it }
val code = "%04d".format(SecureRandom().nextInt(10_000))
prefs.edit().putString("code", code).apply()
return code
}
/** 128-bit random token embedded in the QR code; not brute-forceable. */
fun token(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
prefs.getString("token", null)?.let { return it }
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
val token = bytes.joinToString("") { "%02x".format(it) }
prefs.edit().putString("token", token).apply()
return token
}
/** Best-guess LAN IPv4 address of this device, or null when offline. */
fun lanAddress(): String? {
val candidates = runCatching {
NetworkInterface.getNetworkInterfaces().asSequence()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.asSequence() }
.filterIsInstance<Inet4Address>()
.filter { it.isSiteLocalAddress }
.map { it.hostAddress }
.filterNotNull()
.toList()
}.getOrDefault(emptyList())
return candidates.firstOrNull()
}
fun remoteUrl(address: String): String = "http://$address:$PORT"
fun pairingUrl(context: Context, address: String): String =
"${remoteUrl(address)}/?t=${token(context)}"
}

View File

@@ -0,0 +1,26 @@
package com.nodecast.tv.pairing
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
object Qr {
fun encode(content: String, size: Int, foreground: Int, background: Int = Color.WHITE): Bitmap {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix.get(x, y)) foreground else background
}
}
return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
}
}

View File

@@ -0,0 +1,105 @@
package com.nodecast.tv.player
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
/**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
* keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of
* the TV app quality guidelines).
*/
class PlayerController(
context: Context,
private val onChanged: () -> Unit,
) {
val player: ExoPlayer = ExoPlayer.Builder(context).build()
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
var channelName: String = ""
private set
var channelGroup: String = ""
private set
var errorMessage: String? = null
private set
init {
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onPlayerError(error: PlaybackException) {
errorMessage = error.errorCodeName
onChanged()
}
})
}
val state: String
get() = when {
errorMessage != null -> "error"
player.playbackState == Player.STATE_BUFFERING -> "buffering"
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
player.playbackState == Player.STATE_READY -> "paused"
else -> "idle"
}
val hasMedia: Boolean
get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE
fun play(url: String, name: String, group: String) {
if (url.isEmpty()) return
errorMessage = null
channelName = name.ifEmpty { url }
channelGroup = group
val item = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build())
.build()
player.setMediaItem(item)
player.prepare()
player.play()
}
fun toggle() {
if (!hasMedia) return
if (player.isPlaying) player.pause() else resume()
}
fun pause() {
if (player.isPlaying) player.pause()
}
fun resume() {
if (!hasMedia) return
if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition()
player.play()
}
fun stop() {
player.stop()
player.clearMediaItems()
channelName = ""
channelGroup = ""
errorMessage = null
onChanged()
}
fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000)
.coerceIn(0, player.duration.coerceAtLeast(0))
player.seekTo(target)
}
fun release() {
mediaSession.release()
player.release()
}
}

View File

@@ -0,0 +1,35 @@
package com.nodecast.tv.playlist
import org.json.JSONArray
import org.json.JSONObject
data class Channel(
val name: String,
val url: String,
val group: String,
val logo: String,
) {
fun toJson(): JSONObject = JSONObject()
.put("name", name)
.put("url", url)
.put("group", group)
.put("logo", logo)
companion object {
fun fromJson(obj: JSONObject): Channel = Channel(
name = obj.optString("name"),
url = obj.optString("url"),
group = obj.optString("group"),
logo = obj.optString("logo"),
)
fun listToJson(channels: List<Channel>): JSONArray {
val arr = JSONArray()
channels.forEach { arr.put(it.toJson()) }
return arr
}
fun listFromJson(arr: JSONArray): List<Channel> =
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
}
}

View File

@@ -0,0 +1,48 @@
package com.nodecast.tv.playlist
object M3uParser {
private const val MAX_CHANNELS = 5000
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
fun parse(content: String): List<Channel> {
val channels = mutableListOf<Channel>()
var name = ""
var group = ""
var logo = ""
var pendingInfo = false
for (rawLine in content.lineSequence()) {
val line = rawLine.trim()
when {
line.startsWith("#EXTINF", ignoreCase = true) -> {
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
group = attrs["group-title"].orEmpty()
logo = attrs["tvg-logo"].orEmpty()
name = line.substringAfterLast(',', "").trim()
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
pendingInfo = true
}
line.startsWith("#EXTGRP", ignoreCase = true) -> {
group = line.substringAfter(':', "").trim()
}
line.isEmpty() || line.startsWith("#") -> Unit
pendingInfo -> {
channels.add(Channel(name.ifEmpty { line }, line, group, logo))
if (channels.size >= MAX_CHANNELS) return channels
name = ""
group = ""
logo = ""
pendingInfo = false
}
else -> {
// Bare URL without #EXTINF — still a playable entry.
channels.add(Channel(line, line, "", ""))
if (channels.size >= MAX_CHANNELS) return channels
}
}
}
return channels
}
}

View File

@@ -0,0 +1,63 @@
package com.nodecast.tv.playlist
import android.content.Context
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
/**
* Fetches and caches the M3U playlist. The channel list is persisted so the
* remote sees its channels again right after an app restart.
*/
class PlaylistRepository(context: Context) {
private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE)
private val executor = Executors.newSingleThreadExecutor()
var channels: List<Channel> = loadCached()
private set
val playlistUrl: String
get() = prefs.getString(KEY_URL, "").orEmpty()
fun refresh(url: String, onResult: (Result<List<Channel>>) -> Unit) {
executor.execute {
val result = runCatching {
val content = download(url)
val parsed = M3uParser.parse(content)
require(parsed.isNotEmpty()) { "playlist is empty" }
parsed
}
result.onSuccess { parsed ->
channels = parsed
prefs.edit()
.putString(KEY_URL, url)
.putString(KEY_CACHE, Channel.listToJson(parsed).toString())
.apply()
}
onResult(result)
}
}
private fun download(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 15_000
connection.readTimeout = 30_000
connection.instanceFollowRedirects = true
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
private fun loadCached(): List<Channel> = runCatching {
Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty()))
}.getOrDefault(emptyList())
private companion object {
const val KEY_URL = "url"
const val KEY_CACHE = "channels"
}
}

View File

@@ -0,0 +1,244 @@
package com.nodecast.tv.server
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.nodecast.tv.pairing.Pairing
import com.nodecast.tv.playlist.Channel
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.json.JSONObject
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.CopyOnWriteArrayList
/**
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
* page) over HTTP and takes playback commands over a WebSocket. A client
* authorizes itself with the four-digit pairing code from the QR code.
*/
class ControlServer(
private val context: Context,
private val listener: Listener,
) : NanoWSD(Pairing.PORT) {
interface Listener {
fun onPlay(url: String, name: String, group: String)
fun onTogglePlay()
fun onPause()
fun onResume()
fun onStopCast()
fun onSeek(deltaSeconds: Long)
fun onVolume(value: Float)
fun onSetPlaylist(url: String)
fun onClientsChanged(count: Int, newestName: String?)
fun currentStatus(): JSONObject
fun currentChannels(): List<Channel>
fun currentPlaylistUrl(): String
}
private val mainHandler = Handler(Looper.getMainLooper())
private val clients = CopyOnWriteArrayList<RemoteSocket>()
private val pairingCode = Pairing.code(context)
private val pairingToken = Pairing.token(context)
private var pingTimer: Timer? = null
// Rate limit for the human-typable 4-digit code (the QR token is not
// brute-forceable and stays exempt).
private val codeAttempts = ArrayDeque<Long>()
@Synchronized
private fun codeAttemptAllowed(): Boolean {
val now = System.currentTimeMillis()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
codeAttempts.removeFirst()
}
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
codeAttempts.addLast(now)
return true
}
fun startServer() {
start(0, true)
pingTimer = Timer("ws-ping", true).also {
it.schedule(object : TimerTask() {
override fun run() = pingClients()
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
}
}
fun stopServer() {
pingTimer?.cancel()
pingTimer = null
stop()
}
// --- HTTP ---
override fun serveHttp(session: IHTTPSession): Response {
return when (session.uri) {
"/", "/index.html" -> {
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
addHeader("Cache-Control", "no-store")
}
}
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
}
}
// --- WebSocket ---
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
fun broadcast(message: JSONObject) {
val payload = message.toString()
clients.forEach { client ->
if (client.authorized) client.trySend(payload)
}
}
fun broadcastStatus() {
broadcast(listener.currentStatus().put("type", "status"))
}
fun broadcastChannels() {
broadcast(channelsMessage())
}
fun broadcastToast(message: String) {
broadcast(JSONObject().put("type", "toast").put("message", message))
}
private fun channelsMessage(): JSONObject = JSONObject()
.put("type", "channels")
.put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels()))
private fun pingClients() {
clients.forEach { client ->
try {
client.ping(PING_PAYLOAD)
} catch (e: IOException) {
Log.d(TAG, "ping failed, dropping client", e)
clients.remove(client)
}
}
notifyClientsChanged(null)
}
private fun notifyClientsChanged(newestName: String?) {
val count = clients.count { it.authorized }
mainHandler.post { listener.onClientsChanged(count, newestName) }
}
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
@Volatile
var authorized = false
private set
private var deviceName: String = ""
fun trySend(payload: String) {
try {
send(payload)
} catch (e: IOException) {
Log.d(TAG, "send failed, dropping client", e)
clients.remove(this)
}
}
override fun onOpen() {
clients.add(this)
}
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
clients.remove(this)
notifyClientsChanged(null)
}
override fun onMessage(message: WebSocketFrame) {
val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
val type = msg.optString("type")
if (!authorized) {
if (type == "hello") handleHello(msg)
return
}
when (type) {
"play" -> post {
listener.onPlay(
msg.optString("url"),
msg.optString("name"),
msg.optString("group"),
)
}
"toggle" -> post { listener.onTogglePlay() }
"pause" -> post { listener.onPause() }
"resume" -> post { listener.onResume() }
"stop" -> post { listener.onStopCast() }
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"get_state" -> {
trySend(listener.currentStatus().put("type", "status").toString())
trySend(channelsMessage().toString())
}
else -> Unit
}
}
override fun onPong(pong: WebSocketFrame?) = Unit
override fun onException(exception: IOException?) {
clients.remove(this)
}
private fun handleHello(msg: JSONObject) {
val token = msg.optString("token")
val tokenOk = token.isNotEmpty() && token == pairingToken
val codeOk = !tokenOk && msg.optString("code").let { code ->
code.isNotEmpty() && when {
!codeAttemptAllowed() -> {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return
}
else -> code == pairingCode
}
}
if (!tokenOk && !codeOk) {
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return
}
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
trySend(
JSONObject()
.put("type", "welcome")
.put("device", android.os.Build.MODEL)
.put("token", pairingToken)
.put("status", listener.currentStatus())
.put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels()))
.toString()
)
notifyClientsChanged(deviceName)
}
private fun post(action: () -> Unit) {
mainHandler.post(action)
}
}
private companion object {
const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
const val CODE_ATTEMPT_MAX = 5
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/chip_bg" />
<stroke android:width="1dp" android:color="@color/chip_stroke" />
<corners android:radius="100dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/card" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/accent" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/live" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
<path
android:pathData="M8.5,21.5 L15.5,21.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
</vector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.8"
android:scaleY="1.8"
android:pivotX="54"
android:pivotY="54">
<path
android:pathData="M42,48.5 A1.5,1.5 0 0 1 43.5,47 L64.5,47 A1.5,1.5 0 0 1 66,48.5 L66,59.5 A1.5,1.5 0 0 1 64.5,61 L43.5,61 A1.5,1.5 0 0 1 42,59.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
<path
android:pathData="M50,64.5 L58,64.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/accent" />
<corners android:radius="1dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="90"
android:startColor="#EB05060A"
android:endColor="#0005060A" />
</shape>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg" />
</shape>
</item>
<item android:gravity="center" android:width="80dp" android:height="80dp">
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
<path
android:pathData="M8.5,21.5 L15.5,21.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
</vector>
</item>
</layer-list>

Binary file not shown.

View File

@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg">
<!-- Pairing screen -->
<FrameLayout
android:id="@+id/pairing_screen"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_cast" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/app_name"
android:textAllCaps="true"
android:textColor="@color/fg"
android:textSize="16sp"
android:letterSpacing="0.22" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:padding="16dp"
android:background="@drawable/bg_qr_card">
<ImageView
android:id="@+id/qr_image"
android:layout_width="170dp"
android:layout_height="170dp"
android:importantForAccessibility="no" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="@string/pair_title"
android:textColor="@color/fg"
android:textSize="26sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxWidth="440dp"
android:gravity="center"
android:text="@string/pair_subtitle"
android:textColor="@color/muted"
android:textSize="14sp"
android:lineSpacingMultiplier="1.3" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="36dp"
android:paddingEnd="36dp"
android:paddingBottom="26dp">
<View
android:layout_width="7dp"
android:layout_height="7dp"
android:background="@drawable/dot_accent" />
<TextView
android:id="@+id/pairing_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/ready_to_pair"
android:textColor="@color/faint"
android:textSize="12sp" />
<TextView
android:id="@+id/pairing_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="@color/faint"
android:textSize="12sp" />
<View
android:layout_width="1dp"
android:layout_height="12dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:background="@color/line" />
<TextView
android:id="@+id/pairing_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
<!-- Player screen -->
<FrameLayout
android:id="@+id/player_screen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:background="@color/bg_deep">
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_gravity="bottom"
android:background="@drawable/scrim_bottom" />
<LinearLayout
android:id="@+id/device_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginTop="22dp"
android:layout_marginEnd="30dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:background="@drawable/bg_chip"
android:visibility="gone">
<TextView
android:id="@+id/device_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="11sp" />
<View
android:layout_width="5dp"
android:layout_height="5dp"
android:layout_marginStart="8dp"
android:background="@drawable/dot_accent" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical"
android:paddingStart="36dp"
android:paddingEnd="36dp"
android:paddingBottom="30dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<View
android:id="@+id/overlay_live_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:background="@drawable/dot_live" />
<TextView
android:id="@+id/overlay_live_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:textColor="@color/muted"
android:textSize="11sp"
android:letterSpacing="0.18" />
<TextView
android:id="@+id/overlay_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/overlay_channel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/fg"
android:textSize="29sp" />
<View
android:id="@+id/overlay_progress"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="16dp"
android:background="@drawable/progress_line" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pair_title">Mit dem Handy koppeln</string>
<string name="pair_subtitle">Richte die Kamera auf den Code — die Fernbedienung öffnet sich direkt im Browser.</string>
<string name="ready_to_pair">Bereit zum Koppeln</string>
<string name="paired_with">Gekoppelt mit %1$s</string>
<string name="pairing_code">Code %1$s</string>
<string name="no_network">Keine Netzwerkverbindung</string>
<string name="playing">Wird abgespielt</string>
<string name="paused">Pausiert</string>
<string name="buffering">Lädt…</string>
<string name="playback_error">Wiedergabefehler (%1$s)</string>
<string name="playlist_error">Playlist konnte nicht geladen werden</string>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#0A0B0D</color>
<color name="bg_deep">#05060A</color>
<color name="fg">#F2F3F5</color>
<color name="muted">#9AA0A8</color>
<color name="faint">#6B717A</color>
<color name="accent">#5FD4C4</color>
<color name="card">#FBFCFD</color>
<color name="live">#E5484D</color>
<color name="line">#23262B</color>
<color name="chip_stroke">#14FFFFFF</color>
<color name="chip_bg">#8C0A0B0D</color>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NodeCast</string>
<string name="pair_title">Pair with your phone</string>
<string name="pair_subtitle">Point your camera at the code — the remote opens right in your browser.</string>
<string name="ready_to_pair">Ready to pair</string>
<string name="paired_with">Paired with %1$s</string>
<string name="pairing_code">Code %1$s</string>
<string name="no_network">No network connection</string>
<string name="live">LIVE</string>
<string name="playing">Playing</string>
<string name="paused">Paused</string>
<string name="buffering">Buffering…</string>
<string name="playback_error">Playback error (%1$s)</string>
<string name="playlist_error">Could not load playlist</string>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NodeCast" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:colorBackground">@color/bg</item>
<item name="android:textColorPrimary">@color/fg</item>
<item name="android:textColorSecondary">@color/muted</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:fontFamily">@font/space_grotesk</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
</resources>

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

View File

@@ -0,0 +1,14 @@
---
status: superseded by ADR-0003
---
# NodeCast is a companion to the nodecast-tv server, not standalone
The first cut of the native TV app parsed M3U playlists itself and needed no
backend. We decided to make it a thin companion instead: the TV app is the only
client of a nodecast-tv server, which owns sources, channels, EPG, favorites
and users. Rationale: those features already exist server-side and would
otherwise be rebuilt in the app; the household already runs the server.
Consequence: the app is not usable without a reachable nodecast-tv instance.
The app-side M3U parser is deliberately kept as a generic-source fallback so
M3U/XMLTV servers like ErsatzTV can be added later without rearchitecting.

View File

@@ -0,0 +1,12 @@
---
status: superseded by ADR-0003
---
# Long-lived device tokens via nodecast-tv fork
nodecast-tv issues JWTs with a 24h expiry, which would force the TV either to
store the user's password in plaintext or to re-prompt daily. We decided to
extend our nodecast-tv fork with non-expiring per-device tokens (API keys
scoped to a user, revocable server-side). The TV logs in once with credentials,
exchanges them for a device token, and never stores the password.
Consequence: the app requires the forked server until the feature is upstreamed.

View File

@@ -0,0 +1,16 @@
---
status: accepted
supersedes: ADR-0001, ADR-0002
---
# Dispatcharr as backend, app speaks Xtream API + XMLTV
ADR-0001 made the app a companion to nodecast-tv; grilling the ecosystem
(Dispatcharr, Threadfin, Tunarr, ErsatzTV) showed they all emit standard
outputs, while nodecast-tv is the only backend with a proprietary API. We
decided to drop nodecast-tv and use Dispatcharr as the backend: it owns
sources, aggregation, failover, EPG and stream profiles. The TV app talks the
Xtream API (channels, categories, EPG as JSON) plus XMLTV, with a generic
M3U/XMLTV fallback so Threadfin/Tunarr/ErsatzTV work too. The nodecast-tv
fork plans (device tokens, ADR-0002) are void; the app-side M3U parser stays
as the fallback path.

View File

@@ -0,0 +1,13 @@
---
status: accepted
---
# Fork Dispatcharr to add native OIDC (Authentik) for the web UI
Dispatcharr has no SSO (open upstream request: issue #806). A reverse-proxy
forward-auth setup would work without code changes, but we decided to fork
Dispatcharr (be-nj) and build real OIDC login (Authentik as IdP, auto user
creation, group mapping) into its Django backend and React UI, offered
upstream as a PR. Rationale: proxy-auth needs bypass rules for every client
endpoint (Xtream/M3U/HDHomeRun) and gives no in-app user identity. The TV app
is unaffected either way — it authenticates with Xtream credentials.

View File

@@ -0,0 +1,20 @@
---
status: accepted
---
# Multi-user app with full TV UI; login via OIDC Device Flow
The app's audience is every user of the household's Dispatcharr backend, each
on their own Google TV — not a single phone-controlled screen. Two consequences:
1. **Full TV UI** (Compose for TV): channel list, zapping and favorites are
operable with the D-pad alone. The QR-paired phone Remote stays as an
optional convenience, no longer the primary control.
2. **Login via OIDC Device Flow** against Authentik: the TV shows a QR/code,
the user confirms on their phone; the app then calls the Dispatcharr fork's
API with Bearer tokens. No Xtream credentials for the primary backend.
The generic M3U/XMLTV source type (ADR-0003) stays as a login-less fallback
for Threadfin/Tunarr/ErsatzTV. The fork scope now bundles: OIDC UI login,
Device Flow + Bearer API, per-user favorites, per-request stream profile
parameter.

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NodeCast"
include(":app")