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
+20 -2
View File
@@ -11,8 +11,22 @@ android {
applicationId = "pro.relaytv"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "1.2.3"
versionCode = 6
versionName = "1.3.0"
}
// Release signing is configured via gradle.properties (see todo.md /
// docs/RELEASE_CHECKLIST.md); without those properties the release
// build stays unsigned so CI and local debug workflows keep working.
signingConfigs {
release {
if (project.hasProperty("MYAPP_RELEASE_STORE_FILE")) {
storeFile = file(MYAPP_RELEASE_STORE_FILE)
storePassword = MYAPP_RELEASE_STORE_PASSWORD
keyAlias = MYAPP_RELEASE_KEY_ALIAS
keyPassword = MYAPP_RELEASE_KEY_PASSWORD
}
}
}
buildTypes {
@@ -23,6 +37,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (project.hasProperty("MYAPP_RELEASE_STORE_FILE")) {
signingConfig = signingConfigs.release
}
}
debug {
minifyEnabled = false
@@ -46,4 +63,5 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "androidx.work:work-runtime-ktx:2.9.1"
implementation "androidx.media3:media3-session:1.7.1"
}
+16 -1
View File
@@ -1,9 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<application
android:allowBackup="false"
@@ -91,6 +94,18 @@
android:name=".SettingsActivity"
android:exported="false"
android:theme="@style/Theme.RelayTV.NoActionBar"/>
<!-- Must be exported so system media controllers can bind; media3
gates access itself via MediaSession's connection callbacks. -->
<service
android:name=".MediaControlService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,18 @@
package pro.relaytv
import android.content.Context
/** App-level feature settings (separate from the server list in [HostStore]). */
object AppSettings {
private const val PREF = "relaytv_prefs"
private const val KEY_MEDIA_CONTROLS = "media_controls_enabled"
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE)
fun isMediaControlsEnabled(ctx: Context): Boolean =
prefs(ctx).getBoolean(KEY_MEDIA_CONTROLS, true)
fun setMediaControlsEnabled(ctx: Context, enabled: Boolean) {
prefs(ctx).edit().putBoolean(KEY_MEDIA_CONTROLS, enabled).apply()
}
}
@@ -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()
}
@@ -0,0 +1,221 @@
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) }
}
})
}
}
@@ -0,0 +1,203 @@
package pro.relaytv
import android.os.Looper
import android.os.SystemClock
import androidx.media3.common.C
import androidx.media3.common.DeviceInfo
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
/**
* A [SimpleBasePlayer] that mirrors playback happening on the RelayTV server.
* It never plays audio locally: state comes from polling /status and every
* transport control is forwarded to the server over HTTP via [Listener].
*/
@UnstableApi
class RelayRemotePlayer(
looper: Looper,
private val listener: Listener,
) : SimpleBasePlayer(looper) {
interface Listener {
fun onSetPaused(paused: Boolean)
fun onNext()
fun onPrevious()
fun onSeekTo(seconds: Double)
fun onStop()
fun onSetVolume(percent: Int)
}
private var status: RemoteStatus = RemoteStatus.IDLE
private var serverName: String = "RelayTV"
private var artworkBytes: ByteArray? = null
// Anchor for extrapolating the playback position between polls.
private var anchorPositionMs: Long = 0
private var anchorElapsedRealtime: Long = SystemClock.elapsedRealtime()
/** Must be called on the application looper. */
fun updateStatus(newStatus: RemoteStatus, newServerName: String, artwork: ByteArray?) {
status = newStatus
serverName = newServerName
artworkBytes = artwork
setPositionAnchor((newStatus.positionSec ?: 0.0) * 1000.0)
invalidateState()
}
fun isActive(): Boolean = status.active
private fun setPositionAnchor(positionMs: Double) {
anchorPositionMs = positionMs.toLong().coerceAtLeast(0)
anchorElapsedRealtime = SystemClock.elapsedRealtime()
}
private fun extrapolatedPositionMs(): Long {
var pos = anchorPositionMs
if (status.playing && !status.paused) {
pos += SystemClock.elapsedRealtime() - anchorElapsedRealtime
}
val durationMs = status.durationSec?.let { (it * 1000).toLong() }
if (durationMs != null && durationMs > 0) {
pos = pos.coerceAtMost(durationMs)
}
return pos.coerceAtLeast(0)
}
override fun getState(): State {
val s = status
if (!s.active) {
return State.Builder()
.setAvailableCommands(Player.Commands.EMPTY)
.setPlaybackState(Player.STATE_IDLE)
.build()
}
val durationUs = s.durationSec
?.takeIf { it > 0 }
?.let { (it * C.MICROS_PER_SECOND).toLong() }
?: C.TIME_UNSET
val seekable = durationUs != C.TIME_UNSET
val metadata = MediaMetadata.Builder()
.setTitle(s.title ?: "Playing on $serverName")
.setArtist(serverName)
.apply {
artworkBytes?.let { setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
}
.build()
// Placeholder neighbours let controllers issue next/previous, which we
// forward to the server's queue instead of a local playlist.
val previousItem = MediaItemData.Builder("previous")
.setMediaItem(MediaItem.Builder().setMediaId("previous").build())
.build()
val currentItem = MediaItemData.Builder("current")
.setMediaItem(
MediaItem.Builder()
.setMediaId("current")
.setMediaMetadata(metadata)
.build()
)
.setDurationUs(durationUs)
.setIsSeekable(seekable)
.build()
val nextItem = MediaItemData.Builder("next")
.setMediaItem(MediaItem.Builder().setMediaId("next").build())
.build()
val commands = Player.Commands.Builder()
.addAll(
Player.COMMAND_PLAY_PAUSE,
Player.COMMAND_STOP,
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
Player.COMMAND_GET_TIMELINE,
Player.COMMAND_GET_METADATA,
)
.addIf(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, seekable)
.addIf(Player.COMMAND_SEEK_BACK, seekable)
.addIf(Player.COMMAND_SEEK_FORWARD, seekable)
.addIf(Player.COMMAND_GET_DEVICE_VOLUME, s.volumePercent != null)
.addIf(Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS, s.volumePercent != null)
.addIf(Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS, s.volumePercent != null)
.build()
val builder = State.Builder()
.setAvailableCommands(commands)
.setPlaylist(listOf(previousItem, currentItem, nextItem))
.setCurrentMediaItemIndex(1)
.setPlaybackState(Player.STATE_READY)
.setPlayWhenReady(!s.paused, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setContentPositionMs { extrapolatedPositionMs() }
.setSeekBackIncrementMs(10_000)
.setSeekForwardIncrementMs(30_000)
.setMaxSeekToPreviousPositionMs(3_000)
if (s.volumePercent != null) {
builder
.setDeviceInfo(
DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE)
.setMinVolume(0)
.setMaxVolume(100)
.build()
)
.setDeviceVolume(s.volumePercent)
}
return builder.build()
}
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
// Optimistic local update; the next poll confirms the real state.
status = status.copy(paused = !playWhenReady, playing = true)
setPositionAnchor(extrapolatedPositionMs().toDouble())
listener.onSetPaused(!playWhenReady)
return Futures.immediateVoidFuture()
}
override fun handleSeek(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int,
): ListenableFuture<*> {
when {
mediaItemIndex > 1 -> listener.onNext()
mediaItemIndex < 1 -> listener.onPrevious()
else -> {
val target = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0)
setPositionAnchor(target.toDouble())
listener.onSeekTo(target / 1000.0)
}
}
return Futures.immediateVoidFuture()
}
override fun handleStop(): ListenableFuture<*> {
status = RemoteStatus.IDLE
listener.onStop()
return Futures.immediateVoidFuture()
}
override fun handleSetDeviceVolume(deviceVolume: Int, flags: Int): ListenableFuture<*> {
val vol = deviceVolume.coerceIn(0, 100)
status = status.copy(volumePercent = vol)
listener.onSetVolume(vol)
return Futures.immediateVoidFuture()
}
override fun handleIncreaseDeviceVolume(flags: Int): ListenableFuture<*> =
handleSetDeviceVolume((status.volumePercent ?: 0) + 5, flags)
override fun handleDecreaseDeviceVolume(flags: Int): ListenableFuture<*> =
handleSetDeviceVolume((status.volumePercent ?: 0) - 5, flags)
override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture()
}
@@ -0,0 +1,64 @@
package pro.relaytv
import org.json.JSONObject
/**
* Snapshot of the active server's playback state, parsed from GET /status.
* Parsing is intentionally defensive across RelayTV server versions
* (mirrors the field fallbacks used by the Home Assistant integration).
*/
data class RemoteStatus(
val playing: Boolean = false,
val paused: Boolean = false,
val positionSec: Double? = null,
val durationSec: Double? = null,
val title: String? = null,
val thumbnail: String? = null,
val volumePercent: Int? = null,
) {
val active: Boolean get() = playing || paused
companion object {
val IDLE = RemoteStatus()
fun parse(body: String): RemoteStatus {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return IDLE
}
fun num(vararg keys: String): Double? {
for (k in keys) {
if (o.has(k) && !o.isNull(k)) {
val v = o.optDouble(k, Double.NaN)
if (!v.isNaN()) return v
}
}
return null
}
val np = o.optJSONObject("now_playing") ?: o.optJSONObject("media")
fun str(obj: JSONObject?, vararg keys: String): String? {
if (obj == null) return null
for (k in keys) {
val v = obj.optString(k, "")
if (v.isNotBlank() && v != "null") return v
}
return null
}
return RemoteStatus(
playing = o.optBoolean("playing") || o.optBoolean("is_playing") || o.optBoolean("play"),
paused = o.optBoolean("paused") || o.optBoolean("is_paused") || o.optBoolean("pause"),
positionSec = num("position", "pos", "time"),
durationSec = num("duration", "len", "total"),
title = str(np, "title", "name") ?: str(o, "title"),
thumbnail = str(np, "thumbnail_local", "thumbnail", "thumb")
?: str(o, "thumbnail_local", "thumbnail", "thumb"),
volumePercent = num("volume", "vol")?.let { it.toInt().coerceIn(0, 100) },
)
}
}
}
@@ -1,18 +1,68 @@
package pro.relaytv
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.materialswitch.MaterialSwitch
/**
* Kept for backward compatibility (older deep links / flows).
* This activity simply opens the server picker inside MainActivity.
*/
class SettingsActivity : AppCompatActivity() {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(android.content.Intent(this, MainActivity::class.java).apply {
putExtra("open_servers", true)
})
finish()
setContentView(R.layout.activity_settings)
val appBar = findViewById<AppBarLayout>(R.id.appBar)
applyWindowInsets(appBar)
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
toolbar.setNavigationOnClickListener { finish() }
val mediaSwitch = findViewById<MaterialSwitch>(R.id.switchMediaControls)
mediaSwitch.isChecked = AppSettings.isMediaControlsEnabled(this)
mediaSwitch.setOnCheckedChangeListener { _, checked ->
AppSettings.setMediaControlsEnabled(this, checked)
val svc = Intent(this, MediaControlService::class.java)
if (checked) {
runCatching { startService(svc) }
} else {
stopService(svc)
}
}
findViewById<LinearLayout>(R.id.rowMediaControls).setOnClickListener {
mediaSwitch.toggle()
}
findViewById<LinearLayout>(R.id.rowManageServers).setOnClickListener {
startActivity(Intent(this, MainActivity::class.java).apply {
putExtra("open_servers", true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
})
finish()
}
}
private fun applyWindowInsets(appBar: AppBarLayout) {
val initialTop = appBar.paddingTop
val initialLeft = appBar.paddingLeft
val initialRight = appBar.paddingRight
ViewCompat.setOnApplyWindowInsetsListener(appBar) { view: View, insets: WindowInsetsCompat ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
initialLeft + systemBars.left,
initialTop + systemBars.top,
initialRight + systemBars.right,
view.paddingBottom
)
insets
}
ViewCompat.requestApplyInsets(appBar)
}
}
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/settings" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp">
<LinearLayout
android:id="@+id/rowMediaControls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?attr/selectableItemBackground"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/media_controls_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/media_controls_summary"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="#B3FFFFFF" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchMediaControls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowManageServers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/selectableItemBackground"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_manage_servers"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/settings_manage_servers_summary"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="#B3FFFFFF" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
+4
View File
@@ -11,6 +11,10 @@
android:title="@string/reload"
android:icon="@android:drawable/ic_popup_sync"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_settings"
android:title="@string/settings"
app:showAsAction="never" />
<item
android:id="@+id/action_privacy"
android:title="@string/privacy_policy"
+5
View File
@@ -34,4 +34,9 @@
<string name="privacy_policy">Privacy policy</string>
<string name="privacy_policy_url">https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md</string>
<string name="privacy_open_failed">No browser available to open the privacy policy.</string>
<string name="settings">Settings</string>
<string name="media_controls_title">Media controls</string>
<string name="media_controls_summary">Show system media controls (lock screen and quick settings) for playback on the active server</string>
<string name="settings_manage_servers">Manage servers</string>
<string name="settings_manage_servers_summary">Add, edit, or switch RelayTV servers</string>
</resources>
+1 -1
View File
@@ -9,7 +9,7 @@
<item name="android:colorBackground">@color/relaytv_surface</item>
</style>
<style name="Theme.RelayTV.NoActionBar" parent="Theme.Material3.DayNight.NoActionBar" />
<style name="Theme.RelayTV.NoActionBar" parent="Theme.RelayTV" />
<style name="Theme.RelayTV.Share" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>