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>
This commit is contained in:
be-nj
2026-08-26 02:14:48 +02:00
parent 7abd4b2a48
commit c0adce2b09
17 changed files with 585 additions and 81 deletions

View File

@@ -45,6 +45,16 @@ class AppState(
/** Digits typed on the remote's number pad (channel switching). */
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
* auto-hide timer. */
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 {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
@@ -154,17 +169,40 @@ class AppState(
}
fun play(channel: Channel) {
lastWatched = channel
currentChannel = channel
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)
}
fun stopPlayback() {
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
lastStoppedAt = System.currentTimeMillis()
player.stop()
playerVisible = false
currentChannel = null
}
/** Retry the failed channel without leaving the player. */
fun retryPlayback() {
player.retryNow()
}
fun zap(direction: Int) {
val list = activeChannels()
if (list.isEmpty()) return
@@ -187,10 +225,15 @@ class AppState(
overlayVisible = true
}
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
currentChannel = null
}
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
}
/** Steps to the next audio track (D-pad friendly: one key, cycles). */