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>
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.suppressUnsupportedCompileSdk=35
|
||||
|
||||
Reference in New Issue
Block a user