Resolve the full review backlog (issues #1-#13)
Some checks failed
Build TV app / build (push) Failing after 2s
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:
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user