8 Commits

Author SHA1 Message Date
be-nj
f157323626 fix(live): returning from playback keeps the group you were in
Some checks failed
Build TV app / build (push) Failing after 2s
The rail opens whatever gets focused, so when the player closed and focus
landed on "Alle Sender", the view silently switched away from the group
the viewer had been browsing. A restore flag now suppresses that
auto-select until focus has been placed on the channel that was watched.

Completes #13.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:20:17 +02:00
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
22 changed files with 606 additions and 87 deletions

1
.gitignore vendored
View File

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

View File

@@ -7,7 +7,13 @@ 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 Handy, meldet sich dort an — fertig. Danach zappt man mit der normalen
TV-Fernbedienung oder steuert alles bequem vom Handy aus. TV-Fernbedienung oder steuert alles bequem vom Handy aus.
![Senderliste](docs/screenshots/senderliste.png) | Senderliste | Player |
| --- | --- |
| ![Senderliste](docs/screenshots/senderliste.png) | ![Player](docs/screenshots/player.png) |
| Nummerntasten | Einrichtung |
| --- | --- |
| ![Nummerntasten](docs/screenshots/nummerntasten.png) | ![Willkommens-Bildschirm](docs/screenshots/willkommen.png) |
## Was die App kann ## Was die App kann
@@ -27,10 +33,6 @@ TV-Fernbedienung oder steuert alles bequem vom Handy aus.
- **Updates aus der App heraus** — unter Einstellungen → App genügt ein - **Updates aus der App heraus** — unter Einstellungen → App genügt ein
Klick, wenn eine neue Version bereitsteht. Klick, wenn eine neue Version bereitsteht.
| Player | Nummerntasten |
| --- | --- |
| ![Player](docs/screenshots/player.png) | ![Nummerntasten](docs/screenshots/nummerntasten.png) |
## Installation auf dem Fernseher ## Installation auf dem Fernseher
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen 1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 22 versionCode = 24
versionName = "0.8.6" versionName = "0.9.1"
} }
// 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,23 @@ 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)
/**
* True while the list is being restored after playback. The group rail
* opens whatever gets focused, so without this the focus landing there
* would silently switch the view back to "Alle Sender".
*/
var restorePending by mutableStateOf(false)
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 +156,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 +176,41 @@ 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() {
restorePending = lastWatched != null
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 +233,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 {
!codeAttemptAllowed() -> {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return 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,25 @@ 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, state.restorePending) {
if (restoreIndex != null && (state.restorePending || !restored)) {
listState.scrollToItem(restoreIndex)
runCatching { restoreFocus.requestFocus() }
restored = true
state.restorePending = false
}
} }
val intoList = if (channels.isEmpty()) Modifier val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus } else Modifier.focusProperties { right = listFocus }
@@ -118,6 +136,7 @@ fun LiveScreen(state: AppState) {
modifier = intoList.then( modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier if (selected) Modifier.focusRequester(railFocus) else Modifier
), ),
suppressAutoSelect = { state.restorePending },
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = null state.groupFilter = null
@@ -132,6 +151,7 @@ fun LiveScreen(state: AppState) {
modifier = intoList.then( modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
), ),
suppressAutoSelect = { state.restorePending },
) { ) {
state.favoritesOnly = true state.favoritesOnly = true
state.groupFilter = null state.groupFilter = null
@@ -155,6 +175,7 @@ fun LiveScreen(state: AppState) {
modifier = intoList.then( modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
), ),
suppressAutoSelect = { state.restorePending },
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = group state.groupFilter = group
@@ -193,7 +214,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
@@ -216,6 +241,7 @@ private fun GroupItem(
count: Int, count: Int,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
Surface( Surface(
@@ -224,7 +250,7 @@ private fun GroupItem(
onClick = onSelect, onClick = onSelect,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() }, .onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(

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) {
@@ -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) {
if (state.playerState == "error") {
// One clear action instead of silently dropping back to
// the channel list.
PillButton("Erneut versuchen", focusRequester = playFocus) {
state.retryPlayback()
}
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
} else {
IconButton( IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus, focusRequester = playFocus,
) { state.player.toggle() } ) { state.player.toggle() }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() } 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.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

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)
}
}