feat: system media controls (MediaSession) for the active server (#1)

* 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>
This commit is contained in:
2026-07-06 19:45:34 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 0967082e1c
commit edb367cca3
17 changed files with 773 additions and 14 deletions
@@ -127,6 +127,10 @@ class MainActivity : AppCompatActivity() {
loadActiveServer(forcePickerOnFailure = false, manualRefresh = true)
true
}
R.id.action_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.action_privacy -> {
openPrivacyPolicy()
true
@@ -184,6 +188,14 @@ class MainActivity : AppCompatActivity() {
loadServerBase(base.trimEnd('/'), forcePickerOnFailure = true, manualRefresh = false)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.getBooleanExtra("open_servers", false)) {
showServerPicker()
}
}
private fun buildFileChooserIntent(fileChooserParams: WebChromeClient.FileChooserParams): Intent {
val acceptTypes = fileChooserParams.acceptTypes
.orEmpty()
@@ -263,11 +275,19 @@ class MainActivity : AppCompatActivity() {
super.onResume()
isInForeground = true
registerNetworkCallback()
ensureMediaControlService()
if (!activeBaseUrl.isNullOrBlank()) {
scheduleHeartbeat(2_000)
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun ensureMediaControlService() {
if (!AppSettings.isMediaControlsEnabled(this)) return
if (HostStore.getActiveBaseUrl(this).isNullOrBlank()) return
runCatching { startService(Intent(this, MediaControlService::class.java)) }
}
override fun onPause() {
super.onPause()
isInForeground = false
@@ -341,6 +361,8 @@ class MainActivity : AppCompatActivity() {
}
val recovered = consecutiveHealthFailures > 0
consecutiveHealthFailures = 0
// Keep the media-controls service alive while we can see the server.
ensureMediaControlService()
if (recovered && !manualRefresh) {
Toast.makeText(this@MainActivity, "Reconnected to RelayTV.", Toast.LENGTH_SHORT).show()
}