Author SHA1 Message Date
markandClaude Fable 5 1a0711319e ci: bump release actions to Node 24 majors (#2)
Update checkout@v7, setup-java@v5, setup-android@v4, upload-artifact@v7,
and action-gh-release@v3 — all target the Node 24 runtime, clearing the
Node 20 deprecation warning on the release workflow. Inputs we use are
unchanged; the app has no Node dependency (Android/Kotlin + JDK 17), so
no application changes are needed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:59:56 -05:00
markandClaude Fable 5 edb367cca3 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>
2026-07-06 19:45:34 -05:00
17 changed files with 778 additions and 19 deletions
+29 -5
View File
@@ -18,17 +18,17 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses >/dev/null
@@ -42,9 +42,33 @@ jobs:
- name: Build debug APK
run: ./scripts/build-debug.sh assembleDebug lint
- name: Decode release keystore
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
run: |
if [ -z "$RELEASE_KEYSTORE_BASE64" ]; then
echo "::error::RELEASE_KEYSTORE_BASE64 secret is not set; cannot sign the release."
exit 1
fi
echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
- name: Build release bundle
env:
ORG_GRADLE_PROJECT_MYAPP_RELEASE_STORE_FILE: ${{ runner.temp }}/release.keystore
ORG_GRADLE_PROJECT_MYAPP_RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
ORG_GRADLE_PROJECT_MYAPP_RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
ORG_GRADLE_PROJECT_MYAPP_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: ./scripts/build-release.sh bundleRelease lintRelease
- name: Verify the release bundle is signed
run: |
if jarsigner -verify "app/build/outputs/bundle/release/app-release.aab" | grep -q "jar verified"; then
echo "Release bundle is signed."
else
echo "::error::Release bundle is NOT signed."
exit 1
fi
- name: Prepare release assets
run: |
mkdir -p dist
@@ -52,14 +76,14 @@ jobs:
cp app/build/outputs/bundle/release/app-release.aab "dist/relaytv-${GITHUB_REF_NAME}.aab"
- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: relaytv-${{ github.ref_name }}
path: dist/*
if-no-files-found: error
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |
+1
View File
@@ -19,6 +19,7 @@ https://buymeacoffee.com/relaytv
- Health verification before saving a server
- LAN auto-discovery of `_relaytv._tcp` servers using Android NSD / mDNS
- Embedded RelayTV `/ui` WebView access
- System media controls (lock screen / quick settings) mirroring playback on the active server
- Reconnect and heartbeat recovery
- Dedicated Android share targets:
- `RelayTV Queue``POST /smart`
+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>
+7 -2
View File
@@ -1,6 +1,6 @@
# RelayTV Android Privacy Policy
Last updated: April 3, 2026
Last updated: July 6, 2026
## Summary
@@ -19,6 +19,8 @@ RelayTV Android may process the following categories of data in order to operate
- RelayTV service advertisements discovered on your local network via mDNS / NSD
4. Device-local notification data
- Notification text used to confirm a queue or play action
5. Playback status data
- Now-playing metadata (title, artwork, playback position, volume) fetched from your selected RelayTV server to render system media controls
## How The App Uses Data
@@ -29,6 +31,7 @@ RelayTV Android uses the data above only to:
3. Send shared links to RelayTV endpoints such as `/smart` or `/play_now`
4. Discover compatible RelayTV servers on your local network
5. Show local notifications about share results and reconnect state
6. Show optional system media controls (lock screen / quick settings) that mirror and control playback on your selected server
## Data Storage
@@ -57,7 +60,9 @@ RelayTV Android may request or use these permissions:
3. `CHANGE_WIFI_MULTICAST_STATE`
- Required for local network RelayTV discovery on some devices
4. `POST_NOTIFICATIONS`
- Required on newer Android versions to show queue/play result notifications
- Required on newer Android versions to show queue/play result notifications and the media controls notification
5. `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`
- Required to keep the optional media controls notification active while your RelayTV server is playing
## Children
+9
View File
@@ -29,10 +29,14 @@ Use this checklist before cutting a public release or uploading a new Play Conso
- `app/build/outputs/bundle/release/app-release.aab`
3. Use Play App Signing in Play Console.
4. Complete the Data safety form for this release.
- The media controls feature adds no off-device data collection; no Data safety changes are expected.
5. Provide a valid support email in Play Console.
6. Publish a public privacy policy URL.
- This repo includes a draft policy in `docs/PRIVACY_POLICY.md`.
- The in-app privacy menu points to the GitHub-hosted copy on `main`.
7. Complete the Foreground service permissions declaration (App content -> Foreground service permissions).
- Declares `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, used by the media controls notification that mirrors playback on the user's RelayTV server.
- Requires a short demo video showing the media controls notification while a RelayTV server is playing.
## App-Specific Verification
@@ -43,6 +47,11 @@ Use this checklist before cutting a public release or uploading a new Play Conso
- `RelayTV Play` -> `/play_now`
4. Confirm the WebView reconnects after the RelayTV server restarts or the network drops.
5. Confirm the privacy menu opens the published policy URL.
6. Confirm system media controls appear while the active server is playing:
- Play/pause, next/previous, seek, and the volume slider control the server.
- The notification disappears after the server goes idle.
- The Settings toggle ("Media controls") removes and restores the controls.
7. Confirm the Settings screen opens from the toolbar menu and "Manage servers" opens the server picker.
## Signing And Distribution
+1
View File
@@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g
android.useAndroidX=true
android.enableJetifier=true
android.suppressUnsupportedCompileSdk=35