9 Commits

Author SHA1 Message Date
be-nj
c0adce2b09 Resolve the full review backlog (issues #1-#13)
Some checks failed
Build TV app / build (push) Failing after 2s
Control server security and robustness:
- Socket read timeout, handshake deadline and a client cap so an idle or
  hostile connection can no longer pin threads forever (#1)
- Per-address rate limiting that counts every failed hello, Origin
  checking on the upgrade, and a separate revocable session token for
  code-authenticated clients so the guessable path no longer yields the
  QR credential (#2)
- Credentials excluded from cloud backup and device transfer, constant
  time comparisons, and a pairing reset in the settings (#3)
- Playlist fetches restricted to http(s), capped at 24 MB and bounded by
  an overall transfer deadline (#4)
- Port conflicts and MediaSession id collisions no longer crash the app;
  the remote degrades to unavailable with a plain-language note (#11)

Player:
- Seeking no longer collapses to position 0 when the duration is unknown
  (#5)
- Pause acts on playWhenReady, so pausing during a stall works and
  playback cannot resume in the background after leaving the app (#6)
- Playback failures stay on screen with a retry action instead of
  silently dropping back to the list (#7)
- Reconnects are spaced 1s/3s/8s and re-entering the channel just closed
  waits out a short grace period, which is what the provider needs to
  release the previous session (#12)

Channel list and remote:
- Leaving playback returns to the channel the viewer came from (#13)
- The remote only rebuilds its list when the data changed, never
  overwrites a focused input and carries indices instead of scanning (#8)
- Pairing retry reconnects properly, resets its backoff and validates the
  code before spending an attempt (#9)
- M3U parsing keeps commas in names, strips a BOM and rejects payloads
  that are not playlists, covered by unit tests under tests/ (#10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:14:48 +02:00
be-nj
7abd4b2a48 README: generic group names in screenshots, consistent captions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:55:15 +02:00
be-nj
f69ae2f437 README: screenshots as 2x2 grid
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:43:14 +02:00
be-nj
01a76bffcd README: welcome screen screenshot (QR and LAN address pixelated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:49 +02:00
be-nj
13352994e4 README: all screenshots full width
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:05 +02:00
be-nj
6acbe77200 README: overlay screenshot (scene pick)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:41:16 +02:00
be-nj
7da6f5c69f README: brighter player screenshot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:36:11 +02:00
be-nj
e66c8a863b Overlay readability on bright video: brighter times, translucent progress track
Some checks failed
Build TV app / build (push) Failing after 2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:32:34 +02:00
be-nj
ca401e8ca3 README for humans: German, screenshots, install guide
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:30:29 +02:00
22 changed files with 659 additions and 122 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ local.properties
.idea/ .idea/
*.iml *.iml
.kotlin/ .kotlin/
tests/runs/

109
README.md
View File

@@ -1,49 +1,82 @@
# Castarr # Castarr
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr) **Live-TV auf dem Fernseher, eingerichtet in einer Minute — alles läuft übers Handy.**
backend: live IPTV with a full D-pad UI (Compose for TV), login via the OIDC
device flow (Authentik), per-user favorites, per-stream output profiles, and
an optional phone remote paired by QR code — served by the TV itself, no phone
app install.
## Features Castarr ist eine App für Google TV / Android TV. Auf dem Fernseher muss nie
etwas getippt werden: Zum Einrichten scannt man einmal einen QR-Code mit dem
Handy, meldet sich dort an — fertig. Danach zappt man mit der normalen
TV-Fernbedienung oder steuert alles bequem vom Handy aus.
- **Live TV** channel list with Now/Next EPG and progress, zapping via | Senderliste | Player |
channel/D-pad keys, fullscreen playback (ExoPlayer, HLS + MPEG-TS) with a | --- | --- |
minimal auto-hiding overlay | ![Senderliste](docs/screenshots/senderliste.png) | ![Player](docs/screenshots/player.png) |
- **Login without typing**: the TV shows a QR code and a user code; confirm on
your phone at your identity provider (OIDC device authorization grant). The
only thing entered on the TV is the Dispatcharr URL.
- **Dispatcharr source**: channels, groups and EPG via the fork's
Bearer-authenticated API; favorites are stored per user on the backend
(long-press a channel to star it, filter chip in the list)
- **Stream profiles**: pick Standard / Passthrough / audiofix / 720p (list
comes from the backend) — applied per stream request
- **Generic source fallback**: any M3U (+ optional XMLTV) URL works without a
login — ErsatzTV, Threadfin, Tunarr, plain playlists
- **Phone remote** (optional): scan the QR under Einstellungen; play/pause,
zapping, volume, channel list with Now/Next and favorite toggling in the
browser — no app install, rate-limited pairing code as fallback
- Physical remote keys and Google Assistant keep working (Media3
MediaSession, TV app quality guidelines TV-PC/TV-PP/TV-VC)
## Building | Nummerntasten | Einrichtung |
| --- | --- |
| ![Nummerntasten](docs/screenshots/nummerntasten.png) | ![Willkommens-Bildschirm](docs/screenshots/willkommen.png) |
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is ## Was die App kann
resolved automatically (foojay) if the host only has a JRE.
- **Senderliste mit Logos und Programm** — was gerade läuft, wie lange noch,
was danach kommt. Gruppen und Favoriten links, Sender rechts.
- **Zappen wie früher** — Nummerntasten auf der Fernbedienung schalten direkt
um (mit großer Anzeige, welcher Sender gleich kommt), hoch/runter blättert
durch die Sender.
- **Aufgeräumter Player** — OK-Taste zeigt Sendung, Zeiten und Fortschritt,
dazu Pause/Stopp-Buttons und Tonspur-Wahl, wenn der Sender mehrere hat.
Blendet sich von selbst wieder aus.
- **Handy als Fernbedienung** — QR-Code in den Einstellungen scannen, schon
hat man Senderliste, Suche, Lautstärke und Favoriten im Handy-Browser.
Keine App-Installation nötig.
- **Favoriten** — OK auf einem Sender gedrückt halten, Stern gesetzt. Die
Favoriten gelten pro Person und sind auf jedem Gerät dieselben.
- **Updates aus der App heraus** — unter Einstellungen → App genügt ein
Klick, wenn eine neue Version bereitsteht.
## Installation auf dem Fernseher
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
Weg nutzen, eine APK-Datei zu öffnen).
2. Diese Adresse eingeben:
`git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk`
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
wenn der Fernseher fragt).
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
QR-Code scannen, auf dem Handy Server angeben und anmelden — den Rest
macht der Fernseher.
Danach hält sich die App über den eingebauten Updater selbst aktuell.
---
## Für Technikinteressierte
Native Kotlin-App (Jetpack Compose for TV, Media3/ExoPlayer für HLS und
MPEG-TS). Als Backend dient ein [Dispatcharr](https://github.com/be-nj/Dispatcharr)-Fork
mit OIDC-Unterstützung: Anmeldung per Device Authorization Grant (der
Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
Zahlencode als Fallback). Physische Mediatasten und Google Assistant
funktionieren über eine Media3 MediaSession.
### Selbst bauen
JDK 17+ und Android SDK (Platform 35) erforderlich; eine passende
JDK-Toolchain wird automatisch aufgelöst (foojay).
``` ```
./gradlew assembleDebug # debug APK ./gradlew assembleDebug # Debug-APK
./gradlew assembleRelease # signed when CASTARR_STORE_* env vars are set ./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
``` ```
APK output: `app/build/outputs/apk/<variant>/`. Releases on the APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
[releases page](https://git.beckm4nn.net/benjamin/castarr/releases) are signed; install sind signiert; über einem Debug-Build muss einmal deinstalliert werden
over an existing debug build requires uninstalling first (signature change). (Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
## Backend Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
The full feature set needs the Dispatcharr fork with OIDC support
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app
the issuer and public device-flow client id. Configuration lives in
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 21 versionCode = 23
versionName = "0.8.5" versionName = "0.9.0"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -51,6 +51,20 @@ android {
} }
// Global layout rule: everything test-related lives under tests/.
sourceSets {
getByName("test") {
java.setSrcDirs(listOf("../tests/unit"))
}
}
testOptions {
unitTests.all {
it.reports.junitXml.outputLocation.set(file("../tests/runs/junit"))
it.reports.html.outputLocation.set(file("../tests/runs/junit-html"))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
@@ -82,6 +96,7 @@ dependencies {
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3") implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.coil-kt:coil-compose:2.7.0")
testImplementation("junit:junit:4.13.2")
} }
// Full JDK for javac via toolchain (host may only have a JRE); resolved by // Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -12,6 +12,8 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -320,6 +320,7 @@
ws.onclose = () => { ws.onclose = () => {
state.connected = false; state.authorized = false; state.connected = false; state.authorized = false;
renderConnection(); renderConnection();
state.ws = null;
setTimeout(connect, state.retryDelay); setTimeout(connect, state.retryDelay);
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000); state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
}; };
@@ -340,7 +341,7 @@
if (msg.token) localStorage.setItem('castarr_token', msg.token); if (msg.token) localStorage.setItem('castarr_token', msg.token);
$('tv-name').textContent = msg.device || 'TV'; $('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; } if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; } if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); }
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = ''; $('pair-error').textContent = '';
@@ -358,10 +359,10 @@
case 'status': case 'status':
state.status = msg; state.status = msg;
renderStatus(); renderStatus();
renderChannels(); renderChannelsIfChanged();
break; break;
case 'channels': case 'channels':
state.channels = msg.channels || []; state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; });
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
renderChannels(); renderChannels();
@@ -446,6 +447,7 @@
$('setup').classList.add('open'); $('setup').classList.add('open');
return; return;
} }
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []); const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly; const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
@@ -462,9 +464,18 @@
}); });
list.appendChild(bar); list.appendChild(bar);
} }
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = favOn
? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
}
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
shown.slice(0, 500).forEach((c) => { shown.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c); const idx = c._idx;
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : ''); btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
const num = document.createElement('span'); const num = document.createElement('span');
@@ -517,12 +528,27 @@
btn.addEventListener('click', () => playChannel(c)); btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn); frag.appendChild(btn);
}); });
list.innerHTML = '';
list.appendChild(frag); list.appendChild(frag);
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl; // Never overwrite an input the user is typing in.
const pl = $('playlist-input');
if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl;
} }
function renderAll() { renderStatus(); renderChannels(); } // Rebuilding the list on every 2s status push swallowed taps that landed
// between touchstart and touchend. Only rebuild when something changed.
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly,
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');
if (sig === listSignature) return;
listSignature = sig;
renderChannels();
}
function renderAll() { renderStatus(); listSignature = ''; renderChannels(); }
// --- actions --- // --- actions ---
function playChannel(c) { function playChannel(c) {
@@ -583,10 +609,17 @@
$('pair-btn').addEventListener('click', () => { $('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim(); const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; } if (!/^[0-9]{4}$/.test(code)) {
$('pair-error').textContent = 'Bitte 4 Ziffern eingeben.';
return;
}
// A fresh attempt must not inherit the backoff from earlier failures.
state.retryDelay = 1000;
$('pair-error').textContent = 'Verbinde…';
manualCode = code; manualCode = code;
$('pair-error').textContent = ''; $('pair-error').textContent = '';
if (state.ws) state.ws.close(); else connect(); // A closed socket ignores close(), so the retry has to reconnect.
if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect();
}); });
let toastTimer = null; let toastTimer = null;

View File

@@ -45,6 +45,16 @@ class AppState(
/** Digits typed on the remote's number pad (channel switching). */ /** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("") var digitBuffer by mutableStateOf("")
/** False when the control server could not bind its port. */
var remoteAvailable by mutableStateOf(true)
/** Channel to restore focus to when the list comes back (#13). */
var lastWatched by mutableStateOf<Channel?>(null)
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var lastStoppedUrl: String = ""
private var lastStoppedAt: Long = 0L
/** Bumped on every interaction with the visible overlay to restart the /** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */ * auto-hide timer. */
var overlayPing by mutableLongStateOf(0L) var overlayPing by mutableLongStateOf(0L)
@@ -139,6 +149,11 @@ class AppState(
) )
} }
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
}
fun isOnline(): Boolean { fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
@@ -154,17 +169,40 @@ class AppState(
} }
fun play(channel: Channel) { fun play(channel: Channel) {
lastWatched = channel
currentChannel = channel currentChannel = channel
playerVisible = true playerVisible = true
// Re-opening the very channel that was just closed can hit the
// provider before it released the previous session, which comes back
// as its "Stream Offline" still image. Give it a moment.
val sinceStop = System.currentTimeMillis() - lastStoppedAt
val sameChannel = channel.url == lastStoppedUrl
if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
val wait = REENTRY_GRACE_MS - sinceStop
playerState = "reconnecting"
reentryHandler.removeCallbacksAndMessages(null)
reentryHandler.postDelayed({
player.play(channel.url, channel.name, channel.group)
}, wait)
return
}
player.play(channel.url, channel.name, channel.group) player.play(channel.url, channel.name, channel.group)
} }
fun stopPlayback() { fun stopPlayback() {
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
lastStoppedAt = System.currentTimeMillis()
player.stop() player.stop()
playerVisible = false playerVisible = false
currentChannel = null currentChannel = null
} }
/** Retry the failed channel without leaving the player. */
fun retryPlayback() {
player.retryNow()
}
fun zap(direction: Int) { fun zap(direction: Int) {
val list = activeChannels() val list = activeChannels()
if (list.isEmpty()) return if (list.isEmpty()) return
@@ -187,10 +225,15 @@ class AppState(
overlayVisible = true overlayVisible = true
} }
lastNowTitle = nowTitle lastNowTitle = nowTitle
if (!player.hasMedia && playerVisible) { // Keep the player on screen while an error or reconnect is pending —
// otherwise the failure silently drops the viewer back to the list.
if (!player.hasMedia && playerVisible &&
player.errorMessage == null && !player.reconnecting
) {
playerVisible = false playerVisible = false
currentChannel = null currentChannel = null
} }
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
} }
/** Steps to the next audio track (D-pad friendly: one key, cycles). */ /** Steps to the next audio track (D-pad friendly: one key, cycles). */

View File

@@ -48,6 +48,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
) )
server = ControlServer(this, this) server = ControlServer(this, this)
server.startServer() server.startServer()
state.remoteAvailable = server.running
setContent { CastarrApp(state) } setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) } lifecycleScope.launch { UpdateChecker.check(state) }
} }
@@ -115,6 +116,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("duration", player.player.duration.coerceAtLeast(0)) .put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0) .put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "") .put("error", player.errorMessage ?: "")
.put("reconnecting", player.reconnecting)
.put("audioTracks", org.json.JSONArray().also { arr -> .put("audioTracks", org.json.JSONArray().also { arr ->
state.audioTracks.forEach { arr.put(it.label) } state.audioTracks.forEach { arr.put(it.label) }
}) })

View File

@@ -96,17 +96,44 @@ class SourceRepository(context: Context) {
} }
} }
private fun open(url: String): HttpURLConnection = /**
(URL(url).openConnection() as HttpURLConnection).apply { * Only plain HTTP(S) is fetched, and redirects are followed manually so
* every hop can be checked again — a paired phone must not be able to
* point the TV at arbitrary internal services (SSRF).
*/
private fun open(url: String): HttpURLConnection {
val parsed = URL(url)
require(parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) {
"unsupported scheme"
}
return (parsed.openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 60_000 readTimeout = 60_000
instanceFollowRedirects = true instanceFollowRedirects = true
} }
}
/**
* Reads at most [MAX_BYTES] and gives up after [MAX_TRANSFER_MS] overall.
* `readTimeout` alone only bounds a single read, so a server trickling
* bytes could grow the buffer until the app died.
*/
private fun download(url: String): String { private fun download(url: String): String {
val connection = open(url) val connection = open(url)
val deadline = System.currentTimeMillis() + MAX_TRANSFER_MS
return try { return try {
connection.inputStream.bufferedReader().use { it.readText() } val builder = StringBuilder()
connection.inputStream.bufferedReader().use { reader ->
val buffer = CharArray(8 * 1024)
while (true) {
val read = reader.read(buffer)
if (read < 0) break
builder.append(buffer, 0, read)
require(builder.length <= MAX_BYTES) { "playlist too large" }
require(System.currentTimeMillis() < deadline) { "download timed out" }
}
}
builder.toString()
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
@@ -114,5 +141,7 @@ class SourceRepository(context: Context) {
private companion object { private companion object {
const val TAG = "SourceRepository" const val TAG = "SourceRepository"
const val MAX_BYTES = 24 * 1024 * 1024
const val MAX_TRANSFER_MS = 120_000L
} }
} }

View File

@@ -3,35 +3,84 @@ package dev.castarr.tv.pairing
import android.content.Context import android.content.Context
import java.net.Inet4Address import java.net.Inet4Address
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
object Pairing { object Pairing {
const val PORT = 8765 const val PORT = 8765
private const val PREFS = "pairing"
private const val KEY_CODE = "code"
private const val KEY_TOKEN = "token"
private const val KEY_SESSIONS = "session_tokens"
private const val MAX_SESSIONS = 8
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** /**
* Four-digit pairing code, generated once and kept stable so an already * Four-digit pairing code, generated once and kept stable so an already
* paired phone survives app restarts. Human fallback only — the QR code * paired phone survives app restarts. Human fallback only — the QR code
* carries the long token below. * carries the long token below.
*/ */
fun code(context: Context): String { fun code(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) prefs(context).getString(KEY_CODE, null)?.let { return it }
prefs.getString("code", null)?.let { return it }
val code = "%04d".format(SecureRandom().nextInt(10_000)) val code = "%04d".format(SecureRandom().nextInt(10_000))
prefs.edit().putString("code", code).apply() prefs(context).edit().putString(KEY_CODE, code).apply()
return code return code
} }
/** 128-bit random token embedded in the QR code; not brute-forceable. */ /** 128-bit random token embedded in the QR code; not brute-forceable. */
fun token(context: Context): String { fun token(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) prefs(context).getString(KEY_TOKEN, null)?.let { return it }
prefs.getString("token", null)?.let { return it } val token = randomToken()
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } prefs(context).edit().putString(KEY_TOKEN, token).apply()
val token = bytes.joinToString("") { "%02x".format(it) }
prefs.edit().putString("token", token).apply()
return token return token
} }
/**
* Issues a separate token for a client that authenticated with the
* four-digit code. Handing out the QR token there would make the
* brute-forceable path yield the un-rotating master credential.
*/
fun newSessionToken(context: Context): String {
val token = randomToken()
val sessions = sessionTokens(context).toMutableList()
sessions.add(token)
while (sessions.size > MAX_SESSIONS) sessions.removeAt(0)
prefs(context).edit().putStringSet(KEY_SESSIONS, sessions.toSet()).apply()
return token
}
/** Constant-time check against the QR token and all session tokens. */
fun isValidToken(context: Context, candidate: String): Boolean {
if (candidate.isEmpty()) return false
if (constantTimeEquals(candidate, token(context))) return true
return sessionTokens(context).any { constantTimeEquals(candidate, it) }
}
fun isValidCode(context: Context, candidate: String): Boolean =
candidate.isNotEmpty() && constantTimeEquals(candidate, code(context))
/**
* Drops every credential: the QR token, the code and all session tokens.
* Paired phones must scan again — the way to revoke a leaked token.
*/
fun reset(context: Context) {
prefs(context).edit().clear().apply()
}
private fun sessionTokens(context: Context): List<String> =
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
private fun randomToken(): String =
ByteArray(16).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) }
private fun constantTimeEquals(a: String, b: String): Boolean =
MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
/** Best-guess LAN IPv4 address of this device, or null when offline. */ /** Best-guess LAN IPv4 address of this device, or null when offline. */
fun lanAddress(): String? { fun lanAddress(): String? {
val candidates = runCatching { val candidates = runCatching {

View File

@@ -23,7 +23,10 @@ class PlayerController(
) { ) {
val player: ExoPlayer = ExoPlayer.Builder(context).build() val player: ExoPlayer = ExoPlayer.Builder(context).build()
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build() // Explicit id: Media3 keeps a process-wide registry and throws when a
// session with the same (default, empty) id is still registered.
private val mediaSession: MediaSession =
MediaSession.Builder(context, player).setId("castarr").build()
var channelName: String = "" var channelName: String = ""
private set private set
@@ -33,6 +36,16 @@ class PlayerController(
private set private set
private var retryCount = 0 private var retryCount = 0
private var lastUrl: String = "" private var lastUrl: String = ""
private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var pendingRetry: Runnable? = null
/** True while a reconnect attempt is scheduled or running. */
var reconnecting: Boolean = false
private set
/** When the current channel was opened — used to pace re-entry. */
var startedAt: Long = 0L
private set
/** One selectable audio track of the current stream. */ /** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean) data class AudioTrack(val label: String, val selected: Boolean)
@@ -46,14 +59,18 @@ class PlayerController(
override fun onTracksChanged(tracks: Tracks) = onChanged() override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13). // Self-heal with spacing: providers rate-limit reconnects, so
if (retryCount < 2 && lastUrl.isNotEmpty()) { // three back-to-back attempts all land in the same blocked
// window and burn the budget in under a second.
if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) {
val delay = RETRY_DELAYS_MS[retryCount]
retryCount++ retryCount++
player.setMediaItem(MediaItem.fromUri(lastUrl)) reconnecting = true
player.prepare() onChanged()
player.play() scheduleRetry(delay)
return return
} }
reconnecting = false
errorMessage = "unreachable" errorMessage = "unreachable"
onChanged() onChanged()
} }
@@ -63,6 +80,7 @@ class PlayerController(
val state: String val state: String
get() = when { get() = when {
errorMessage != null -> "error" errorMessage != null -> "error"
reconnecting -> "reconnecting"
player.playbackState == Player.STATE_BUFFERING -> "buffering" player.playbackState == Player.STATE_BUFFERING -> "buffering"
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing" player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
player.playbackState == Player.STATE_READY -> "paused" player.playbackState == Player.STATE_READY -> "paused"
@@ -74,9 +92,12 @@ class PlayerController(
fun play(url: String, name: String, group: String) { fun play(url: String, name: String, group: String) {
if (url.isEmpty()) return if (url.isEmpty()) return
cancelRetry()
errorMessage = null errorMessage = null
reconnecting = false
retryCount = 0 retryCount = 0
lastUrl = url lastUrl = url
startedAt = System.currentTimeMillis()
channelName = name.ifEmpty { url } channelName = name.ifEmpty { url }
channelGroup = group channelGroup = group
val item = MediaItem.Builder() val item = MediaItem.Builder()
@@ -88,13 +109,45 @@ class PlayerController(
player.play() player.play()
} }
private fun scheduleRetry(delayMs: Long) {
cancelRetry()
val runnable = Runnable {
pendingRetry = null
player.setMediaItem(MediaItem.fromUri(lastUrl))
player.prepare()
player.play()
}
pendingRetry = runnable
retryHandler.postDelayed(runnable, delayMs)
}
private fun cancelRetry() {
pendingRetry?.let { retryHandler.removeCallbacks(it) }
pendingRetry = null
}
/** Retries the current channel immediately, resetting the backoff. */
fun retryNow() {
if (lastUrl.isEmpty()) return
cancelRetry()
retryCount = 0
errorMessage = null
reconnecting = true
onChanged()
player.setMediaItem(MediaItem.fromUri(lastUrl))
player.prepare()
player.play()
}
fun toggle() { fun toggle() {
if (!hasMedia) return if (!hasMedia) return
if (player.isPlaying) player.pause() else resume() // playWhenReady, not isPlaying: the latter is false while buffering,
// which made pausing during a stall silently do nothing.
if (player.playWhenReady) pause() else resume()
} }
fun pause() { fun pause() {
if (player.isPlaying) player.pause() player.playWhenReady = false
} }
fun resume() { fun resume() {
@@ -104,6 +157,8 @@ class PlayerController(
} }
fun stop() { fun stop() {
cancelRetry()
reconnecting = false
player.stop() player.stop()
player.clearMediaItems() player.clearMediaItems()
channelName = "" channelName = ""
@@ -158,13 +213,22 @@ class PlayerController(
fun seekBy(deltaSeconds: Long) { fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000) // An unknown duration is C.TIME_UNSET (large negative). Clamping the
.coerceIn(0, player.duration.coerceAtLeast(0)) // upper bound to it collapsed every seek to position 0.
val duration = player.duration
var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0)
if (duration > 0) target = target.coerceAtMost(duration)
player.seekTo(target) player.seekTo(target)
} }
fun release() { fun release() {
cancelRetry()
mediaSession.release() mediaSession.release()
player.release() player.release()
} }
private companion object {
/** Spacing between reconnect attempts after a stream drops. */
val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L)
}
} }

View File

@@ -3,10 +3,24 @@ package dev.castarr.tv.playlist
object M3uParser { object M3uParser {
private const val MAX_CHANNELS = 5000 private const val MAX_CHANNELS = 5000
private const val BOM = ''
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""") private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
/** Thrown when the payload is not a playlist at all (HTML, JSON, …). */
class NotAPlaylistException : IllegalArgumentException("not an M3U playlist")
/**
* Parses an M3U playlist. Rejects payloads that carry no playlist markers
* at all — pointing the app at a 404 page used to silently replace the
* channel list with HTML fragments.
*/
fun parse(content: String): List<Channel> { fun parse(content: String): List<Channel> {
// A UTF-8 BOM is not whitespace, so trim() leaves it in place and the
// first line stops looking like a comment.
val text = content.trimStart(BOM)
if (!looksLikePlaylist(text)) throw NotAPlaylistException()
val channels = mutableListOf<Channel>() val channels = mutableListOf<Channel>()
var name = "" var name = ""
var group = "" var group = ""
@@ -14,15 +28,15 @@ object M3uParser {
var tvgId = "" var tvgId = ""
var pendingInfo = false var pendingInfo = false
for (rawLine in content.lineSequence()) { for (rawLine in text.lineSequence()) {
val line = rawLine.trim() val line = rawLine.trim().trimStart(BOM)
when { when {
line.startsWith("#EXTINF", ignoreCase = true) -> { line.startsWith("#EXTINF", ignoreCase = true) -> {
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] } val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
group = attrs["group-title"].orEmpty() group = attrs["group-title"].orEmpty()
logo = attrs["tvg-logo"].orEmpty() logo = attrs["tvg-logo"].orEmpty()
tvgId = attrs["tvg-id"].orEmpty() tvgId = attrs["tvg-id"].orEmpty()
name = line.substringAfterLast(',', "").trim() name = displayName(line)
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty() if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
pendingInfo = true pendingInfo = true
} }
@@ -41,11 +55,43 @@ object M3uParser {
} }
else -> { else -> {
// Bare URL without #EXTINF — still a playable entry. // Bare URL without #EXTINF — still a playable entry.
channels.add(Channel(line, line, "", "")) channels.add(Channel(line, line, group, ""))
if (channels.size >= MAX_CHANNELS) return channels if (channels.size >= MAX_CHANNELS) return channels
} }
} }
} }
return channels return channels
} }
private fun looksLikePlaylist(text: String): Boolean {
val head = text.lineSequence().take(50)
return head.any {
val line = it.trim().trimStart(BOM)
line.startsWith("#EXTM3U", ignoreCase = true) ||
line.startsWith("#EXTINF", ignoreCase = true)
}
}
/**
* The display name is everything after the FIRST comma that follows the
* duration field — `substringAfterLast` swallowed every name containing
* a comma ("Comedy Central, HD" became "HD").
*/
private fun displayName(extinf: String): String {
val payload = extinf.substringAfter(':', "")
val comma = firstUnquotedComma(payload)
return if (comma < 0) "" else payload.substring(comma + 1).trim()
}
/** Commas inside attribute values (group-title="A, B") do not separate. */
private fun firstUnquotedComma(payload: String): Int {
var inQuotes = false
payload.forEachIndexed { index, c ->
when {
c == '"' -> inQuotes = !inQuotes
c == ',' && !inQuotes -> return index
}
}
return -1
}
} }

View File

@@ -18,7 +18,8 @@ import java.util.concurrent.Executors
/** /**
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML * Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
* page) over HTTP and takes playback commands over a WebSocket. A client * page) over HTTP and takes playback commands over a WebSocket. A client
* authorizes itself with the four-digit pairing code from the QR code. * authorizes itself with the token from the QR code or the four-digit
* pairing code.
*/ */
class ControlServer( class ControlServer(
private val context: Context, private val context: Context,
@@ -51,30 +52,43 @@ class ControlServer(
// sends are serialized through this worker. // sends are serialized through this worker.
private val sendExecutor = Executors.newSingleThreadExecutor() private val sendExecutor = Executors.newSingleThreadExecutor()
private val clients = CopyOnWriteArrayList<RemoteSocket>() private val clients = CopyOnWriteArrayList<RemoteSocket>()
private val pairingCode = Pairing.code(context)
private val pairingToken = Pairing.token(context)
private var pingTimer: Timer? = null private var pingTimer: Timer? = null
// Rate limit for the human-typable 4-digit code (the QR token is not /** False when the port could not be bound; the app stays usable. */
// brute-forceable and stays exempt). @Volatile
private val codeAttempts = ArrayDeque<Long>() var running = false
private set
// Failed authentication attempts per remote address. Counting every
// failure (not just the ones carrying a code) keeps a hostile client
// from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>()
@Synchronized @Synchronized
private fun codeAttemptAllowed(): Boolean { private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) { val queue = attempts.getOrPut(address) { ArrayDeque() }
codeAttempts.removeFirst() while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
} }
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false if (attempts.size > MAX_TRACKED_ADDRESSES) {
codeAttempts.addLast(now) attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true return true
} }
fun startServer() { fun startServer() {
start(0, true) // A busy port must not take the whole app down — the remote is
// optional, everything else keeps working.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess
if (!running) return
pingTimer = Timer("ws-ping", true).also { pingTimer = Timer("ws-ping", true).also {
it.schedule(object : TimerTask() { it.schedule(object : TimerTask() {
override fun run() = pingClients() override fun run() = maintainClients()
}, PING_INTERVAL_MS, PING_INTERVAL_MS) }, PING_INTERVAL_MS, PING_INTERVAL_MS)
} }
} }
@@ -83,17 +97,41 @@ class ControlServer(
pingTimer?.cancel() pingTimer?.cancel()
pingTimer = null pingTimer = null
sendExecutor.shutdown() sendExecutor.shutdown()
stop() if (running) runCatching { stop() }
running = false
} }
// --- HTTP --- // --- HTTP ---
/**
* Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`,
* and WebSockets are exempt from the same-origin policy, so without this
* any page on the LAN could talk to the control socket.
*/
override fun serve(session: IHTTPSession): Response {
val origin = session.headers["origin"]
if (origin != null && !isOwnOrigin(origin, session.headers["host"])) {
Log.w(TAG, "rejected cross-origin request")
return newFixedLengthResponse(
Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden",
)
}
return super.serve(session)
}
private fun isOwnOrigin(origin: String, host: String?): Boolean {
if (host.isNullOrEmpty()) return false
return origin.equals("http://$host", ignoreCase = true) ||
origin.equals("https://$host", ignoreCase = true)
}
override fun serveHttp(session: IHTTPSession): Response { override fun serveHttp(session: IHTTPSession): Response {
return when (session.uri) { return when (session.uri) {
"/", "/index.html" -> { "/", "/index.html" -> {
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() } val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply { newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
addHeader("Cache-Control", "no-store") addHeader("Cache-Control", "no-store")
addHeader("Referrer-Policy", "no-referrer")
} }
} }
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found") else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
@@ -102,7 +140,8 @@ class ControlServer(
// --- WebSocket --- // --- WebSocket ---
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake) override fun openWebSocket(handshake: IHTTPSession): WebSocket =
RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty())
fun broadcast(message: JSONObject) { fun broadcast(message: JSONObject) {
val payload = message.toString() val payload = message.toString()
@@ -139,8 +178,18 @@ class ControlServer(
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
.put("extras", listener.channelsExtras()) .put("extras", listener.channelsExtras())
private fun pingClients() { /**
* Pings live clients and drops sockets that never authenticated. Without
* this an unauthenticated connection would pin a thread forever.
*/
private fun maintainClients() {
val now = System.currentTimeMillis()
clients.forEach { client -> clients.forEach { client ->
if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) {
Log.d(TAG, "dropping client that never authenticated")
client.dropSilently()
return@forEach
}
try { try {
client.ping(PING_PAYLOAD) client.ping(PING_PAYLOAD)
} catch (e: IOException) { } catch (e: IOException) {
@@ -156,12 +205,16 @@ class ControlServer(
mainHandler.post { listener.onClientsChanged(count, newestName) } mainHandler.post { listener.onClientsChanged(count, newestName) }
} }
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) { inner class RemoteSocket(
handshake: IHTTPSession,
private val remoteAddress: String,
) : WebSocket(handshake) {
@Volatile @Volatile
var authorized = false var authorized = false
private set private set
val openedAt = System.currentTimeMillis()
private var deviceName: String = "" private var deviceName: String = ""
fun trySend(payload: String) { fun trySend(payload: String) {
@@ -173,7 +226,19 @@ class ControlServer(
} }
} }
fun dropSilently() {
clients.remove(this)
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
}
override fun onOpen() { override fun onOpen() {
// Cap concurrent sockets — a TV remote needs a handful, and an
// unbounded count is a free resource-exhaustion vector.
if (clients.size >= MAX_CLIENTS) {
Log.w(TAG, "client limit reached, refusing connection")
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
return
}
clients.add(this) clients.add(this)
} }
@@ -227,18 +292,15 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
val token = msg.optString("token") // Every failed attempt counts against this address, whether it
val tokenOk = token.isNotEmpty() && token == pairingToken // carried a token or a code.
val codeOk = !tokenOk && msg.optString("code").let { code -> if (!attemptAllowed(remoteAddress)) {
code.isNotEmpty() && when { trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
!codeAttemptAllowed() -> { runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) return
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return
}
else -> code == pairingCode
}
} }
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
@@ -246,6 +308,11 @@ class ControlServer(
} }
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
// the QR token — otherwise the guessable path would yield the
// master credential.
val grantedToken =
if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
// Build the welcome payload on the main thread — it reads the // Build the welcome payload on the main thread — it reads the
// player and UI state, which must not happen on the WS thread. // player and UI state, which must not happen on the WS thread.
post { post {
@@ -253,7 +320,7 @@ class ControlServer(
.put("type", "welcome") .put("type", "welcome")
.put("setup", listener.setupNeeded()) .put("setup", listener.setupNeeded())
.put("device", android.os.Build.MODEL) .put("device", android.os.Build.MODEL)
.put("token", pairingToken) .put("token", grantedToken)
.put("status", listener.currentStatus()) .put("status", listener.currentStatus())
.put("playlistUrl", listener.currentPlaylistUrl()) .put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
@@ -272,8 +339,11 @@ class ControlServer(
private companion object { private companion object {
const val TAG = "ControlServer" const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L const val PING_INTERVAL_MS = 8_000L
const val CODE_ATTEMPT_WINDOW_MS = 60_000L const val ATTEMPT_WINDOW_MS = 60_000L
const val CODE_ATTEMPT_MAX = 5 const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64
const val MAX_CLIENTS = 8
const val HANDSHAKE_TIMEOUT_MS = 10_000L
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
} }
} }

View File

@@ -23,6 +23,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -92,9 +94,24 @@ fun LiveScreen(state: AppState) {
// list at the top — which also gets scrolled up on every group change. // list at the top — which also gets scrolled up on every group change.
val railFocus = remember { FocusRequester() } val railFocus = remember { FocusRequester() }
val listFocus = remember { FocusRequester() } val listFocus = remember { FocusRequester() }
val restoreFocus = remember { FocusRequester() }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Index of the channel the viewer last watched, so leaving playback
// returns them to where they were instead of the top of the list.
val restoreIndex = state.lastWatched?.let { watched ->
channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
}
var restored by remember { mutableStateOf(false) }
LaunchedEffect(state.groupFilter, state.favoritesOnly) { LaunchedEffect(state.groupFilter, state.favoritesOnly) {
listState.scrollToItem(0) // A fresh group starts at the top; a return from playback does not.
if (restoreIndex == null) listState.scrollToItem(0)
}
LaunchedEffect(restoreIndex, channels.size) {
if (restoreIndex != null && !restored) {
listState.scrollToItem(restoreIndex)
runCatching { restoreFocus.requestFocus() }
restored = true
}
} }
val intoList = if (channels.isEmpty()) Modifier val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus } else Modifier.focusProperties { right = listFocus }
@@ -193,7 +210,11 @@ fun LiveScreen(state: AppState) {
number = allChannels.indexOf(channel) + 1, number = allChannels.indexOf(channel) + 1,
modifier = Modifier modifier = Modifier
.focusProperties { left = railFocus } .focusProperties { left = railFocus }
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier), .then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
.then(
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
else Modifier
),
nowNext = state.nowNext(channel), nowNext = state.nowNext(channel),
playing = state.currentChannel?.url == channel.url, playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the // All rows are favorites in the favorites view — the

View File

@@ -177,7 +177,8 @@ private fun Overlay(state: AppState) {
val statusWord = when (state.playerState) { val statusWord = when (state.playerState) {
"paused" -> "Pausiert" "paused" -> "Pausiert"
"buffering" -> "Lädt…" "buffering" -> "Lädt…"
"error" -> "Wiedergabefehler" "reconnecting" -> "Verbindung wird wiederhergestellt…"
"error" -> "Sender gerade nicht erreichbar"
else -> null else -> null
} }
if (statusWord != null) { if (statusWord != null) {
@@ -235,7 +236,7 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text( Text(
"${formatClock(now.start)} ${formatClock(now.stop)}", "${formatClock(now.start)} ${formatClock(now.stop)}",
color = CastarrColors.muted, color = Color(0xFFD7DBE0),
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText, style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -248,7 +249,7 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
"Danach: ${next.title} (${formatClock(next.start)})", "Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.faint, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText, style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -270,7 +271,7 @@ private fun Overlay(state: AppState) {
.fillMaxWidth() .fillMaxWidth()
.height(3.dp) .height(3.dp)
.clip(RoundedCornerShape(1.5.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(Color(0x59FFFFFF))
) { ) {
Box( Box(
Modifier Modifier
@@ -282,13 +283,27 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton( if (state.playerState == "error") {
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, // One clear action instead of silently dropping back to
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", // the channel list.
focusRequester = playFocus, PillButton("Erneut versuchen", focusRequester = playFocus) {
) { state.player.toggle() } state.retryPlayback()
Spacer(Modifier.width(10.dp)) }
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() } Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
} else {
IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus,
) { state.player.toggle() }
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
}
if (state.audioTracks.size > 1) { if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected } val current = state.audioTracks.firstOrNull { it.selected }

View File

@@ -72,6 +72,8 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState() val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") } var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) } var picker by remember { mutableStateOf<Picker?>(null) }
// Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) }
Row( Row(
Modifier Modifier
@@ -212,9 +214,15 @@ fun SettingsScreen(state: AppState) {
Column(Modifier.weight(0.75f)) { Column(Modifier.weight(0.75f)) {
SettingsCard("Handy-Fernbedienung") { SettingsCard("Handy-Fernbedienung") {
val address = remember { Pairing.lanAddress() } val address = remember(pairingEpoch) { Pairing.lanAddress() }
if (address != null) { if (!state.remoteAvailable) {
val qr = remember(address) { Text(
"Fernbedienung nicht verfügbar — Port belegt. Neustart des Fernsehers hilft meistens.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
} else if (address != null) {
val qr = remember(address, pairingEpoch) {
Qr.encode( Qr.encode(
Pairing.pairingUrl(context, address), 400, Pairing.pairingUrl(context, address), 400,
android.graphics.Color.parseColor("#101216"), android.graphics.Color.parseColor("#101216"),
@@ -236,6 +244,14 @@ fun SettingsScreen(state: AppState) {
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
) )
Spacer(Modifier.height(10.dp))
}
SettingRow(
"Kopplung zurücksetzen",
subtitle = "Neuer Code, alle Handys müssen neu scannen",
) {
Pairing.reset(context)
pairingEpoch++
} }
} else { } else {
Text( Text(

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Credentials must not travel to Google Drive: the pairing token grants
full control of the TV and the OIDC tokens are account credentials. -->
<full-backup-content>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</full-backup-content>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</device-transfer>
</data-extraction-rules>

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
docs/screenshots/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,80 @@
package dev.castarr.tv.playlist
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class M3uParserTest {
@Test
fun `keeps commas inside display names`() {
val channels = M3uParser.parse(
"""
#EXTM3U
#EXTINF:-1 group-title="Doku",Comedy Central, HD
http://example.com/a.ts
""".trimIndent()
)
assertEquals(1, channels.size)
assertEquals("Comedy Central, HD", channels[0].name)
assertEquals("Doku", channels[0].group)
}
@Test
fun `commas inside attributes do not split the name`() {
val channels = M3uParser.parse(
"""
#EXTM3U
#EXTINF:-1 group-title="News, Sport" tvg-id="x",Das Erste HD
http://example.com/b.ts
""".trimIndent()
)
assertEquals("Das Erste HD", channels[0].name)
assertEquals("News, Sport", channels[0].group)
assertEquals("x", channels[0].tvgId)
}
@Test
fun `strips a UTF-8 BOM instead of inventing a channel`() {
val channels = M3uParser.parse(
"#EXTM3U\n#EXTINF:-1,Channel A\nhttp://example.com/a.ts\n"
)
assertEquals(1, channels.size)
assertEquals("Channel A", channels[0].name)
}
@Test
fun `rejects HTML instead of turning it into channels`() {
assertThrows(M3uParser.NotAPlaylistException::class.java) {
M3uParser.parse("<!DOCTYPE html>\n<html>\n<body>404</body>\n</html>")
}
}
@Test
fun `rejects an empty payload`() {
assertThrows(M3uParser.NotAPlaylistException::class.java) {
M3uParser.parse("")
}
}
@Test
fun `accepts a playlist whose header is missing but has entries`() {
val channels = M3uParser.parse("#EXTINF:-1,Nur ein Sender\nhttp://example.com/c.ts")
assertEquals(1, channels.size)
assertEquals("Nur ein Sender", channels[0].name)
}
@Test
fun `falls back to the url when the name is empty`() {
val channels = M3uParser.parse("#EXTM3U\n#EXTINF:-1,\nhttp://example.com/d.ts")
assertEquals("http://example.com/d.ts", channels[0].name)
}
@Test
fun `carries EXTGRP over to a bare url`() {
val channels = M3uParser.parse("#EXTM3U\n#EXTGRP:Sport\nhttp://example.com/e.ts")
assertTrue(channels.isNotEmpty())
assertEquals("Sport", channels[0].group)
}
}