Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60076175f1 | ||
|
|
9f46eecf0e |
@@ -42,9 +42,33 @@ jobs:
|
|||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./scripts/build-debug.sh assembleDebug lint
|
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
|
- 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
|
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
|
- name: Prepare release assets
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ https://buymeacoffee.com/relaytv
|
|||||||
- Health verification before saving a server
|
- Health verification before saving a server
|
||||||
- LAN auto-discovery of `_relaytv._tcp` servers using Android NSD / mDNS
|
- LAN auto-discovery of `_relaytv._tcp` servers using Android NSD / mDNS
|
||||||
- Embedded RelayTV `/ui` WebView access
|
- Embedded RelayTV `/ui` WebView access
|
||||||
|
- System media controls (lock screen / quick settings) mirroring playback on the active server
|
||||||
- Reconnect and heartbeat recovery
|
- Reconnect and heartbeat recovery
|
||||||
- Dedicated Android share targets:
|
- Dedicated Android share targets:
|
||||||
- `RelayTV Queue` → `POST /smart`
|
- `RelayTV Queue` → `POST /smart`
|
||||||
|
|||||||
+20
-2
@@ -11,8 +11,22 @@ android {
|
|||||||
applicationId = "pro.relaytv"
|
applicationId = "pro.relaytv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "1.2.3"
|
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 {
|
buildTypes {
|
||||||
@@ -23,6 +37,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
if (project.hasProperty("MYAPP_RELEASE_STORE_FILE")) {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
minifyEnabled = false
|
minifyEnabled = false
|
||||||
@@ -46,4 +63,5 @@ dependencies {
|
|||||||
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<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.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -91,6 +94,18 @@
|
|||||||
android:name=".SettingsActivity"
|
android:name=".SettingsActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.RelayTV.NoActionBar"/>
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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)
|
loadActiveServer(forcePickerOnFailure = false, manualRefresh = true)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_settings -> {
|
||||||
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_privacy -> {
|
R.id.action_privacy -> {
|
||||||
openPrivacyPolicy()
|
openPrivacyPolicy()
|
||||||
true
|
true
|
||||||
@@ -184,6 +188,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
loadServerBase(base.trimEnd('/'), forcePickerOnFailure = true, manualRefresh = false)
|
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 {
|
private fun buildFileChooserIntent(fileChooserParams: WebChromeClient.FileChooserParams): Intent {
|
||||||
val acceptTypes = fileChooserParams.acceptTypes
|
val acceptTypes = fileChooserParams.acceptTypes
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
@@ -263,11 +275,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
registerNetworkCallback()
|
registerNetworkCallback()
|
||||||
|
ensureMediaControlService()
|
||||||
if (!activeBaseUrl.isNullOrBlank()) {
|
if (!activeBaseUrl.isNullOrBlank()) {
|
||||||
scheduleHeartbeat(2_000)
|
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() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
isInForeground = false
|
isInForeground = false
|
||||||
@@ -341,6 +361,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
val recovered = consecutiveHealthFailures > 0
|
val recovered = consecutiveHealthFailures > 0
|
||||||
consecutiveHealthFailures = 0
|
consecutiveHealthFailures = 0
|
||||||
|
// Keep the media-controls service alive while we can see the server.
|
||||||
|
ensureMediaControlService()
|
||||||
if (recovered && !manualRefresh) {
|
if (recovered && !manualRefresh) {
|
||||||
Toast.makeText(this@MainActivity, "Reconnected to RelayTV.", Toast.LENGTH_SHORT).show()
|
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
|
package pro.relaytv
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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() {
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
startActivity(android.content.Intent(this, MainActivity::class.java).apply {
|
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)
|
putExtra("open_servers", true)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
})
|
})
|
||||||
finish()
|
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:title="@string/reload"
|
||||||
android:icon="@android:drawable/ic_popup_sync"
|
android:icon="@android:drawable/ic_popup_sync"
|
||||||
app:showAsAction="ifRoom" />
|
app:showAsAction="ifRoom" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_settings"
|
||||||
|
android:title="@string/settings"
|
||||||
|
app:showAsAction="never" />
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_privacy"
|
android:id="@+id/action_privacy"
|
||||||
android:title="@string/privacy_policy"
|
android:title="@string/privacy_policy"
|
||||||
|
|||||||
@@ -34,4 +34,9 @@
|
|||||||
<string name="privacy_policy">Privacy policy</string>
|
<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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<item name="android:colorBackground">@color/relaytv_surface</item>
|
<item name="android:colorBackground">@color/relaytv_surface</item>
|
||||||
|
|
||||||
</style>
|
</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">
|
<style name="Theme.RelayTV.Share" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="android:windowIsTranslucent">true</item>
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
<item name="android:windowBackground">@android:color/transparent</item>
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RelayTV Android Privacy Policy
|
# RelayTV Android Privacy Policy
|
||||||
|
|
||||||
Last updated: April 3, 2026
|
Last updated: July 6, 2026
|
||||||
|
|
||||||
## Summary
|
## 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
|
- RelayTV service advertisements discovered on your local network via mDNS / NSD
|
||||||
4. Device-local notification data
|
4. Device-local notification data
|
||||||
- Notification text used to confirm a queue or play action
|
- 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
|
## 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`
|
3. Send shared links to RelayTV endpoints such as `/smart` or `/play_now`
|
||||||
4. Discover compatible RelayTV servers on your local network
|
4. Discover compatible RelayTV servers on your local network
|
||||||
5. Show local notifications about share results and reconnect state
|
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
|
## Data Storage
|
||||||
|
|
||||||
@@ -57,7 +60,9 @@ RelayTV Android may request or use these permissions:
|
|||||||
3. `CHANGE_WIFI_MULTICAST_STATE`
|
3. `CHANGE_WIFI_MULTICAST_STATE`
|
||||||
- Required for local network RelayTV discovery on some devices
|
- Required for local network RelayTV discovery on some devices
|
||||||
4. `POST_NOTIFICATIONS`
|
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
|
## 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`
|
- `app/build/outputs/bundle/release/app-release.aab`
|
||||||
3. Use Play App Signing in Play Console.
|
3. Use Play App Signing in Play Console.
|
||||||
4. Complete the Data safety form for this release.
|
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.
|
5. Provide a valid support email in Play Console.
|
||||||
6. Publish a public privacy policy URL.
|
6. Publish a public privacy policy URL.
|
||||||
- This repo includes a draft policy in `docs/PRIVACY_POLICY.md`.
|
- This repo includes a draft policy in `docs/PRIVACY_POLICY.md`.
|
||||||
- The in-app privacy menu points to the GitHub-hosted copy on `main`.
|
- 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
|
## 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`
|
- `RelayTV Play` -> `/play_now`
|
||||||
4. Confirm the WebView reconnects after the RelayTV server restarts or the network drops.
|
4. Confirm the WebView reconnects after the RelayTV server restarts or the network drops.
|
||||||
5. Confirm the privacy menu opens the published policy URL.
|
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
|
## Signing And Distribution
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.suppressUnsupportedCompileSdk=35
|
android.suppressUnsupportedCompileSdk=35
|
||||||
|
|||||||
Reference in New Issue
Block a user