diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd4cc62..b1f5708 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 7cd8615..af4b710 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/build.gradle b/app/build.gradle index 33fb1b7..132398c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d62b056..257e22d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,9 +1,12 @@ - + + + + + + + + + + diff --git a/app/src/main/java/pro/relaytv/AppSettings.kt b/app/src/main/java/pro/relaytv/AppSettings.kt new file mode 100644 index 0000000..c9c61a3 --- /dev/null +++ b/app/src/main/java/pro/relaytv/AppSettings.kt @@ -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() + } +} diff --git a/app/src/main/java/pro/relaytv/MainActivity.kt b/app/src/main/java/pro/relaytv/MainActivity.kt index b8f4ad7..3891150 100644 --- a/app/src/main/java/pro/relaytv/MainActivity.kt +++ b/app/src/main/java/pro/relaytv/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/pro/relaytv/MediaControlService.kt b/app/src/main/java/pro/relaytv/MediaControlService.kt new file mode 100644 index 0000000..aa28cd5 --- /dev/null +++ b/app/src/main/java/pro/relaytv/MediaControlService.kt @@ -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) } + } + }) + } +} diff --git a/app/src/main/java/pro/relaytv/RelayRemotePlayer.kt b/app/src/main/java/pro/relaytv/RelayRemotePlayer.kt new file mode 100644 index 0000000..25a43a9 --- /dev/null +++ b/app/src/main/java/pro/relaytv/RelayRemotePlayer.kt @@ -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() +} diff --git a/app/src/main/java/pro/relaytv/RemoteStatus.kt b/app/src/main/java/pro/relaytv/RemoteStatus.kt new file mode 100644 index 0000000..1ade5dd --- /dev/null +++ b/app/src/main/java/pro/relaytv/RemoteStatus.kt @@ -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) }, + ) + } + } +} diff --git a/app/src/main/java/pro/relaytv/SettingsActivity.kt b/app/src/main/java/pro/relaytv/SettingsActivity.kt index c4a93a7..1caec00 100644 --- a/app/src/main/java/pro/relaytv/SettingsActivity.kt +++ b/app/src/main/java/pro/relaytv/SettingsActivity.kt @@ -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(R.id.appBar) + applyWindowInsets(appBar) + + val toolbar = findViewById(R.id.toolbar) + toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material) + toolbar.setNavigationOnClickListener { finish() } + + val mediaSwitch = findViewById(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(R.id.rowMediaControls).setOnClickListener { + mediaSwitch.toggle() + } + + findViewById(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) } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..3eff47f --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index c539af2..7e5f5f8 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -11,6 +11,10 @@ android:title="@string/reload" android:icon="@android:drawable/ic_popup_sync" app:showAsAction="ifRoom" /> + Privacy policy https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md No browser available to open the privacy policy. + Settings + Media controls + Show system media controls (lock screen and quick settings) for playback on the active server + Manage servers + Add, edit, or switch RelayTV servers diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 08c82a9..bb0e7da 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,7 +9,7 @@ @color/relaytv_surface -