* feat: add system media controls for the active server Add a media3 MediaSessionService that mirrors playback on the active RelayTV server as a system media session (lock screen / quick settings): - MediaControlService polls GET /status and publishes state, metadata, artwork, and remote volume; forwards play/pause, next/previous, seek, stop, and volume to the server HTTP API - RelayRemotePlayer implements SimpleBasePlayer over the remote state - RemoteStatus parses /status defensively across server versions - New Settings screen (toolbar menu) with a media-controls toggle and a Manage servers shortcut; fix open_servers intent handling via onNewIntent when MainActivity is already running Release prep: - Bump to versionCode 6 / versionName 1.3.0 - Wire optional release signing from MYAPP_RELEASE_* gradle properties - Add FOREGROUND_SERVICE(_MEDIA_PLAYBACK) permissions and lint opt-ins - Update privacy policy, release checklist (Play Console foreground service declaration), README, and Gradle daemon heap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: sign release bundle in GitHub Actions Decode the upload keystore from the RELEASE_KEYSTORE_BASE64 secret and pass the MYAPP_RELEASE_* signing properties to Gradle via ORG_GRADLE_PROJECT_* env vars, so tagged releases produce a signed .aab. Fail fast if the keystore secret is missing, and verify the bundle signature after the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
222 lines
7.9 KiB
Kotlin
222 lines
7.9 KiB
Kotlin
package pro.relaytv
|
|
|
|
import android.app.PendingIntent
|
|
import android.content.Intent
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.os.SystemClock
|
|
import androidx.media3.common.util.UnstableApi
|
|
import androidx.media3.session.MediaSession
|
|
import androidx.media3.session.MediaSessionService
|
|
import okhttp3.Call
|
|
import okhttp3.Callback
|
|
import okhttp3.Response
|
|
import java.io.IOException
|
|
|
|
/**
|
|
* Publishes a MediaSession that mirrors playback on the active RelayTV server,
|
|
* giving lock screen / quick settings media controls for the TV. All transport
|
|
* actions are forwarded to the server's HTTP API; nothing plays locally.
|
|
*
|
|
* The service polls GET /status while running and stops itself after the
|
|
* server has been idle for a while (MainActivity restarts it while the app
|
|
* is in the foreground).
|
|
*/
|
|
@UnstableApi
|
|
class MediaControlService : MediaSessionService() {
|
|
|
|
private val handler = Handler(Looper.getMainLooper())
|
|
private var player: RelayRemotePlayer? = null
|
|
private var session: MediaSession? = null
|
|
|
|
private var lastActiveAt = SystemClock.elapsedRealtime()
|
|
private var artworkUrl: String? = null
|
|
private var artworkBytes: ByteArray? = null
|
|
|
|
private val pollRunnable = Runnable { poll() }
|
|
|
|
companion object {
|
|
private const val POLL_PLAYING_MS = 3_000L
|
|
private const val POLL_PAUSED_MS = 5_000L
|
|
private const val POLL_IDLE_MS = 10_000L
|
|
private const val POLL_AFTER_COMMAND_MS = 700L
|
|
private const val IDLE_STOP_MS = 5 * 60_000L
|
|
private const val MAX_ARTWORK_BYTES = 3L * 1024 * 1024
|
|
}
|
|
|
|
private val playerListener = object : RelayRemotePlayer.Listener {
|
|
override fun onSetPaused(paused: Boolean) = post(if (paused) "/pause" else "/resume")
|
|
override fun onNext() = post("/next")
|
|
override fun onPrevious() = post("/previous")
|
|
override fun onSeekTo(seconds: Double) = post("/seek_abs", """{"sec":$seconds}""")
|
|
override fun onStop() = post("/stop")
|
|
override fun onSetVolume(percent: Int) = post("/volume", """{"set":$percent}""")
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
val player = RelayRemotePlayer(Looper.getMainLooper(), playerListener)
|
|
this.player = player
|
|
|
|
val sessionActivity = PendingIntent.getActivity(
|
|
this,
|
|
0,
|
|
Intent(this, MainActivity::class.java),
|
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
)
|
|
val session = MediaSession.Builder(this, player)
|
|
.setSessionActivity(sessionActivity)
|
|
.build()
|
|
this.session = session
|
|
// Nothing external binds to this service (playback starts on the server),
|
|
// so the session must be registered explicitly for the media notification
|
|
// manager to track it.
|
|
addSession(session)
|
|
|
|
schedulePoll(0)
|
|
}
|
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
super.onStartCommand(intent, flags, startId)
|
|
if (!AppSettings.isMediaControlsEnabled(this)) {
|
|
stopSelf()
|
|
return START_NOT_STICKY
|
|
}
|
|
// Keep the idle timer fresh while the app keeps nudging us.
|
|
lastActiveAt = SystemClock.elapsedRealtime()
|
|
schedulePoll(0)
|
|
return START_NOT_STICKY
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
handler.removeCallbacksAndMessages(null)
|
|
session?.let {
|
|
removeSession(it)
|
|
it.release()
|
|
}
|
|
session = null
|
|
player?.release()
|
|
player = null
|
|
super.onDestroy()
|
|
}
|
|
|
|
private fun activeBase(): String? = HostStore.getActiveBaseUrl(this)?.trimEnd('/')
|
|
|
|
private fun schedulePoll(delayMs: Long) {
|
|
handler.removeCallbacks(pollRunnable)
|
|
handler.postDelayed(pollRunnable, delayMs)
|
|
}
|
|
|
|
private fun poll() {
|
|
if (!AppSettings.isMediaControlsEnabled(this)) {
|
|
stopSelf()
|
|
return
|
|
}
|
|
val base = activeBase()
|
|
if (base.isNullOrBlank()) {
|
|
applyStatus(RemoteStatus.IDLE, "RelayTV", base = null)
|
|
return
|
|
}
|
|
Net.client.newCall(Net.get("$base/status")).enqueue(object : Callback {
|
|
override fun onFailure(call: Call, e: IOException) {
|
|
handler.post { applyStatus(RemoteStatus.IDLE, serverName(), base) }
|
|
}
|
|
|
|
override fun onResponse(call: Call, response: Response) {
|
|
val status = response.use { resp ->
|
|
if (!resp.isSuccessful) {
|
|
RemoteStatus.IDLE
|
|
} else {
|
|
RemoteStatus.parse(resp.body?.string().orEmpty())
|
|
}
|
|
}
|
|
handler.post { applyStatus(status, serverName(), base) }
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun serverName(): String =
|
|
HostStore.getActiveHost(this)?.name?.ifBlank { "RelayTV" } ?: "RelayTV"
|
|
|
|
private fun applyStatus(status: RemoteStatus, serverName: String, base: String?) {
|
|
val player = player ?: return
|
|
|
|
if (status.active) {
|
|
lastActiveAt = SystemClock.elapsedRealtime()
|
|
} else if (SystemClock.elapsedRealtime() - lastActiveAt > IDLE_STOP_MS) {
|
|
player.updateStatus(RemoteStatus.IDLE, serverName, null)
|
|
stopSelf()
|
|
return
|
|
}
|
|
|
|
ensureArtwork(base, status.thumbnail)
|
|
player.updateStatus(status, serverName, artworkBytes)
|
|
|
|
val delay = when {
|
|
status.playing && !status.paused -> POLL_PLAYING_MS
|
|
status.active -> POLL_PAUSED_MS
|
|
else -> POLL_IDLE_MS
|
|
}
|
|
schedulePoll(delay)
|
|
}
|
|
|
|
/** Resolve the thumbnail to an absolute URL and fetch it once per URL change. */
|
|
private fun ensureArtwork(base: String?, thumbnail: String?) {
|
|
val absolute = when {
|
|
thumbnail.isNullOrBlank() -> null
|
|
thumbnail.startsWith("http://") || thumbnail.startsWith("https://") -> thumbnail
|
|
base.isNullOrBlank() -> null
|
|
thumbnail.startsWith("/") -> base + thumbnail
|
|
else -> "$base/$thumbnail"
|
|
}
|
|
if (absolute == artworkUrl) return
|
|
artworkUrl = absolute
|
|
artworkBytes = null
|
|
if (absolute == null) return
|
|
|
|
Net.client.newCall(Net.get(absolute)).enqueue(object : Callback {
|
|
override fun onFailure(call: Call, e: IOException) { /* no artwork */ }
|
|
|
|
override fun onResponse(call: Call, response: Response) {
|
|
val bytes = response.use { resp ->
|
|
val body = resp.body
|
|
if (!resp.isSuccessful || body == null || body.contentLength() > MAX_ARTWORK_BYTES) {
|
|
null
|
|
} else {
|
|
try {
|
|
body.bytes().takeIf { it.isNotEmpty() && it.size <= MAX_ARTWORK_BYTES }
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
}
|
|
if (bytes != null) {
|
|
handler.post {
|
|
if (artworkUrl == absolute) {
|
|
artworkBytes = bytes
|
|
// Re-render the notification with artwork right away.
|
|
schedulePoll(0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun post(path: String, json: String = "{}") {
|
|
val base = activeBase() ?: return
|
|
Net.client.newCall(Net.postJson(base + path, json)).enqueue(object : Callback {
|
|
override fun onFailure(call: Call, e: IOException) {
|
|
handler.post { schedulePoll(POLL_AFTER_COMMAND_MS) }
|
|
}
|
|
|
|
override fun onResponse(call: Call, response: Response) {
|
|
response.close()
|
|
handler.post { schedulePoll(POLL_AFTER_COMMAND_MS) }
|
|
}
|
|
})
|
|
}
|
|
}
|