Initial commit: LG Slim, a private LAN-only remote for LG webOS TVs

Rebuilt from the LG ThinQ app's TV features without the login, cloud,
ads, trackers or analytics. Remote, touchpad, apps, sources, keyboard,
private listening, Wake-on-LAN with live power state, DLNA photo and
video casting, quick-settings tile and widget, dark and light themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zakaria
2026-10-28 01:16:49 -04:00
commit a590779e31
62 changed files with 4331 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "app.tvremote"
compileSdk = 35
defaultConfig {
applicationId = "app.tvremote"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Sideload build: signed with the local debug key.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
// Don't embed the Google Play dependency-metadata blob in the APK.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// The only network library. Locked to LAN addresses in net/Lan.kt.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
+3
View File
@@ -0,0 +1,3 @@
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network access is used only for the TV on your LAN (enforced in net/Lan.kt). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Private listening keeps playing with the screen off. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".TvApp"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.TvRemote">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".audio.ListenService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<service
android:name=".widget.ListenTile"
android:exported="true"
android:icon="@drawable/ic_headphones"
android:label="Private listening"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<receiver
android:name=".widget.RemoteWidget"
android:exported="true"
android:label="LG Slim">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_remote" />
</receiver>
<receiver
android:name=".widget.WidgetActionReceiver"
android:exported="false" />
</application>
</manifest>
+33
View File
@@ -0,0 +1,33 @@
package app.tvremote
import android.app.Application
import android.content.ComponentName
import android.service.quicksettings.TileService
import app.tvremote.audio.ListenService
import app.tvremote.data.Connection
import app.tvremote.widget.ListenTile
import kotlinx.coroutines.launch
import app.tvremote.data.TvStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class TvApp : Application() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
lateinit var store: TvStore
private set
lateinit var connection: Connection
private set
override fun onCreate() {
super.onCreate()
store = TvStore(this)
connection = Connection(this, store, scope)
// Keep the quick-settings tile in step with private listening.
scope.launch {
ListenService.status.collect {
runCatching { TileService.requestListeningState(this@TvApp, ComponentName(this@TvApp, ListenTile::class.java)) }
}
}
}
}
@@ -0,0 +1,281 @@
package app.tvremote.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.net.wifi.WifiManager
import android.os.Build
import android.os.IBinder
import app.tvremote.R
import app.tvremote.TvApp
import app.tvremote.net.Lan
import app.tvremote.net.TvException
import app.tvremote.net.WebOsClient
import app.tvremote.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import kotlin.coroutines.coroutineContext
/**
* Private listening: the TV streams its audio to this phone over the LAN.
* 1. Probe secondscreen.gateway/test/secure until the TV's SSG binds our client key.
* 2. soundshare/startStreamOut with our key, LAN IP and port 9000.
* 3. Read raw PCM from TV:9000 (48 kHz, 16-bit, stereo, big-endian), byte-swap, play.
* 4. soundshare/stopStreamOut when done.
*/
class ListenService : Service() {
sealed interface Status {
data object Off : Status
data class Starting(val step: String) : Status
data object Playing : Status
data class Failed(val reason: String) : Status
}
companion object {
private const val ACTION_STOP = "stop"
private const val CHANNEL = "listen"
private const val PORT = 9000
private const val RATE = 48_000
private val _status = MutableStateFlow<Status>(Status.Off)
val status: StateFlow<Status> = _status
fun start(context: Context) =
context.startForegroundService(Intent(context, ListenService::class.java))
fun stop(context: Context) =
context.startService(Intent(context, ListenService::class.java).setAction(ACTION_STOP))
private val HINTS = mapOf(
"MAX_CONNECTION" to "Too many listeners are attached to this TV.",
"MINIMAL_APP" to "The TV is on a setup or first-launch screen.",
"MIRACAST_PLAYING" to "Screen mirroring is running on the TV.",
"MIRACAST_TX_PLAYING" to "The TV is casting to another device.",
"BT_SINK" to "The TV is sending audio to Bluetooth already.",
"NOT_AVAILABLE" to "This TV doesn't allow private listening right now.",
)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var wifiLock: WifiManager.WifiLock? = null
@Volatile private var audioSocket: Socket? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
return START_NOT_STICKY
}
if (job?.isActive == true) return START_NOT_STICKY
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(1, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(1, notification())
}
} catch (e: Exception) {
// Android can refuse to start media playback from the background (e.g. some tile taps).
_status.value = Status.Failed("Open the app to start private listening")
stopSelf()
return START_NOT_STICKY
}
_status.value = Status.Starting("Connecting to the TV")
acquireWifiLock()
job = scope.launch {
// Started from the tile or widget, the app may not be connected yet.
val client = (application as TvApp).connection.ensureConnected()
if (client == null) {
_status.value = Status.Failed("Couldn't reach the TV")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return@launch
}
try {
stream(client)
_status.value = Status.Off
} catch (e: Exception) {
if (isActive) _status.value = Status.Failed(explain(e))
} finally {
withContext(NonCancellable) { stopStream(client) }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
private fun shutdown() {
job?.cancel()
runCatching { audioSocket?.close() } // unblocks the read loop
_status.value = Status.Off
if (job == null) stopSelf()
}
override fun onDestroy() {
scope.cancel()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
private suspend fun stream(client: WebOsClient) {
val key = client.clientKey ?: throw TvException("Not paired")
_status.value = Status.Starting("Authorizing with the TV")
retry { client.request("com.webos.service.secondscreen.gateway/test/secure") }
_status.value = Status.Starting("Starting the audio stream")
val param = JSONObject().put("key", key).put("addr", Lan.routeTo(client.ip)).put("port", PORT.toString())
retry { client.request("com.webos.service.soundshare/startStreamOut", JSONObject().put("deviceParam", param)) }
// The TV only accepts on :9000 while its stream engine is fed, which starts when
// sound plays. A volume nudge makes it beep and opens the port.
_status.value = Status.Starting("Waiting for audio")
var socket: Socket? = null
for (attempt in 0 until 60) {
try {
socket = Socket().apply { connect(InetSocketAddress(client.ip, PORT), 1_000) }
break
} catch (_: IOException) {
if (attempt % 4 == 0) runCatching {
client.request("audio/volumeUp")
delay(800)
client.request("audio/volumeDown")
}
delay(250)
}
}
socket ?: throw TvException("The TV never opened its audio port. Is it muted?")
audioSocket = socket
val minBuf = AudioTrack.getMinBufferSize(RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
)
.setBufferSizeInBytes(maxOf(minBuf * 2, RATE * 4 / 10))
.setTransferMode(AudioTrack.MODE_STREAM)
.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) }
.build()
try {
socket.use { s ->
val input = s.getInputStream()
val buf = ByteArray(16_384)
var have = 0
track.play()
_status.value = Status.Playing
while (coroutineContext.isActive) {
val n = input.read(buf, have, buf.size - have)
if (n < 0) break
have += n
val aligned = have - have % 4 // whole stereo frames only
var i = 0
while (i < aligned) {
val t = buf[i]; buf[i] = buf[i + 1]; buf[i + 1] = t
i += 2
}
track.write(buf, 0, aligned)
val rest = have - aligned
if (rest > 0) System.arraycopy(buf, aligned, buf, 0, rest)
have = rest
}
}
} finally {
track.runCatching { stop() }
track.release()
}
}
private suspend fun stopStream(client: WebOsClient) {
val key = client.clientKey ?: return
runCatching {
client.request(
"com.webos.service.soundshare/stopStreamOut",
JSONObject().put("deviceParam", JSONObject().put("key", key)),
timeoutMs = 3_000,
)
}
}
/** The TV's SSG binds a new key asynchronously, so the first calls can fail. */
private suspend fun <T> retry(tries: Int = 6, block: suspend () -> T): T {
var last: Exception? = null
repeat(tries) {
try {
return block()
} catch (e: TvException) {
last = e
if (HINTS.keys.any { k -> e.message?.contains(k) == true }) throw e
delay(1_000)
}
}
throw last ?: TvException("Failed")
}
private fun explain(e: Exception): String {
val msg = e.message.orEmpty()
return HINTS.entries.firstOrNull { msg.contains(it.key) }?.value ?: msg.ifBlank { "Stream failed" }
}
private fun acquireWifiLock() {
val wifi = applicationContext.getSystemService(WifiManager::class.java) ?: return
@Suppress("DEPRECATION")
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) WifiManager.WIFI_MODE_FULL_LOW_LATENCY
else WifiManager.WIFI_MODE_FULL_HIGH_PERF
wifiLock = wifi.createWifiLock(mode, "tvremote-listen").apply { setReferenceCounted(false); acquire() }
}
private fun notification(): Notification {
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Private listening", NotificationManager.IMPORTANCE_LOW))
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
)
val stop = PendingIntent.getService(
this, 1, Intent(this, ListenService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE
)
return Notification.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_headphones)
.setContentTitle("Private listening")
.setContentText("TV audio is playing on this phone")
.setContentIntent(open)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.build()
}
}
@@ -0,0 +1,236 @@
package app.tvremote.data
import android.content.Context
import app.tvremote.net.CertChangedException
import app.tvremote.net.Lan
import app.tvremote.net.TvOffException
import app.tvremote.net.WakeOnLan
import app.tvremote.net.WebOsClient
import app.tvremote.net.causes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/** What the panel is doing. A TV in standby can keep the socket open, so Connected alone doesn't mean on. */
enum class Power { Unknown, On, Standby }
/** Owns the single live TV session, shared by the UI, the widget and the private-listening service. */
class Connection(
private val context: Context,
private val store: TvStore,
private val scope: CoroutineScope,
) {
sealed interface State {
data object Idle : State
data class Connecting(val tv: SavedTv) : State
data class Prompt(val tv: SavedTv) : State
data class Connected(val tv: SavedTv, val client: WebOsClient) : State
data class Offline(val tv: SavedTv, val reason: String, val certChanged: Boolean = false) : State
}
private val _state = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = _state
val client: WebOsClient? get() = (_state.value as? State.Connected)?.client
private val _power = MutableStateFlow(Power.Unknown)
val power: StateFlow<Power> = _power
/** True from a Wake-on-LAN until the TV reports On (or we give up). */
private val _waking = MutableStateFlow(false)
val waking: StateFlow<Boolean> = _waking
/** Connected and the screen is on (firmware that doesn't report power counts as on). */
val isLive: Boolean get() = _state.value is State.Connected && _power.value != Power.Standby
private var job: Job? = null
private var wakeJob: Job? = null
init {
Lan.pinFor = { ip -> store.tvs.value.firstOrNull { it.ip == ip }?.certPin }
}
fun connect(target: SavedTv) {
job?.cancel()
_power.value = Power.Unknown
_state.value = State.Connecting(target)
job = scope.launch {
val c = WebOsClient(target.ip)
val prompt = launch {
c.phase.first { it == WebOsClient.Phase.Prompt }
_state.value = State.Prompt(target)
}
try {
val key = c.connect(target.clientKey, pinned = target.certPin != null)
prompt.cancel()
// Subscribe before anything else so standby shows the moment we're in.
c.subscribe("com.webos.service.tvpower/power/getPowerState") { p ->
val state = p.optString("state")
// Some replies carry no state at all; those say nothing about the TV.
if (state.isNotEmpty()) _power.value = powerOf(state, p.optString("processing"))
}
val uuid = c.hello?.optString("deviceUUID")?.takeIf { it.isNotBlank() } ?: target.uuid
val tv = target.copy(uuid = uuid, clientKey = key, certPin = target.certPin ?: Lan.seenCerts[target.ip])
store.upsert(tv)
store.lastUuid = uuid
_state.value = State.Connected(tv, c)
// Name and MACs aren't needed to start pressing buttons; fill them in after.
refreshDetails(c, tv)
c.phase.first { it == WebOsClient.Phase.Closed }
_power.value = Power.Unknown
_state.value = State.Offline(store.find(uuid) ?: tv, "TV is off")
} catch (e: CancellationException) {
c.close()
throw e
} catch (e: Exception) {
prompt.cancel()
c.close()
_state.value = when {
e.causes().any { it is CertChangedException } ->
State.Offline(target, "This TV's security certificate changed", certChanged = true)
e is TvOffException -> State.Offline(target, "TV is off")
else -> State.Offline(target, e.message ?: "Couldn't reach the TV")
}
}
}
}
private suspend fun refreshDetails(c: WebOsClient, tv: SavedTv) {
val info = runCatching { c.request("system/getSystemInfo") }.getOrNull()
val name = tv.name.takeUnless { it == "LG TV" || it.isBlank() }
?: info?.optString("modelName")?.takeIf { it.isNotBlank() }?.let { "LG $it" }
?: tv.name
val updated = tv.copy(name = name, macs = macs(c).ifEmpty { tv.macs })
if (updated != tv) {
store.upsert(updated)
val s = _state.value
if (s is State.Connected && s.client === c) _state.value = s.copy(tv = updated)
}
}
/**
* Coming back to the app: keep the open session if the TV still answers, instead of
* paying for a fresh TLS handshake; Wi-Fi may have dropped while we were away.
*/
fun resume() {
when (val s = _state.value) {
is State.Connected -> scope.launch { if (!s.client.ping()) connect(s.tv) }
is State.Connecting, is State.Prompt -> {}
is State.Offline -> if (!_waking.value) connect(s.tv)
State.Idle -> store.find(store.lastUuid)?.let(::connect)
}
}
/**
* Wake-on-LAN, then watch closely: probe the TV's port every 500 ms and connect the
* moment it opens, so the remote is live as soon as the TV's network is.
*/
fun wake() {
val tv = _state.value.tv ?: store.find(store.lastUuid) ?: return
wakeJob?.cancel()
wakeJob = scope.launch {
_waking.value = true
try {
WakeOnLan.wake(context, tv.macs, tv.ip)
val deadline = System.currentTimeMillis() + 25_000
var resent = false
while (System.currentTimeMillis() < deadline) {
if (_state.value is State.Connected && _power.value == Power.On) return@launch
// Firmware that never reports power: being connected at all has to do.
if (_state.value is State.Connected && _power.value == Power.Unknown) {
delay(1_500)
if (_power.value != Power.Standby) return@launch
}
val s = _state.value
if (s is State.Offline || s == State.Idle) {
val current = store.find(tv.uuid) ?: tv
if (withContext(Dispatchers.IO) { Lan.reachable(current.ip, 3001, 400) }) connect(current)
}
// Some TVs miss the first packets while their Wi-Fi chip is waking up.
if (!resent && System.currentTimeMillis() > deadline - 20_000) {
resent = true
runCatching { WakeOnLan.wake(context, tv.macs, tv.ip) }
}
delay(500)
}
} finally {
_waking.value = false
}
}
}
/** Your own name for a TV ("Living room"). Kept through reconnects and IP changes. */
fun rename(uuid: String, name: String) {
val tv = store.find(uuid) ?: return
val renamed = tv.copy(name = name)
store.upsert(renamed)
when (val s = _state.value) {
is State.Connected -> if (s.tv.uuid == uuid) _state.value = s.copy(tv = renamed)
is State.Offline -> if (s.tv.uuid == uuid) _state.value = s.copy(tv = renamed)
else -> {}
}
}
/** Forgets the key and certificate, so the TV has to approve us again on its screen. */
fun repair(tv: SavedTv) {
val fresh = tv.copy(clientKey = null, certPin = null)
store.upsert(fresh)
connect(fresh)
}
fun disconnect() {
job?.cancel()
wakeJob?.cancel()
_power.value = Power.Unknown
_state.value = State.Idle
}
/** Returns a live client, connecting to the last TV if needed. Null if it can't be reached. */
suspend fun ensureConnected(timeoutMs: Long = 8_000): WebOsClient? {
client?.let { return it }
val s = _state.value
if (s !is State.Connecting && s !is State.Prompt) {
connect(s.tv ?: store.find(store.lastUuid) ?: return null)
}
return withTimeoutOrNull(timeoutMs) {
(_state.first { it is State.Connected || it is State.Offline } as? State.Connected)?.client
}
}
private suspend fun macs(c: WebOsClient): List<String> {
val info = runCatching { c.request("com.webos.service.connectionmanager/getinfo") }.getOrNull()
?: return emptyList()
return listOf("wiredInfo", "wifiInfo")
.mapNotNull { info.optJSONObject(it)?.optString("macAddress") }
.filter { it.count { ch -> ch == ':' } == 5 }
.distinct()
}
companion object {
/**
* Turning off, the TV reports "Active" with processing "Request Power Off" first and only
* settles on "Active Standby" a few seconds later, so a pending off counts as off already.
*/
fun powerOf(state: String, processing: String): Power = when {
state in setOf("Active Standby", "Suspend", "Power Off") -> Power.Standby
listOf("Power Off", "Standby", "Suspend").any { processing.contains(it) } -> Power.Standby
else -> Power.On
}
}
}
val Connection.State.tv: SavedTv?
get() = when (this) {
is Connection.State.Connected -> tv
is Connection.State.Connecting -> tv
is Connection.State.Prompt -> tv
is Connection.State.Offline -> tv
Connection.State.Idle -> null
}
@@ -0,0 +1,75 @@
package app.tvremote.data
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
/** A paired TV. [uuid] is the TV's deviceUUID, or its IP until we learn it. */
data class SavedTv(
val uuid: String,
val name: String,
val ip: String,
val clientKey: String? = null,
val macs: List<String> = emptyList(),
/** SHA-256 of the TV's TLS certificate, remembered at pairing. */
val certPin: String? = null,
)
/** Paired TVs, kept in app-private storage only (backups are disabled in the manifest). */
class TvStore(context: Context) {
private val prefs = context.getSharedPreferences("tvs", Context.MODE_PRIVATE)
private val _tvs = MutableStateFlow(load())
val tvs: StateFlow<List<SavedTv>> = _tvs
var lastUuid: String?
get() = prefs.getString("last", null)
set(value) = prefs.edit().putString("last", value).apply()
/** Dark remote by default, like CaveRemote. */
var dark: Boolean
get() = prefs.getBoolean("dark", true)
set(value) = prefs.edit().putBoolean("dark", value).apply()
fun find(uuid: String?) = _tvs.value.firstOrNull { it.uuid == uuid }
fun upsert(tv: SavedTv) {
// Replace by uuid, and drop an IP-keyed placeholder for the same TV.
save(_tvs.value.filterNot { it.uuid == tv.uuid || it.ip == tv.ip } + tv)
}
fun remove(uuid: String) {
save(_tvs.value.filterNot { it.uuid == uuid })
if (lastUuid == uuid) lastUuid = null
}
private fun save(list: List<SavedTv>) {
_tvs.value = list
val arr = JSONArray()
list.forEach { tv ->
arr.put(
JSONObject()
.put("uuid", tv.uuid).put("name", tv.name).put("ip", tv.ip)
.put("key", tv.clientKey).put("macs", JSONArray(tv.macs)).put("pin", tv.certPin)
)
}
prefs.edit().putString("list", arr.toString()).apply()
}
private fun load(): List<SavedTv> {
val arr = runCatching { JSONArray(prefs.getString("list", "[]")) }.getOrElse { JSONArray() }
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val macs = o.optJSONArray("macs") ?: JSONArray()
SavedTv(
uuid = o.getString("uuid"),
name = o.optString("name", "LG TV"),
ip = o.getString("ip"),
clientKey = o.optString("key").takeIf { it.isNotBlank() && it != "null" },
macs = (0 until macs.length()).map { macs.getString(it) },
certPin = o.optString("pin").takeIf { it.isNotBlank() && it != "null" },
)
}
}
}
@@ -0,0 +1,87 @@
package app.tvremote.net
import android.content.Context
import android.net.wifi.WifiManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.net.URLDecoder
data class FoundTv(val ip: String, val name: String, val uuid: String?)
/** Finds webOS TVs on the LAN: SSDP first, then a port-3001 sweep of the /24 as a fallback. */
object Discovery {
private const val ST = "urn:lge-com:service:webos-second-screen:1"
suspend fun find(context: Context): List<FoundTv> {
val ssdp = ssdp(context)
return ssdp.ifEmpty { sweep(context) }
}
private suspend fun ssdp(context: Context, timeoutMs: Long = 3_000): List<FoundTv> = withContext(Dispatchers.IO) {
val wifi = context.applicationContext.getSystemService(WifiManager::class.java)
val lock = wifi.createMulticastLock("tvremote-ssdp").apply { setReferenceCounted(false); acquire() }
val found = linkedMapOf<String, FoundTv>()
try {
DatagramSocket().use { s ->
s.soTimeout = 400
val msg = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\nMX: 2\r\nST: $ST\r\n\r\n").toByteArray()
val group = InetAddress.getByName("239.255.255.250")
repeat(2) { s.send(DatagramPacket(msg, msg.size, group, 1900)) }
val buf = ByteArray(4096)
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val p = DatagramPacket(buf, buf.size)
try { s.receive(p) } catch (_: SocketTimeoutException) { continue }
val ip = p.address.hostAddress ?: continue
if (!Lan.isPrivate(ip) || ip in found) continue
val headers = String(buf, 0, p.length).lines().mapNotNull { line ->
val i = line.indexOf(':')
if (i <= 0) null else line.substring(0, i).trim().lowercase() to line.substring(i + 1).trim()
}.toMap()
if (headers["st"]?.equals(ST, ignoreCase = true) == false) continue
val name = headers["dlnadevicename.lge.com"]
?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrNull() } ?: "LG TV"
val uuid = headers["usn"]?.removePrefix("uuid:")?.substringBefore("::")
found[ip] = FoundTv(ip, name, uuid)
}
}
} catch (_: Exception) {
} finally {
lock.release()
}
found.values.toList()
}
private suspend fun sweep(context: Context): List<FoundTv> = withContext(Dispatchers.IO) {
val (me, _) = Lan.localIpv4(context) ?: return@withContext emptyList()
val self = me.hostAddress ?: return@withContext emptyList()
if (!Lan.isPrivate(self)) return@withContext emptyList()
val base = self.substringBeforeLast('.')
val gate = Semaphore(48)
coroutineScope {
(1..254).map { "$base.$it" }.filter { it != self }.map { ip ->
async {
gate.withPermit {
runCatching {
Socket().use { it.connect(InetSocketAddress(ip, 3001), 350) }
FoundTv(ip, "LG TV", null)
}.getOrNull()
}
}
}.awaitAll().filterNotNull()
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package app.tvremote.net
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
/**
* Shows a photo or plays a video through the TV's UPnP/DLNA MediaRenderer (AVTransport).
* This is what LG's own app tries first; on current webOS, ssap media.viewer/open opens
* the viewer but never fetches the file.
*/
object Dlna {
private const val AV_TRANSPORT = "urn:schemas-upnp-org:service:AVTransport:1"
private val controlUrls = ConcurrentHashMap<String, String>()
suspend fun play(tvIp: String, url: String, mimeType: String, title: String) = withContext(Dispatchers.IO) {
val control = controlUrl(tvIp)
val photo = mimeType.startsWith("image/")
val didl = "<DIDL-Lite xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" " +
"xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">" +
"<item id=\"0\" parentID=\"-1\" restricted=\"1\"><dc:title>${xml(title)}</dc:title>" +
"<upnp:class>${if (photo) "object.item.imageItem.photo" else "object.item.videoItem"}</upnp:class>" +
"<res protocolInfo=\"http-get:*:$mimeType:${contentFeatures(mimeType)}\">${xml(url)}</res>" +
"</item></DIDL-Lite>"
soap(control, "SetAVTransportURI", "<CurrentURI>${xml(url)}</CurrentURI><CurrentURIMetaData>${xml(didl)}</CurrentURIMetaData>")
soap(control, "Play", "<Speed>1</Speed>")
}
suspend fun stop(tvIp: String) = withContext(Dispatchers.IO) {
runCatching { soap(controlUrl(tvIp), "Stop", "") }
}
/** DLNA flags for both the DIDL item and the HTTP response: byte seeks allowed, streaming. */
fun contentFeatures(mimeType: String): String {
val pn = if (mimeType == "image/jpeg") "DLNA.ORG_PN=JPEG_LRG;" else ""
return "${pn}DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000"
}
private fun controlUrl(tvIp: String): String = controlUrls[tvIp] ?: run {
// LG serves its renderer description on :1966; ask SSDP only if that ever moves.
val location = "http://$tvIp:1966/".takeIf { fetch(it) != null } ?: ssdpLocation(tvIp)
?: throw TvException("This TV doesn't offer a media player on the network")
val desc = fetch(location) ?: throw TvException("Couldn't read the TV's media player")
val path = Regex("<service>(.*?)</service>", RegexOption.DOT_MATCHES_ALL).findAll(desc)
.map { it.groupValues[1] }
.firstOrNull { it.contains(AV_TRANSPORT) }
?.let { Regex("<controlURL>(.*?)</controlURL>").find(it)?.groupValues?.get(1) }
?: throw TvException("The TV's media player has no AVTransport")
val base = location.indexOf('/', "http://".length).let { if (it < 0) location else location.substring(0, it) }
(if (path.startsWith("http")) path else base + (if (path.startsWith("/")) path else "/$path"))
.also { controlUrls[tvIp] = it }
}
private fun fetch(url: String): String? = runCatching {
Lan.http.newCall(Request.Builder().url(url).build()).execute().use { if (it.isSuccessful) it.body?.string() else null }
}.getOrNull()
private fun ssdpLocation(tvIp: String): String? = runCatching<String?> {
DatagramSocket().use { s ->
s.soTimeout = 1_500
val msg = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 1\r\n" +
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n").toByteArray()
s.send(DatagramPacket(msg, msg.size, InetAddress.getByName(tvIp), 1900))
val buf = ByteArray(2048)
var location: String? = null
while (location == null) {
val p = DatagramPacket(buf, buf.size)
try { s.receive(p) } catch (_: SocketTimeoutException) { break }
if (p.address.hostAddress != tvIp) continue
location = String(buf, 0, p.length).lines()
.firstOrNull { it.startsWith("location:", ignoreCase = true) }
?.substringAfter(':')?.trim()
}
location
}
}.getOrNull()
private fun soap(control: String, action: String, args: String) {
val body = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" " +
"s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>" +
"<u:$action xmlns:u=\"$AV_TRANSPORT\"><InstanceID>0</InstanceID>$args</u:$action>" +
"</s:Body></s:Envelope>"
val request = Request.Builder()
.url(control)
.header("SOAPAction", "\"$AV_TRANSPORT#$action\"")
.post(body.toRequestBody("text/xml; charset=\"utf-8\"".toMediaType()))
.build()
Lan.http.newCall(request).execute().use {
if (!it.isSuccessful) throw IOException("TV refused $action (HTTP ${it.code})")
}
}
private fun xml(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
}
+146
View File
@@ -0,0 +1,146 @@
package app.tvremote.net
import android.content.Context
import android.net.ConnectivityManager
import okhttp3.Dns
import okhttp3.OkHttpClient
import java.io.IOException
import java.net.DatagramSocket
import java.net.Inet4Address
import java.net.InetAddress
import java.net.UnknownHostException
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.net.InetSocketAddress
import java.net.Socket
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import java.security.MessageDigest
import java.security.cert.CertificateException
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedTrustManager
class CertChangedException : CertificateException("The TV's security certificate changed")
/**
* Everything this app sends goes to a private IPv4 literal on the local network.
* Hostnames are refused outright, so the app never even performs a DNS lookup.
*/
object Lan {
private val ipv4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""")
fun isPrivate(host: String): Boolean {
val m = ipv4.matchEntire(host) ?: return false
val o = m.groupValues.drop(1).map { it.toInt() }
if (o.any { it > 255 }) return false
val (a, b) = o[0] to o[1]
return a == 10 ||
(a == 172 && b in 16..31) ||
(a == 192 && b == 168) ||
(a == 169 && b == 254)
}
/** Certificate fingerprints seen per IP during this run, so a new pairing can remember one. */
val seenCerts = ConcurrentHashMap<String, String>()
/** The remembered fingerprint for a TV at this IP, if it has been paired. */
@Volatile var pinFor: (String) -> String? = { null }
/**
* TVs present a certificate from LG's own CA that no public root vouches for, so we
* trust on first use: any certificate is accepted while pairing, and from then on only
* the one remembered for that TV.
*/
private object PinningTrustManager : X509ExtendedTrustManager() {
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket) =
check(chain, socket.inetAddress?.hostAddress)
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine) =
check(chain, engine.peerHost)
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = check(chain, null)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket) = refuse()
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine) = refuse()
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = refuse()
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
private fun refuse(): Nothing = throw CertificateException("Client certificates are not used")
private fun check(chain: Array<X509Certificate>, ip: String?) {
if (ip == null || !isPrivate(ip)) throw CertificateException("Unknown peer")
val fp = MessageDigest.getInstance("SHA-256").digest(chain[0].encoded)
.joinToString("") { "%02x".format(it) }
seenCerts[ip] = fp
val pin = pinFor(ip)
if (pin != null && pin != fp) throw CertChangedException()
}
}
/** OkHttp client that can only reach LAN IPs, with certificates pinned per TV. */
val http: OkHttpClient by lazy {
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(PinningTrustManager), SecureRandom()) }
OkHttpClient.Builder()
.dns(object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (!isPrivate(hostname)) throw UnknownHostException("LAN only: $hostname")
return listOf(InetAddress.getByName(hostname))
}
})
.addInterceptor { chain ->
val host = chain.request().url.host
if (!isPrivate(host)) throw IOException("Blocked non-LAN host $host")
chain.proceed(chain.request())
}
.sslSocketFactory(ssl.socketFactory, PinningTrustManager)
.hostnameVerifier { host, _ -> isPrivate(host) }
.socketFactory(NoDelaySocketFactory)
.connectTimeout(3, TimeUnit.SECONDS)
// A TV that drops off the network (unplugged, Wi-Fi gone) is noticed within ~10 s.
.pingInterval(5, TimeUnit.SECONDS)
.build()
}
/** Quick TCP check: is anything listening on [ip]:[port]? A TV that's off just times out. */
fun reachable(ip: String, port: Int, timeoutMs: Int): Boolean = runCatching {
Socket().use { it.connect(InetSocketAddress(ip, port), timeoutMs) }
}.isSuccess
/**
* Remote keys are a few bytes each. Without TCP_NODELAY the kernel may hold one back
* to batch it with the next, which feels like lag on held arrows and volume.
*/
private object NoDelaySocketFactory : SocketFactory() {
private val base = getDefault()
private fun Socket.noDelay() = apply { tcpNoDelay = true }
override fun createSocket(): Socket = base.createSocket().noDelay()
override fun createSocket(host: String, port: Int): Socket = base.createSocket(host, port).noDelay()
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket =
base.createSocket(host, port, localHost, localPort).noDelay()
override fun createSocket(host: InetAddress, port: Int): Socket = base.createSocket(host, port).noDelay()
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket =
base.createSocket(address, port, localAddress, localPort).noDelay()
}
/** This phone's IPv4 address and prefix length on the active network. */
fun localIpv4(context: Context): Pair<Inet4Address, Int>? {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return null
val props = cm.getLinkProperties(cm.activeNetwork) ?: return null
val link = props.linkAddresses.firstOrNull { it.address is Inet4Address } ?: return null
return (link.address as Inet4Address) to link.prefixLength
}
fun broadcast(context: Context): String? {
val (addr, prefix) = localIpv4(context) ?: return null
val ip = addr.address.fold(0) { acc, b -> (acc shl 8) or (b.toInt() and 0xFF) }
val mask = if (prefix == 0) 0 else -1 shl (32 - prefix)
val bcast = ip or mask.inv()
return (3 downTo 0).joinToString(".") { ((bcast shr (it * 8)) and 0xFF).toString() }
}
/** Address of the local interface that routes to [target]. No packet is sent. */
fun routeTo(target: String): String = DatagramSocket().use {
it.connect(InetAddress.getByName(target), 3001)
it.localAddress.hostAddress ?: throw IOException("No route to $target")
}
}
@@ -0,0 +1,188 @@
package app.tvremote.net
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.SecureRandom
import kotlin.concurrent.thread
import kotlin.math.max
/**
* A tiny HTTP server that hands one photo or video to the TV's media viewer.
* It listens only on the LAN interface, answers only the TV's IP, and the path
* carries a random token. Photos are re-encoded to JPEG, which drops EXIF data
* such as GPS location.
*/
class MediaServer private constructor(
private val context: Context,
private val tvIp: String,
private val uri: Uri,
val mimeType: String,
private val jpeg: ByteArray?,
) {
private val token = ByteArray(16).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) }
private val fileName = "media." + if (jpeg != null) "jpg" else mimeType.substringAfter('/').substringBefore(';')
private lateinit var server: ServerSocket
@Volatile private var closed = false
val length: Long by lazy { jpeg?.size?.toLong() ?: sizeOf(uri) }
lateinit var url: String
private set
private fun start() {
val local = Lan.routeTo(tvIp)
server = ServerSocket(0, 8, InetAddress.getByName(local))
url = "http://$local:${server.localPort}/$token/$fileName"
thread(isDaemon = true, name = "media-server") {
while (!closed) {
val s = runCatching { server.accept() }.getOrNull() ?: break
if (s.inetAddress.hostAddress != tvIp) {
s.close()
continue
}
thread(isDaemon = true) { s.use { runCatching { serve(it) } } }
}
}
}
fun close() {
closed = true
runCatching { server.close() }
}
private fun serve(s: Socket) {
s.soTimeout = 15_000
val input = s.getInputStream()
val request = readHead(input) ?: return
val (method, path) = request.first().split(' ').let { it.getOrNull(0) to it.getOrNull(1) }
val out = s.getOutputStream()
if (path != "/$token/$fileName" || (method != "GET" && method != "HEAD")) {
out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray())
return
}
val range = request.drop(1)
.firstOrNull { it.startsWith("range:", ignoreCase = true) }
?.substringAfter('=')?.trim()
var start = 0L
var end = length - 1
if (range != null && length > 0) {
start = range.substringBefore('-').toLongOrNull() ?: 0L
end = range.substringAfter('-').toLongOrNull()?.coerceAtMost(length - 1) ?: (length - 1)
}
val count = (end - start + 1).coerceAtLeast(0)
val head = buildString {
append(if (range != null) "HTTP/1.1 206 Partial Content\r\n" else "HTTP/1.1 200 OK\r\n")
append("Content-Type: $mimeType\r\n")
append("Content-Length: $count\r\n")
append("Accept-Ranges: bytes\r\n")
// DLNA players check these before they seek or stream.
append("contentFeatures.dlna.org: ${Dlna.contentFeatures(mimeType)}\r\n")
append("transferMode.dlna.org: ${if (jpeg != null) "Interactive" else "Streaming"}\r\n")
if (range != null) append("Content-Range: bytes $start-$end/$length\r\n")
append("Connection: close\r\n\r\n")
}
out.write(head.toByteArray())
if (method == "HEAD") return
if (jpeg != null) {
out.write(jpeg, start.toInt(), count.toInt())
} else {
context.contentResolver.openInputStream(uri)?.use { it.skipFully(start); it.copyTo(out, count) }
}
out.flush()
}
private fun readHead(input: InputStream): List<String>? {
val lines = mutableListOf<String>()
val line = StringBuilder()
while (lines.size < 64) {
val b = input.read()
if (b < 0) return null
when (b.toChar()) {
'\r' -> {}
'\n' -> {
if (line.isEmpty()) return lines.takeIf { it.isNotEmpty() }
lines += line.toString()
line.clear()
}
else -> if (line.length < 8_192) line.append(b.toChar())
}
}
return null
}
private fun InputStream.skipFully(n: Long) {
var left = n
while (left > 0) {
val skipped = skip(left)
if (skipped <= 0) { if (read() < 0) return; left-- } else left -= skipped
}
}
private fun InputStream.copyTo(out: OutputStream, count: Long) {
val buf = ByteArray(64 * 1024)
var left = count
while (left > 0) {
val n = read(buf, 0, minOf(buf.size.toLong(), left).toInt())
if (n < 0) break
out.write(buf, 0, n)
left -= n
}
}
private fun sizeOf(uri: Uri): Long =
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { c ->
if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null
} ?: context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { it.length } ?: -1L
companion object {
/** Prepares [uri] (re-encoding photos) and starts serving it to [tvIp]. Call off the main thread. */
fun serve(context: Context, tvIp: String, uri: Uri): MediaServer {
val type = context.contentResolver.getType(uri) ?: "application/octet-stream"
val server = if (type.startsWith("image/")) {
MediaServer(context, tvIp, uri, "image/jpeg", toJpeg(context, uri))
} else {
MediaServer(context, tvIp, uri, type, null)
}
return server.also { it.start() }
}
private const val MAX_EDGE = 3840 // 4K is the most any TV panel shows
private fun toJpeg(context: Context, uri: Uri): ByteArray {
val bitmap: Bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// ImageDecoder applies the EXIF rotation for us.
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { d, info, _ ->
val edge = max(info.size.width, info.size.height)
if (edge > MAX_EDGE) {
val scale = MAX_EDGE.toFloat() / edge
d.setTargetSize((info.size.width * scale).toInt(), (info.size.height * scale).toInt())
}
d.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
} else {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
while (max(bounds.outWidth, bounds.outHeight) / sample > MAX_EDGE) sample *= 2
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
} ?: throw IllegalArgumentException("Can't read this photo")
}
return ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 92, out)
bitmap.recycle()
out.toByteArray()
}
}
}
}
@@ -0,0 +1,75 @@
package app.tvremote.net
import org.json.JSONArray
import org.json.JSONObject
/**
* The SSAP registration manifest. The "signed" block and its signature are LG's
* long-published remote-app manifest (also used by connectsdk/bscpylgtv); the TV
* verifies it, and it is what grants TEST_SECURE, which private listening needs.
* It identifies nothing about you or this phone.
*/
object Registration {
private const val SIGNATURE =
"eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbm" +
"ctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR" +
"+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRy" +
"aMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4" +
"RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n" +
"50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM" +
"2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQoj" +
"oa7NQnAtw=="
private val PERMISSIONS = listOf(
"LAUNCH", "LAUNCH_WEBAPP", "APP_TO_APP", "CLOSE", "TEST_OPEN", "TEST_PROTECTED",
"CONTROL_AUDIO", "CONTROL_DISPLAY", "CONTROL_INPUT_JOYSTICK",
"CONTROL_INPUT_MEDIA_RECORDING", "CONTROL_INPUT_MEDIA_PLAYBACK", "CONTROL_INPUT_TV",
"CONTROL_POWER", "CONTROL_TV_SCREEN", "READ_APP_STATUS", "READ_CURRENT_CHANNEL",
"READ_INPUT_DEVICE_LIST", "READ_NETWORK_STATE", "READ_RUNNING_APPS",
"READ_TV_CHANNEL_LIST", "WRITE_NOTIFICATION_TOAST", "READ_POWER_STATE",
"READ_COUNTRY_INFO", "CONTROL_INPUT_TEXT", "CONTROL_MOUSE_AND_KEYBOARD",
"READ_INSTALLED_APPS", "READ_SETTINGS", "READ_STORAGE_DEVICE_LIST",
)
// Must match the signature byte-for-byte, so it is kept verbatim.
private val SIGNED_PERMISSIONS = listOf(
"TEST_SECURE", "CONTROL_INPUT_TEXT", "CONTROL_MOUSE_AND_KEYBOARD", "READ_INSTALLED_APPS",
"READ_LGE_SDX", "READ_NOTIFICATIONS", "SEARCH", "WRITE_SETTINGS",
"WRITE_NOTIFICATION_ALERT", "CONTROL_POWER", "READ_CURRENT_CHANNEL", "READ_RUNNING_APPS",
"READ_UPDATE_INFO", "UPDATE_FROM_REMOTE_APP", "READ_LGE_TV_INPUT_EVENTS",
"READ_TV_CURRENT_TIME",
)
private fun manifest() = JSONObject()
.put("appVersion", "1.1")
.put("manifestVersion", 1)
.put("deviceName", "LG Slim")
.put("permissions", JSONArray(PERMISSIONS))
.put("signatures", JSONArray().put(JSONObject().put("signature", SIGNATURE).put("signatureVersion", 1)))
.put(
"signed", JSONObject()
.put("appId", "com.lge.test")
.put("created", "20140509")
.put(
"localizedAppNames", JSONObject()
.put("", "LG Remote App")
.put("ko-KR", "리모컨 앱")
.put("zxx-XX", "ЛГ Rэмotэ AПП")
)
.put("localizedVendorNames", JSONObject().put("", "LG Electronics"))
.put("permissions", JSONArray(SIGNED_PERMISSIONS))
.put("serial", "2f930e2d2cfe083771f68e4fe7bb07")
.put("vendorId", "com.lge")
)
fun message(clientKey: String?) = JSONObject()
.put("type", "register")
.put("id", "register")
.put(
"payload", JSONObject()
.put("forcePairing", false)
.put("pairingType", "PROMPT")
.put("manifest", manifest())
.apply { if (clientKey != null) put("client-key", clientKey) }
)
}
@@ -0,0 +1,42 @@
package app.tvremote.net
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
/** Powers the TV on. Needs "Turn on via Wi-Fi" / "Mobile TV On" enabled in the TV's settings. */
object WakeOnLan {
suspend fun wake(context: Context, macs: List<String>, lastIp: String?) = withContext(Dispatchers.IO) {
val packets = macs.mapNotNull { magicPacket(it) }
if (packets.isEmpty()) throw IllegalStateException("TV's MAC address is unknown. Connect once while it's on.")
val targets = buildSet {
add("255.255.255.255")
Lan.broadcast(context)?.let(::add)
if (lastIp != null) add(lastIp)
}.map { InetAddress.getByName(it) }
DatagramSocket().use { s ->
s.broadcast = true
repeat(3) {
for (pkt in packets) for (addr in targets) for (port in intArrayOf(9, 7)) {
runCatching { s.send(DatagramPacket(pkt, pkt.size, addr, port)) }
}
delay(100)
}
}
}
/**
* Byte-for-byte what LG's own app sends: 6 x 0xFF, the MAC 16 times, 6 zero bytes,
* then "THINQ". Plain magic packets work too; the trailer just matches the original.
*/
private fun magicPacket(mac: String): ByteArray? {
val bytes = mac.split(':', '-').mapNotNull { it.toIntOrNull(16)?.toByte() }
if (bytes.size != 6) return null
return ByteArray(6) { 0xFF.toByte() } + ByteArray(16 * 6) { bytes[it % 6] } +
ByteArray(6) + "THINQ".toByteArray()
}
}
@@ -0,0 +1,248 @@
package app.tvremote.net
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
open class TvException(message: String, val payload: JSONObject? = null) : Exception(message)
class TvOffException : TvException("TV is off")
private const val PROBE_MS = 1_500
fun Throwable.causes(): Sequence<Throwable> = generateSequence(this) { it.cause }
/** Volume level (null if the TV doesn't report one, e.g. external speakers) and mute state. */
fun volumeOf(p: JSONObject): Pair<Int?, Boolean> {
val status = p.optJSONObject("volumeStatus")
val level = (status ?: p).optInt("volume", -1).takeIf { it >= 0 }
return level to (status?.optBoolean("muteStatus") ?: p.optBoolean("muted"))
}
/** One SSAP (webOS second-screen) session with a TV on wss://ip:3001. */
class WebOsClient(val ip: String) {
enum class Phase { Connecting, Prompt, Connected, Closed }
private val _phase = MutableStateFlow(Phase.Connecting)
val phase: StateFlow<Phase> = _phase
/** The TV's hello payload (deviceUUID, deviceOS, ...). */
var hello: JSONObject? = null
private set
var clientKey: String? = null
private set
private var ws: WebSocket? = null
private val nextId = AtomicInteger()
private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>()
private val subscriptions = ConcurrentHashMap<String, (JSONObject) -> Unit>()
private val registered = CompletableDeferred<String>()
private var pointer: PointerSocket? = null
private val pointerLock = Mutex()
/** Opens the socket and pairs. Shows the TV's accept prompt when [storedKey] is null or stale. */
suspend fun connect(storedKey: String?, pinned: Boolean): String {
// Probe first: a TV that's off doesn't refuse, it just never answers, and waiting
// out a full TLS connect timeout is what made "off" so slow to show.
val secure = withContext(Dispatchers.IO) { Lan.reachable(ip, 3001, PROBE_MS) }
ws = when {
secure -> withTimeout(5_000) { openWebSocket("wss://$ip:3001", listener) }
// Pre-2018 firmware only speaks plain ws on 3000. A TV we pinned speaks TLS,
// so never downgrade it.
!pinned && withContext(Dispatchers.IO) { Lan.reachable(ip, 3000, PROBE_MS) } ->
withTimeout(5_000) { openWebSocket("ws://$ip:3000", listener) }
else -> throw TvOffException()
}
// Optional: gives us the stable deviceUUID. Some firmware ignores it.
hello = runCatching {
withTimeout(3_000) { awaitReply("hello") { send(JSONObject().put("type", "hello").put("id", "hello")) } }
}.getOrNull()
send(Registration.message(storedKey))
val key = withTimeout(90_000) { registered.await() }
clientKey = key
_phase.value = Phase.Connected
return key
}
/** The socket is open but the TV may be asleep or gone: one cheap round trip tells. */
suspend fun ping(timeoutMs: Long = 1_500): Boolean =
runCatching { request("com.webos.service.tvpower/power/getPowerState", timeoutMs = timeoutMs) }
.map { true }
// An error reply (it carries a payload) still proves the TV answered.
.getOrElse { (it as? TvException)?.payload != null }
/** Sends a key on the already-open pointer socket without suspending. False if there isn't one. */
fun buttonNow(name: String): Boolean {
val p = pointer?.takeIf { it.isOpen } ?: return false
p.button(name)
return p.isOpen
}
fun close() {
ws?.close(1000, null)
pointer?.close()
onClosed(TvException("Closed"))
}
suspend fun request(uri: String, payload: JSONObject? = null, timeoutMs: Long = 10_000): JSONObject {
val id = "req_${nextId.incrementAndGet()}"
return withTimeout(timeoutMs) {
awaitReply(id) {
send(JSONObject().put("type", "request").put("id", id).put("uri", fullUri(uri))
.apply { if (payload != null) put("payload", payload) })
}
}
}
/** Subscribes to state pushes. Returns the id to pass to [unsubscribe]. */
fun subscribe(uri: String, payload: JSONObject? = null, onEvent: (JSONObject) -> Unit): String {
val id = "sub_${nextId.incrementAndGet()}"
subscriptions[id] = onEvent
send(JSONObject().put("type", "subscribe").put("id", id).put("uri", fullUri(uri))
.apply { if (payload != null) put("payload", payload) })
return id
}
fun unsubscribe(id: String) {
if (subscriptions.remove(id) != null) {
runCatching { send(JSONObject().put("type", "unsubscribe").put("id", id)) }
}
}
/** The second socket that carries remote buttons and the touchpad pointer. */
suspend fun pointer(): PointerSocket = pointerLock.withLock {
pointer?.takeIf { it.isOpen } ?: run {
val path = request("com.webos.service.networkinput/getPointerInputSocket").getString("socketPath")
val p = PointerSocket()
p.socket = withTimeout(6_000) { openWebSocket(path, p) }
p.also { pointer = it }
}
}
private suspend fun awaitReply(id: String, sendIt: () -> Unit): JSONObject {
val d = CompletableDeferred<JSONObject>()
pending[id] = d
try {
sendIt()
return d.await()
} finally {
pending.remove(id)
}
}
private fun send(msg: JSONObject) {
val socket = ws ?: throw TvException("Not connected")
if (!socket.send(msg.toString())) throw TvException("Not connected")
}
private fun fullUri(uri: String) =
if (uri.startsWith("ssap://") || uri.startsWith("luna://")) uri else "ssap://$uri"
private fun handle(text: String) {
val msg = runCatching { JSONObject(text) }.getOrNull() ?: return
val type = msg.optString("type")
val id = msg.optString("id")
val payload = msg.optJSONObject("payload") ?: JSONObject()
if (id == "register") {
when (type) {
"registered" -> registered.complete(payload.optString("client-key"))
"response" -> if (payload.optString("pairingType") == "PROMPT") _phase.value = Phase.Prompt
"error" -> registered.completeExceptionally(
TvException(msg.optString("error").ifBlank { "Pairing rejected" })
)
}
return
}
subscriptions[id]?.let { cb ->
if (type == "response" && payload.optBoolean("returnValue", true)) cb(payload)
return
}
val waiter = pending[id] ?: return
when {
type == "error" -> waiter.completeExceptionally(TvException(msg.optString("error"), payload))
payload.has("returnValue") && !payload.optBoolean("returnValue") -> waiter.completeExceptionally(
TvException(payload.optString("errorText").ifBlank { payload.toString() }, payload)
)
else -> waiter.complete(payload)
}
}
private fun onClosed(cause: Throwable) {
_phase.value = Phase.Closed
registered.completeExceptionally(cause)
pending.values.forEach { it.completeExceptionally(cause) }
pending.clear()
subscriptions.clear()
}
private val listener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) = handle(text)
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (webSocket === ws) onClosed(TvException("TV closed the connection"))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (webSocket === ws) onClosed(t)
}
}
}
/** Line-based input protocol: "type:button\nname:HOME\n\n" and friends. */
class PointerSocket : WebSocketListener() {
@Volatile var socket: WebSocket? = null
@Volatile var isOpen = true
private set
fun button(name: String) = send("type:button\nname:$name\n\n")
fun move(dx: Int, dy: Int) = send("type:move\ndx:$dx\ndy:$dy\ndown:0\n\n")
fun click() = send("type:click\n\n")
fun scroll(dx: Int, dy: Int) = send("type:scroll\ndx:$dx\ndy:$dy\n\n")
fun close() {
socket?.close(1000, null)
isOpen = false
}
private fun send(frame: String) {
if (socket?.send(frame) != true) isOpen = false
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { isOpen = false }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { isOpen = false }
}
/** Opens a socket on the LAN client and suspends until the handshake succeeds. */
private suspend fun openWebSocket(url: String, delegate: WebSocketListener): WebSocket =
suspendCancellableCoroutine { cont ->
val socket = Lan.http.newWebSocket(Request.Builder().url(url).build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (cont.isActive) cont.resume(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) = delegate.onMessage(webSocket, text)
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) =
delegate.onClosing(webSocket, code, reason)
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) =
delegate.onClosed(webSocket, code, reason)
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (cont.isActive) cont.resumeWithException(t) else delegate.onFailure(webSocket, t, response)
}
})
cont.invokeOnCancellation { socket.cancel() }
}
@@ -0,0 +1,247 @@
package app.tvremote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable
import app.tvremote.R
import app.tvremote.data.Connection.State
import app.tvremote.data.SavedTv
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.SuggestionChip
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DevicesScreen(vm: TvViewModel) {
val saved by vm.saved.collectAsState()
val found by vm.found.collectAsState()
val scanning by vm.scanning.collectAsState()
val state by vm.state.collectAsState()
var addDialog by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<SavedTv?>(null) }
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) { vm.scan() }
LaunchedEffect(Unit) { vm.messages.collect { snackbar.showSnackbar(it) } }
val newOnes = found.filter { f -> saved.none { it.uuid == f.uuid || it.ip == f.ip } }
Scaffold(
topBar = {
TopAppBar(
title = { Text("TVs") },
navigationIcon = {
if (state != State.Idle) IconButton(onClick = vm::closeDevices) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
} else Image(
painterResource(R.mipmap.ic_launcher_foreground), null,
Modifier.padding(horizontal = 12.dp).size(36.dp).clip(RoundedCornerShape(10.dp))
.background(colorResource(R.color.ic_launcher_background)),
)
},
actions = {
if (scanning) CircularProgressIndicator(Modifier.padding(12.dp).size(24.dp), strokeWidth = 2.dp)
else IconButton(onClick = vm::scan) { Icon(Icons.Filled.Refresh, "Search again") }
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { addDialog = true },
icon = { Icon(Icons.Filled.Add, null) },
text = { Text("Add by IP") },
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { pad ->
LazyColumn(contentPadding = PaddingValues(top = pad.calculateTopPadding(), bottom = 96.dp)) {
if (saved.isNotEmpty()) {
item { Header("Paired") }
items(saved, key = { it.uuid }) { tv ->
ListItem(
headlineContent = { Text(tv.name) },
supportingContent = { Text(tv.ip) },
leadingContent = { Icon(Icons.Filled.Tv, null) },
trailingContent = {
Row {
IconButton(onClick = { renaming = tv }) { Icon(Icons.Filled.Edit, "Rename") }
IconButton(onClick = { vm.forget(tv) }) { Icon(Icons.Filled.Delete, "Forget") }
}
},
modifier = Modifier.clickable { vm.connect(tv) },
)
}
}
item { Header("On this network") }
if (newOnes.isEmpty()) {
item {
Text(
if (scanning) "Searching…" else "No new TVs found. Make sure the TV is on and on the same Wi-Fi.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
items(newOnes, key = { it.ip }) { tv ->
ListItem(
headlineContent = { Text(tv.name) },
supportingContent = { Text("${tv.ip} · tap to pair") },
leadingContent = { Icon(Icons.Filled.Tv, null) },
modifier = Modifier.clickable { vm.pair(tv) },
)
}
item {
Text(
"Everything stays on your network: no account, no cloud, no analytics.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
renaming?.let { tv ->
RenameDialog(tv.name, onDismiss = { renaming = null }) { vm.rename(tv, it); renaming = null }
}
if (addDialog) {
var ip by remember { mutableStateOf("") }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { addDialog = false },
title = { Text("Add TV by IP") },
text = {
OutlinedTextField(
value = ip,
onValueChange = { ip = it; error = false },
label = { Text("IP address") },
placeholder = { Text("192.168.1.20") },
isError = error,
supportingText = { if (error) Text("Enter a local IPv4 address") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
)
},
confirmButton = {
TextButton(onClick = { if (vm.addByIp(ip)) addDialog = false else error = true }) { Text("Connect") }
},
dismissButton = { TextButton(onClick = { addDialog = false }) { Text("Cancel") } },
)
}
}
private val SUGGESTED_NAMES = listOf("Living room", "Main TV", "Bedroom", "Office", "Kitchen", "Basement")
/** Pick a name for a TV: type one, or tap a suggestion. */
@Composable
fun RenameDialog(current: String, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(current) }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = LocalRemote.current.surfaceHigh,
title = { Text("Rename TV") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it.take(32) },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onRename(name) }),
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SUGGESTED_NAMES.forEach { s -> SuggestionChip(onClick = { name = s }, label = { Text(s) }) }
}
}
},
confirmButton = { TextButton(onClick = { onRename(name) }, enabled = name.isNotBlank()) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun Header(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
/** Shown over the remote while the TV is asking the user to accept us. */
@Composable
fun PairingDialog(onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = {},
icon = { Icon(Icons.Filled.Tv, null) },
title = { Text("Check your TV") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Accept the connection request on the TV screen using its remote.")
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
Text("Waiting…", style = MaterialTheme.typography.bodyMedium)
}
}
},
confirmButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,86 @@
package app.tvremote.ui
import android.os.Build
import android.net.wifi.WifiManager
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.toArgb
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import app.tvremote.audio.ListenService
import app.tvremote.data.Connection
class MainActivity : ComponentActivity() {
private val vm: TvViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val dark by vm.dark.collectAsState()
val t = if (dark) DarkRemote else LightRemote
LaunchedEffect(dark) {
val bar = t.background.toArgb()
val style = if (dark) SystemBarStyle.dark(bar) else SystemBarStyle.light(bar, bar)
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
}
AppTheme(t) {
val showDevices by vm.showDevices.collectAsState()
if (showDevices) {
BackHandler(vm.state.collectAsState().value != Connection.State.Idle) { vm.closeDevices() }
DevicesScreen(vm)
} else {
RemoteScreen(vm)
}
}
}
}
private var wifiLock: WifiManager.WifiLock? = null
override fun onStart() {
super.onStart()
vm.onForeground()
}
// Wi-Fi power saving can add 100+ ms to each key press; hold low-latency mode while visible.
override fun onResume() {
super.onResume()
val lock = wifiLock ?: run {
val wifi = applicationContext.getSystemService(WifiManager::class.java) ?: return
@Suppress("DEPRECATION")
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) WifiManager.WIFI_MODE_FULL_LOW_LATENCY
else WifiManager.WIFI_MODE_FULL_HIGH_PERF
wifi.createWifiLock(mode, "tvremote-keys").apply { setReferenceCounted(false) }.also { wifiLock = it }
}
lock.acquire()
}
override fun onPause() {
super.onPause()
wifiLock?.takeIf { it.isHeld }?.release()
}
/** Phone volume keys drive the TV, except while TV audio plays on the phone. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val connected = vm.state.value is Connection.State.Connected
val listening = vm.listen.value != ListenService.Status.Off
if (connected && !listening && !vm.showDevices.value) {
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (event.action == KeyEvent.ACTION_DOWN) {
if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP) vm.volumeUp() else vm.volumeDown()
}
return true
}
}
}
return super.dispatchKeyEvent(event)
}
}
@@ -0,0 +1,302 @@
package app.tvremote.ui
import android.os.SystemClock
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.Undo
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.FastForward
import androidx.compose.material.icons.rounded.FastRewind
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun RemotePad(vm: TvViewModel) {
var numbers by remember { mutableStateOf(false) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(22.dp),
) {
DPadCluster(vm)
ControlsRow(vm)
MediaRow(vm)
ExtrasRow(vm, numbers) { numbers = !numbers }
if (numbers) NumberPad(vm)
Spacer(Modifier.height(4.dp))
}
}
/** D-pad on a raised disc, with Settings / Info / Exit / Guide in the corners. */
@Composable
private fun DPadCluster(vm: TvViewModel) {
val t = LocalRemote.current
Box(Modifier.fillMaxWidth().height(250.dp)) {
val corner = 62.dp
RemoteKey({ vm.button("QMENU") }, Modifier.align(Alignment.TopStart), size = corner) { KeyLabel("SETTINGS", size = 8.sp) }
RemoteKey({ vm.button("INFO") }, Modifier.align(Alignment.TopEnd), size = corner) { KeyLabel("INFO", size = 9.sp) }
RemoteKey({ vm.button("EXIT") }, Modifier.align(Alignment.BottomStart), size = corner) { KeyLabel("EXIT", size = 9.sp) }
RemoteKey({ vm.button("GUIDE") }, Modifier.align(Alignment.BottomEnd), size = corner) { KeyLabel("GUIDE", size = 9.sp) }
Box(Modifier.size(220.dp).align(Alignment.Center).soft(t, CircleShape, elevation = 14.dp)) {
val arrow = 70.dp
RemoteKey({ vm.button("UP") }, Modifier.align(Alignment.TopCenter), size = arrow, flat = true, repeat = true) {
KeyIcon(Icons.Rounded.KeyboardArrowUp, "Up", 30.dp)
}
RemoteKey({ vm.button("DOWN") }, Modifier.align(Alignment.BottomCenter), size = arrow, flat = true, repeat = true) {
KeyIcon(Icons.Rounded.KeyboardArrowDown, "Down", 30.dp)
}
RemoteKey({ vm.button("LEFT") }, Modifier.align(Alignment.CenterStart), size = arrow, flat = true, repeat = true) {
KeyIcon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, "Left", 30.dp)
}
RemoteKey({ vm.button("RIGHT") }, Modifier.align(Alignment.CenterEnd), size = arrow, flat = true, repeat = true) {
KeyIcon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, "Right", 30.dp)
}
RemoteKey({ vm.button("ENTER") }, Modifier.align(Alignment.Center), size = 84.dp) {
Text("OK", color = t.text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
/** Volume rocker | Home, Mute, Back | Channel rocker. */
@Composable
private fun ControlsRow(vm: TvViewModel) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
VolumePill(vm)
Column(
Modifier.weight(1f).padding(horizontal = 14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
RemoteKey({ vm.button("HOME") }, size = 60.dp) { KeyIcon(Icons.Rounded.Home, "Home") }
RemoteKey({ vm.button("MUTE") }, size = 60.dp) { KeyIcon(Icons.AutoMirrored.Rounded.VolumeOff, "Mute", 22.dp) }
}
RemoteKey({ vm.button("BACK") }, Modifier.width(134.dp), size = 60.dp, shape = RoundedCornerShape(30.dp)) {
KeyIcon(Icons.AutoMirrored.Rounded.Undo, "Back")
}
}
Rocker("CH", up = { vm.button("CHANNELUP") }, down = { vm.button("CHANNELDOWN") })
}
}
@Composable
private fun MediaRow(vm: TvViewModel) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteKey({ vm.button("REWIND") }, size = 52.dp) { KeyIcon(Icons.Rounded.FastRewind, "Rewind", 22.dp) }
RemoteKey({ vm.button("PLAY") }, size = 52.dp) { KeyIcon(Icons.Rounded.PlayArrow, "Play", 22.dp) }
RemoteKey({ vm.button("PAUSE") }, size = 52.dp) { KeyIcon(Icons.Rounded.Pause, "Pause", 22.dp) }
RemoteKey({ vm.button("STOP") }, size = 52.dp) { KeyIcon(Icons.Rounded.Stop, "Stop", 22.dp) }
RemoteKey({ vm.button("FASTFORWARD") }, size = 52.dp) { KeyIcon(Icons.Rounded.FastForward, "Fast forward", 22.dp) }
}
}
/** Color keys, the channel list and the number pad toggle. */
@Composable
private fun ExtrasRow(vm: TvViewModel, numbersOpen: Boolean, onNumbers: () -> Unit) {
val t = LocalRemote.current
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
listOf(
"RED" to Color(0xFFE5484D), "GREEN" to Color(0xFF30A46C),
"YELLOW" to Color(0xFFF5D90A), "BLUE" to Color(0xFF3E63DD),
).forEach { (name, color) ->
RemoteKey({ vm.button(name) }, size = 44.dp, shape = RoundedCornerShape(14.dp)) {
Box(Modifier.size(width = 18.dp, height = 6.dp).clip(CircleShape).background(color))
}
}
RemoteKey({ vm.button("LIST") }, size = 44.dp, shape = RoundedCornerShape(14.dp)) { KeyLabel("LIST", size = 9.sp) }
RemoteKey(onNumbers, size = 44.dp, shape = RoundedCornerShape(14.dp)) {
KeyLabel("123", size = 11.sp, color = if (numbersOpen) t.busy else t.text)
}
}
}
@Composable
private fun NumberPad(vm: TvViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("-", "0", "")).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
row.forEach { d ->
if (d.isEmpty()) Spacer(Modifier.size(60.dp))
else RemoteKey({ vm.button(if (d == "-") "DASH" else d) }, size = 60.dp) {
Text(d, color = LocalRemote.current.text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
@Composable
private fun Rocker(label: String, up: () -> Unit, down: () -> Unit) {
Column(
Modifier.width(64.dp).height(176.dp).soft(LocalRemote.current, RoundedCornerShape(32.dp)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
RemoteKey(up, size = 64.dp, flat = true, repeat = true) { KeyIcon(Icons.Rounded.KeyboardArrowUp, "$label up") }
KeyLabel(label, color = LocalRemote.current.textDim)
RemoteKey(down, size = 64.dp, flat = true, repeat = true) { KeyIcon(Icons.Rounded.KeyboardArrowDown, "$label down") }
}
}
/**
* The VOL rocker doubles as a level meter: it fills from the bottom with the TV's volume.
* Tap or hold an end to step; drag up or down anywhere on it to slide straight to a level.
*/
@Composable
private fun VolumePill(vm: TvViewModel) {
val t = LocalRemote.current
val view = LocalView.current
val volume by vm.volume.collectAsState()
val muted by vm.muted.collectAsState()
var dragLevel by remember { mutableStateOf<Int?>(null) }
var pressedEnd by remember { mutableIntStateOf(0) }
var lastSent by remember { mutableLongStateOf(0L) }
val level = dragLevel ?: volume
val known = level != null
// A drag produces a level per frame; the TV only needs a few of them.
fun sendLevel(l: Int, final: Boolean) {
val now = SystemClock.uptimeMillis()
if (final || now - lastSent > 80) {
lastSent = now
vm.setVolume(l)
}
}
Box(
Modifier
.width(64.dp)
.height(176.dp)
.soft(t, RoundedCornerShape(32.dp))
.pointerInput(Unit) {
fun levelAt(y: Float) = ((1f - y / size.height) * 100).roundToInt().coerceIn(0, 100)
awaitEachGesture {
val down = awaitFirstDown()
val end = if (down.position.y < size.height / 2) 1 else -1
pressedEnd = end
fun step() {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
vm.button(if (end > 0) "VOLUMEUP" else "VOLUMEDOWN")
}
// Step on touch-down, like every other key.
step()
var dragging = false
var nextRepeat = down.uptimeMillis + 400
while (true) {
val event = if (dragging) awaitPointerEvent() else {
withTimeoutOrNull((nextRepeat - SystemClock.uptimeMillis()).coerceAtLeast(1)) { awaitPointerEvent() }
}
if (event == null) {
// Held still on an end: repeat like a real rocker.
step()
nextRepeat = SystemClock.uptimeMillis() + 110
continue
}
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
if (dragging) dragLevel?.let { sendLevel(it, final = true) }
break
}
val dy = abs(change.position.y - down.position.y)
if (!dragging && dy > viewConfiguration.touchSlop * 2 && volume != null) {
dragging = true
pressedEnd = 0
}
if (dragging) {
val l = levelAt(change.position.y)
dragLevel = l
sendLevel(l, final = false)
change.consume()
}
}
dragLevel = null
pressedEnd = 0
}
},
) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(if (known) (level ?: 0) / 100f else 0f)
.background((if (muted) t.textDim else t.busy).copy(alpha = 0.3f))
)
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
val pressedTint = t.text.copy(alpha = 0.08f)
Box(
Modifier.size(64.dp).background(if (pressedEnd > 0) pressedTint else Color.Transparent),
contentAlignment = Alignment.Center,
) { KeyIcon(Icons.Rounded.Add, "Volume up") }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (known) Text("$level", color = t.text, fontSize = 15.sp, fontWeight = FontWeight.Bold)
KeyLabel(if (muted) "MUTED" else "VOL", size = if (known) 9.sp else 11.sp, color = t.textDim)
}
Box(
Modifier.size(64.dp).background(if (pressedEnd < 0) pressedTint else Color.Transparent),
contentAlignment = Alignment.Center,
) { KeyIcon(Icons.Rounded.Remove, "Volume down") }
}
}
}
@@ -0,0 +1,413 @@
package app.tvremote.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Cast
import androidx.compose.material.icons.rounded.DarkMode
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Headphones
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Input
import androidx.compose.material.icons.rounded.Keyboard
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.SettingsRemote
import androidx.compose.material.icons.rounded.TouchApp
import androidx.compose.material.icons.rounded.Tv
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.tvremote.audio.ListenService
import app.tvremote.data.Connection.State
import app.tvremote.data.Power
import app.tvremote.data.tv
import kotlinx.coroutines.delay
private enum class Tab(val label: String, val icon: ImageVector) {
Remote("Remote", Icons.Rounded.SettingsRemote),
Touch("Touchpad", Icons.Rounded.TouchApp),
Apps("Apps", Icons.Rounded.Apps),
Sources("Sources", Icons.Rounded.Input),
Listen("Listen", Icons.Rounded.Headphones),
}
@Composable
fun RemoteScreen(vm: TvViewModel) {
val t = LocalRemote.current
val state by vm.state.collectAsState()
val power by vm.power.collectAsState()
val waking by vm.waking.collectAsState()
val listen by vm.listen.collectAsState()
val kbFocused by vm.keyboardFocused.collectAsState()
val casting by vm.casting.collectAsState()
var tab by rememberSaveable { mutableStateOf(Tab.Remote) }
var keyboard by remember { mutableStateOf(false) }
var infoDialog by remember { mutableStateOf(false) }
var renameDialog by remember { mutableStateOf(false) }
val snackbar = remember { SnackbarHostState() }
val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
uri?.let(vm::cast)
}
LaunchedEffect(Unit) { vm.messages.collect { snackbar.showSnackbar(it) } }
// Pop the keyboard when a text field gets focus on the TV.
LaunchedEffect(kbFocused) { if (kbFocused) keyboard = true }
val connected = state is State.Connected
val live = connected && power != Power.Standby
val lamp = lampOf(state, power, waking)
val status = when {
lamp == Lamp.Busy && waking -> "Waking up…"
state is State.Prompt -> "Press Accept on your TV"
state is State.Connecting -> "Connecting…"
listen is ListenService.Status.Playing -> "Private listening on"
lamp == Lamp.On -> "On"
connected -> "Standby · tap power to wake"
(state as? State.Offline)?.certChanged == true -> "Certificate changed"
state is State.Offline -> "Off · tap power to wake"
else -> "Not connected"
}
Box(Modifier.fillMaxSize().background(t.background).safeDrawingPadding()) {
Column(Modifier.fillMaxSize()) {
TopBar(
name = state.tv?.name ?: "LG Slim",
status = status,
lamp = lamp,
live = live,
waking = waking,
connected = connected,
kbFocused = kbFocused,
vm = vm,
onKeyboard = { keyboard = true },
onCast = { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) },
onInfo = { infoDialog = true },
onRename = { renameDialog = true },
)
if ((state as? State.Offline)?.certChanged == true) CertChangedBanner(vm)
casting?.let { CastingBanner(it, vm::stopCast) }
// Keys stay usable (standby still takes some), but look asleep until the TV is on.
Box(Modifier.weight(1f).fillMaxWidth().alpha(if (live) 1f else 0.5f)) {
when (tab) {
Tab.Remote -> RemotePad(vm)
Tab.Touch -> Touchpad(vm)
Tab.Apps -> AppsTab(vm)
Tab.Sources -> SourcesTab(vm)
Tab.Listen -> ListenTab(vm)
}
}
TabBar(tab) { tab = it }
}
SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp))
}
if (state is State.Prompt) PairingDialog(onCancel = vm::openDevices)
if (keyboard && connected) KeyboardSheet(vm, onDismiss = { keyboard = false })
if (infoDialog) InfoDialog(vm, onDismiss = { infoDialog = false })
state.tv?.takeIf { renameDialog }?.let { tv ->
RenameDialog(tv.name, onDismiss = { renameDialog = false }) { vm.rename(tv, it); renameDialog = false }
}
}
@Composable
private fun TopBar(
name: String,
status: String,
lamp: Lamp,
live: Boolean,
waking: Boolean,
connected: Boolean,
kbFocused: Boolean,
vm: TvViewModel,
onKeyboard: () -> Unit,
onCast: () -> Unit,
onInfo: () -> Unit,
onRename: () -> Unit,
) {
val t = LocalRemote.current
var menu by remember { mutableStateOf(false) }
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RemoteKey(vm::togglePower, enabled = !waking) {
KeyIcon(Icons.Rounded.PowerSettingsNew, if (live) "Turn off" else "Turn on", tint = t.power)
}
Spacer(Modifier.width(14.dp))
Box(Modifier.weight(1f)) {
RemoteKey({ menu = true }, Modifier.fillMaxWidth(), size = 56.dp, shape = RoundedCornerShape(28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatusLamp(lamp)
Spacer(Modifier.width(10.dp))
Column {
Text(name, color = t.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(status, color = t.textDim, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Switch TV") }, leadingIcon = { Icon(Icons.Rounded.Tv, null) },
onClick = { menu = false; vm.openDevices() },
)
DropdownMenuItem(
text = { Text("Rename TV") }, leadingIcon = { Icon(Icons.Rounded.Edit, null) },
onClick = { menu = false; onRename() },
)
DropdownMenuItem(
text = { Text("Show photo or video") }, leadingIcon = { Icon(Icons.Rounded.Cast, null) },
enabled = connected, onClick = { menu = false; onCast() },
)
DropdownMenuItem(
text = { Text("TV info") }, leadingIcon = { Icon(Icons.Rounded.Info, null) },
enabled = connected, onClick = { menu = false; onInfo() },
)
DropdownMenuItem(
text = { Text(if (t.dark) "Light mode" else "Dark mode") },
leadingIcon = { Icon(if (t.dark) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, null) },
onClick = { menu = false; vm.toggleTheme() },
)
}
}
Spacer(Modifier.width(14.dp))
RemoteKey(onKeyboard, enabled = connected) {
KeyIcon(Icons.Rounded.Keyboard, "Keyboard", size = 22.dp, tint = if (kbFocused) t.busy else t.text)
}
}
}
/** The five sections, on one soft pill like the rest of the remote. */
@Composable
private fun TabBar(current: Tab, onSelect: (Tab) -> Unit) {
val t = LocalRemote.current
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.height(64.dp)
.soft(t, RoundedCornerShape(32.dp), elevation = 10.dp)
.padding(horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Tab.entries.forEach { tab ->
val selected = tab == current
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(vertical = 6.dp)
.clip(RoundedCornerShape(24.dp))
.background(if (selected) t.text.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent)
.clickable(remember { MutableInteractionSource() }, indication = null) { onSelect(tab) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val tint = if (selected) (if (t.dark) t.accent else t.text) else t.textDim
Icon(tab.icon, null, tint = tint, modifier = Modifier.size(22.dp))
Text(tab.label, color = tint, fontSize = 10.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
}
}
}
}
/** On = green, Busy (connecting, waking, pairing) = blue and pulsing, Off/standby = dark. */
private enum class Lamp { On, Busy, Off }
private fun lampOf(state: State, power: Power, waking: Boolean): Lamp = when {
state is State.Connected && power != Power.Standby -> Lamp.On
waking || state is State.Connecting || state is State.Prompt -> Lamp.Busy
else -> Lamp.Off
}
@Composable
private fun StatusLamp(lamp: Lamp) {
val t = LocalRemote.current
val color = when (lamp) {
Lamp.On -> t.online
Lamp.Busy -> t.busy
Lamp.Off -> t.textDim.copy(alpha = 0.5f)
}
val animated by animateColorAsState(color, tween(250), label = "lamp")
val pulse = rememberInfiniteTransition(label = "pulse")
val alpha by pulse.animateFloat(
initialValue = 1f, targetValue = if (lamp == Lamp.Busy) 0.3f else 1f,
animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "alpha",
)
Box(
Modifier
.size(8.dp)
.alpha(if (lamp == Lamp.Busy) alpha else 1f)
.clip(CircleShape)
.background(animated)
)
}
@Composable
private fun CertChangedBanner(vm: TvViewModel) {
val t = LocalRemote.current
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 4.dp)
.soft(t, RoundedCornerShape(20.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("This TV's security certificate changed", color = t.power, fontWeight = FontWeight.SemiBold)
Text(
"That's expected after a TV software update. If you didn't update it, another device on your " +
"network may be pretending to be your TV. Re-pairing asks for approval on the TV screen again.",
color = t.textDim, fontSize = 12.sp,
)
Button(onClick = vm::repair) { Text("Re-pair") }
}
}
@Composable
private fun CastingBanner(title: String, onStop: () -> Unit) {
val t = LocalRemote.current
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 4.dp)
.soft(t, RoundedCornerShape(24.dp))
.padding(start = 16.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Cast, null, tint = t.text)
Text("Showing on TV: $title", Modifier.weight(1f).padding(start = 12.dp), color = t.text, fontSize = 14.sp)
TextButton(onClick = onStop) { Text("Stop") }
}
}
/** Live typing: the field's edits are mirrored to the focused text box on the TV. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun KeyboardSheet(vm: TvViewModel, onDismiss: () -> Unit) {
var text by remember { mutableStateOf("") }
val focus = remember { FocusRequester() }
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = LocalRemote.current.surfaceHigh) {
Column(Modifier.padding(16.dp).imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = text,
onValueChange = { new ->
val old = text
when {
new.startsWith(old) -> vm.typeText(new.substring(old.length))
old.startsWith(new) -> vm.deleteChars(old.length - new.length)
else -> { vm.deleteChars(old.length); vm.typeText(new) }
}
text = new
},
label = { Text("Type on TV") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { vm.sendEnter(); text = "" }),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { vm.deleteChars(text.length.coerceAtLeast(1)); text = "" }) { Text("Clear") }
Button(onClick = { vm.sendEnter(); text = "" }) { Text("Enter") }
}
}
}
LaunchedEffect(Unit) { delay(200); runCatching { focus.requestFocus() } }
}
@Composable
private fun InfoDialog(vm: TvViewModel, onDismiss: () -> Unit) {
val info by vm.info.collectAsState()
AlertDialog(
onDismissRequest = onDismiss,
containerColor = LocalRemote.current.surfaceHigh,
title = { Text("TV info") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
info.forEach { (k, v) ->
Column {
Text(k, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(v, style = MaterialTheme.typography.bodyMedium)
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/** An image served by the TV (app and input icons). */
@Composable
fun TvImage(vm: TvViewModel, url: String?, modifier: Modifier = Modifier) {
val bmp by produceState<ImageBitmap?>(null, url) { value = url?.let { vm.icon(it) } }
Box(modifier.clip(RoundedCornerShape(12.dp)).background(LocalRemote.current.surfaceLow)) {
bmp?.let { Image(it, null, Modifier.fillMaxSize()) }
}
}
+322
View File
@@ -0,0 +1,322 @@
package app.tvremote.ui
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import app.tvremote.audio.ListenService
import kotlin.math.abs
// ---- Touchpad ---------------------------------------------------------------
/** One finger moves the pointer, a tap clicks, two fingers scroll. */
@Composable
fun Touchpad(vm: TvViewModel) {
val density = LocalDensity.current.density
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Box(
Modifier
.fillMaxWidth()
.weight(1f)
.soft(LocalRemote.current, RoundedCornerShape(28.dp), pressed = true, elevation = 0.dp)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown()
var fingers = 1
var travelled = 0f
var acc = Offset.Zero
do {
val event = awaitPointerEvent()
fingers = maxOf(fingers, event.changes.count { it.pressed })
val pan = event.calculatePan()
travelled += abs(pan.x) + abs(pan.y)
acc += pan / density * 1.6f
val dx = acc.x.toInt()
val dy = acc.y.toInt()
if (dx != 0 || dy != 0) {
if (fingers >= 2) vm.scroll(dx, dy) else vm.move(dx, dy)
acc -= Offset(dx.toFloat(), dy.toFloat())
}
event.changes.forEach { it.consume() }
} while (event.changes.any { it.pressed })
if (fingers == 1 && travelled < viewConfiguration.touchSlop) vm.click()
}
},
contentAlignment = Alignment.Center,
) {
Text(
"Drag to move · Tap to click\nTwo fingers to scroll",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteKey({ vm.button("BACK") }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
RemoteKey({ vm.button("HOME") }) { Icon(Icons.Filled.Home, "Home") }
RemoteKey(vm::volumeDown, repeat = true) { Icon(Icons.Filled.Remove, "Volume down") }
RemoteKey(vm::volumeUp, repeat = true) { Icon(Icons.Filled.Add, "Volume up") }
}
}
}
// ---- Apps -------------------------------------------------------------------
@Composable
fun AppsTab(vm: TvViewModel) {
val apps by vm.apps.collectAsState()
val current by vm.foregroundApp.collectAsState()
if (apps.isEmpty()) {
Empty("Connect to your TV to load your apps.")
return
}
Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(22.dp, Alignment.CenterVertically),
) {
apps.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
row.forEach { app -> AppTile(vm, app, active = app.id == current, Modifier.weight(1f)) }
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
/** A big soft key with the app's icon; a green dot marks the app that's on screen. */
@Composable
private fun AppTile(vm: TvViewModel, app: TvAppItem, active: Boolean, modifier: Modifier) {
val t = LocalRemote.current
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
RemoteKey({ vm.launch(app) }, Modifier.fillMaxWidth().aspectRatio(1f), size = 160.dp, shape = RoundedCornerShape(32.dp)) {
TvImage(vm, app.icon, Modifier.fillMaxSize(0.62f))
}
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
if (active) {
Box(Modifier.size(7.dp).clip(CircleShape).background(t.online))
Spacer(Modifier.width(7.dp))
}
Text(
app.title, color = if (active) t.text else t.textDim,
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ---- Sources ----------------------------------------------------------------
@Composable
fun SourcesTab(vm: TvViewModel) {
val inputs by vm.inputs.collectAsState()
val channels by vm.channels.collectAsState()
val current by vm.currentChannel.collectAsState()
val currentApp by vm.foregroundApp.collectAsState()
var query by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(channels) { if (channels != null) loading = false }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item { Section("Inputs") }
items(inputs, key = { "in-" + it.id }) { input ->
ListItem(
headlineContent = { Text(input.label) },
supportingContent = { Text(if (input.connected) "Connected" else "Nothing plugged in") },
leadingContent = { TvImage(vm, input.icon, Modifier.size(40.dp)) },
colors = if (currentApp?.contains(input.id.lowercase().replace("_", "")) == true)
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
else ListItemDefaults.colors(),
modifier = Modifier.clickable { vm.switchInput(input) },
)
}
item { Section("Channels") }
when (val list = channels) {
null -> item {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
FilledTonalButton(onClick = { loading = true; vm.loadChannels() }, enabled = !loading) {
Text("Load channel list")
}
if (loading) CircularProgressIndicator(Modifier.padding(start = 16.dp).size(20.dp), strokeWidth = 2.dp)
}
}
else -> {
item {
OutlinedTextField(
value = query, onValueChange = { query = it }, singleLine = true,
label = { Text("Search ${list.size} channels") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
}
val shown = list.filter {
query.isBlank() || it.name.contains(query, ignoreCase = true) || it.number.startsWith(query)
}
items(shown, key = { "ch-" + it.id }) { ch ->
ListItem(
headlineContent = { Text(ch.name.ifBlank { "Channel ${ch.number}" }) },
leadingContent = { Text(ch.number, style = MaterialTheme.typography.titleMedium, modifier = Modifier.width(56.dp)) },
colors = if (ch.id == current)
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
else ListItemDefaults.colors(),
modifier = Modifier.clickable { vm.openChannel(ch) },
)
}
}
}
}
}
// ---- Private listening ------------------------------------------------------
@Composable
fun ListenTab(vm: TvViewModel) {
val status by vm.listen.collectAsState()
val notify = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { vm.startListening() }
val start: () -> Unit = {
if (Build.VERSION.SDK_INT >= 33) notify.launch(Manifest.permission.POST_NOTIFICATIONS) else vm.startListening()
}
val active = status is ListenService.Status.Playing || status is ListenService.Status.Starting
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(Modifier.height(8.dp))
RemoteKey({ if (active) vm.stopListening() else start() }, size = 160.dp) {
Icon(
Icons.Filled.Headphones, if (active) "Stop private listening" else "Start private listening",
Modifier.size(64.dp),
tint = if (active) LocalRemote.current.busy else LocalRemote.current.text,
)
}
Text(
when (val s = status) {
ListenService.Status.Off -> "Private listening is off"
is ListenService.Status.Starting -> s.step + "…"
ListenService.Status.Playing -> "Listening on this phone"
is ListenService.Status.Failed -> s.reason
},
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
color = if (status is ListenService.Status.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
if (active) {
Button(onClick = vm::stopListening, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Text("Stop")
}
} else {
Button(onClick = start) { Text("Start private listening") }
}
Text(
"TV sound plays through this phone, so you can use headphones. The TV's speakers go quiet while you listen. " +
"Audio goes straight from the TV to the phone over your Wi-Fi. Use the phone's volume buttons for headphone volume.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = vm::screenOff) { Text("Screen off") }
OutlinedButton(onClick = vm::screenOn) { Text("Screen on") }
}
Text(
"Turn the screen off to listen to audio only.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// ---- shared -----------------------------------------------------------------
@Composable
private fun Section(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun Empty(text: String) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(text, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
+202
View File
@@ -0,0 +1,202 @@
package app.tvremote.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Soft, top-lit remote look (after CaveRemote), in a dark and a light variant. */
data class RemoteTheme(
val dark: Boolean,
val background: Color,
/** Key fill, top and bottom of the gradient. */
val surfaceHigh: Color,
val surfaceLow: Color,
/** Hairline edge, lit from above. */
val edgeLight: Color,
val edgeDark: Color,
val shadow: Color,
val text: Color,
val textDim: Color,
val accent: Color,
val onAccent: Color,
val power: Color,
val online: Color,
val busy: Color,
)
val DarkRemote = RemoteTheme(
dark = true,
background = Color(0xFF000000),
surfaceHigh = Color(0xFF1C1C1E),
surfaceLow = Color(0xFF0E0E10),
edgeLight = Color(0xFF34343A),
edgeDark = Color(0xFF0A0A0B),
shadow = Color(0xFF000000),
text = Color(0xFFEDEDED),
textDim = Color(0xFF8E8E93),
accent = Color(0xFF2D8CFF),
onAccent = Color(0xFFFFFFFF),
power = Color(0xFFFF4D5E),
online = Color(0xFF34D27B),
busy = Color(0xFF2D8CFF),
)
val LightRemote = RemoteTheme(
dark = false,
background = Color(0xFFECEDF0),
surfaceHigh = Color(0xFFFFFFFF),
surfaceLow = Color(0xFFF1F2F5),
edgeLight = Color(0xFFFFFFFF),
edgeDark = Color(0xFFD9DCE2),
shadow = Color(0xFF7D8699),
text = Color(0xFF1C1C1E),
textDim = Color(0xFF6E6E73),
accent = Color(0xFF1C1C1E),
onAccent = Color(0xFFFFFFFF),
power = Color(0xFFE5484D),
online = Color(0xFF22B864),
busy = Color(0xFF2D7FF0),
)
val LocalRemote = staticCompositionLocalOf { DarkRemote }
/** Provides the remote theme and maps it onto Material so lists, fields and dialogs match. */
@Composable
fun AppTheme(t: RemoteTheme, content: @Composable () -> Unit) {
val base = if (t.dark) darkColorScheme() else lightColorScheme()
val scheme = base.copy(
primary = t.accent, onPrimary = t.onAccent,
primaryContainer = t.surfaceHigh, onPrimaryContainer = t.text,
secondary = t.accent, onSecondary = t.onAccent,
secondaryContainer = t.surfaceHigh, onSecondaryContainer = t.text,
tertiaryContainer = t.surfaceHigh, onTertiaryContainer = t.text,
background = t.background, onBackground = t.text,
surface = t.background, onSurface = t.text,
surfaceVariant = t.surfaceHigh, onSurfaceVariant = t.textDim,
surfaceContainerLowest = t.background, surfaceContainerLow = t.surfaceLow,
surfaceContainer = t.surfaceLow, surfaceContainerHigh = t.surfaceHigh,
surfaceContainerHighest = t.surfaceHigh,
outline = t.edgeDark, outlineVariant = t.edgeDark,
error = t.power, errorContainer = t.power.copy(alpha = 0.18f), onErrorContainer = t.text,
)
CompositionLocalProvider(LocalRemote provides t) {
MaterialTheme(colorScheme = scheme, content = content)
}
}
/** Raised, softly lit surface; [pressed] flips the light so it reads as pushed in. */
fun Modifier.soft(t: RemoteTheme, shape: Shape, pressed: Boolean = false, elevation: Dp = 8.dp): Modifier = this
.shadow(if (pressed) 1.dp else elevation, shape, ambientColor = t.shadow, spotColor = t.shadow)
.clip(shape)
.background(
Brush.verticalGradient(
if (pressed) listOf(t.surfaceLow, t.surfaceHigh) else listOf(t.surfaceHigh, t.surfaceLow)
)
)
.border(1.dp, Brush.verticalGradient(listOf(t.edgeLight, t.edgeDark)), shape)
/**
* A remote key: fires on touch-down for instant feel. With [repeat], holding it fires
* continuously (volume, arrows). With [flat], it draws no surface of its own (keys sitting
* on the D-pad or a rocker).
*/
@Composable
fun RemoteKey(
onPress: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 56.dp,
shape: Shape = CircleShape,
repeat: Boolean = false,
flat: Boolean = false,
enabled: Boolean = true,
content: @Composable BoxScope.() -> Unit,
) {
val t = LocalRemote.current
val view = LocalView.current
val scope = rememberCoroutineScope()
val press by rememberUpdatedState(onPress)
val on by rememberUpdatedState(enabled)
var pressed by remember { mutableStateOf(false) }
val surface = if (flat) {
Modifier.clip(shape).background(if (pressed) t.text.copy(alpha = 0.08f) else Color.Transparent)
} else {
Modifier.soft(t, shape, pressed)
}
Box(
contentAlignment = Alignment.Center,
content = content,
modifier = modifier
.size(size)
.alpha(if (enabled) 1f else 0.45f)
.then(surface)
.pointerInput(repeat) {
detectTapGestures(onPress = {
if (!on) return@detectTapGestures
pressed = true
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
press()
val repeater = if (repeat) scope.launch {
delay(400)
while (true) { press(); delay(110) }
} else null
tryAwaitRelease()
repeater?.cancel()
pressed = false
})
},
)
}
@Composable
fun KeyIcon(icon: ImageVector, desc: String?, size: Dp = 24.dp, tint: Color = LocalRemote.current.text) =
Icon(icon, contentDescription = desc, tint = tint, modifier = Modifier.size(size))
@Composable
fun KeyLabel(text: String, size: TextUnit = 11.sp, color: Color = LocalRemote.current.text) =
Text(
text,
color = color,
fontSize = size,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp,
textAlign = TextAlign.Center,
maxLines = 1,
)
@@ -0,0 +1,339 @@
package app.tvremote.ui
import android.app.Application
import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.tvremote.TvApp
import app.tvremote.audio.ListenService
import app.tvremote.data.Connection.State
import app.tvremote.data.SavedTv
import app.tvremote.data.tv
import app.tvremote.net.Dlna
import app.tvremote.net.MediaServer
import app.tvremote.net.volumeOf
import android.net.Uri
import app.tvremote.net.Discovery
import app.tvremote.net.FoundTv
import app.tvremote.net.Lan
import app.tvremote.net.WakeOnLan
import app.tvremote.net.WebOsClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
data class TvAppItem(val id: String, val title: String, val icon: String?)
data class InputItem(val id: String, val label: String, val icon: String?, val connected: Boolean)
data class Channel(val id: String, val number: String, val name: String)
class TvViewModel(app: Application) : AndroidViewModel(app) {
private val tvApp = app as TvApp
private val store = tvApp.store
private val connection = tvApp.connection
val saved: StateFlow<List<SavedTv>> = store.tvs
val state: StateFlow<State> = connection.state
val power = connection.power
val waking = connection.waking
val listen = ListenService.status
val dark = MutableStateFlow(store.dark)
fun toggleTheme() {
dark.value = !dark.value
store.dark = dark.value
}
val found = MutableStateFlow<List<FoundTv>>(emptyList())
val scanning = MutableStateFlow(false)
val showDevices = MutableStateFlow(store.lastUuid == null)
val volume = MutableStateFlow<Int?>(null)
val muted = MutableStateFlow(false)
val foregroundApp = MutableStateFlow<String?>(null)
val apps = MutableStateFlow<List<TvAppItem>>(emptyList())
val inputs = MutableStateFlow<List<InputItem>>(emptyList())
val channels = MutableStateFlow<List<Channel>?>(null)
val currentChannel = MutableStateFlow<String?>(null)
val keyboardFocused = MutableStateFlow(false)
val info = MutableStateFlow<List<Pair<String, String>>>(emptyList())
private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 4)
val messages: SharedFlow<String> = _messages
private val icons = LruCache<String, ImageBitmap>(96)
private var sessionJob: Job? = null
init {
viewModelScope.launch {
connection.state.collect { s ->
sessionJob?.cancel()
if (s is State.Connected) sessionJob = launch(Dispatchers.IO) { startSession(s.client) }
}
}
}
// ---- connection -------------------------------------------------------
fun onForeground() = connection.resume()
fun scan() {
if (scanning.value) return
viewModelScope.launch {
scanning.value = true
val list = runCatching { Discovery.find(getApplication()) }.getOrDefault(emptyList())
found.value = list
// A saved TV that got a new DHCP lease: follow it.
list.forEach { f -> store.find(f.uuid)?.takeIf { it.ip != f.ip }?.let { store.upsert(it.copy(ip = f.ip)) } }
scanning.value = false
}
}
fun pair(found: FoundTv) {
val tv = store.tvs.value.firstOrNull { it.uuid == found.uuid || it.ip == found.ip }
?: SavedTv(uuid = found.uuid ?: found.ip, name = found.name, ip = found.ip)
connect(tv)
}
fun addByIp(ip: String): Boolean {
val clean = ip.trim()
if (!Lan.isPrivate(clean)) return false
pair(FoundTv(clean, "LG TV", null))
return true
}
fun connect(tv: SavedTv) {
connection.connect(tv)
showDevices.value = false
}
fun forget(tv: SavedTv) {
if (connection.state.value.tv?.uuid == tv.uuid) connection.disconnect()
store.remove(tv.uuid)
}
fun rename(tv: SavedTv, name: String) {
val clean = name.trim()
if (clean.isNotEmpty()) connection.rename(tv.uuid, clean)
}
fun repair() {
connection.state.value.tv?.let(connection::repair)
}
fun openDevices() {
showDevices.value = true
scan()
}
fun closeDevices() {
if (connection.state.value != State.Idle) showDevices.value = false
}
// ---- live state -------------------------------------------------------
private suspend fun startSession(c: WebOsClient) {
channels.value = null
c.subscribe("audio/getVolume") { p ->
val (level, mute) = volumeOf(p)
volume.value = level
muted.value = mute
}
c.subscribe("com.webos.applicationManager/getForegroundAppInfo") { p ->
foregroundApp.value = p.optString("appId").takeIf { it.isNotBlank() }
}
c.subscribe("com.webos.service.ime/registerRemoteKeyboard") { p ->
keyboardFocused.value = p.optJSONObject("currentWidget")?.optBoolean("focus") == true
}
runCatching {
c.subscribe("tv/getCurrentChannel") { p ->
currentChannel.value = p.optString("channelId").takeIf { it.isNotBlank() }
}
}
runCatching { loadApps(c) }
runCatching { loadInputs(c) }
runCatching { loadInfo(c) }
runCatching { c.pointer() }
}
private suspend fun loadApps(c: WebOsClient) {
val points = c.request("com.webos.applicationManager/listLaunchPoints").optJSONArray("launchPoints")
val all = points.objects().associateBy { it.optString("id") }
// Only the apps you actually use, in your order. The large icon is the sharp one.
apps.value = PINNED_APPS.mapNotNull { id ->
val p = all[id] ?: return@mapNotNull null
val icon = p.optString("largeIcon").ifBlank { p.optString("icon") }.takeIf { it.isNotBlank() }
TvAppItem(id, p.optString("title"), icon)
}
}
private suspend fun loadInputs(c: WebOsClient) {
val devices = c.request("tv/getExternalInputList").optJSONArray("devices")
inputs.value = devices.objects().map {
InputItem(
it.optString("id"), it.optString("label").ifBlank { it.optString("id") },
it.optString("icon").takeIf { s -> s.isNotBlank() }, it.optBoolean("connected"),
)
}
}
private suspend fun loadInfo(c: WebOsClient) {
val sys = runCatching { c.request("system/getSystemInfo") }.getOrNull()
val sw = runCatching { c.request("com.webos.service.update/getCurrentSWInformation") }.getOrNull()
val tv = (connection.state.value as? State.Connected)?.tv
info.value = listOfNotNull(
sys?.optString("modelName")?.let { "Model" to it },
sw?.optString("product_name")?.let { "webOS" to it },
sw?.let { "Firmware" to "${it.optString("major_ver")}.${it.optString("minor_ver")}" },
tv?.let { "IP address" to it.ip },
tv?.macs?.takeIf { it.isNotEmpty() }?.let { "MAC" to it.joinToString("\n") },
c.hello?.optString("deviceUUID")?.takeIf { it.isNotBlank() }?.let { "Device ID" to it },
).filter { it.second.isNotBlank() }
}
fun loadChannels() = command {
val list = it.request("tv/getChannelList", timeoutMs = 20_000).optJSONArray("channelList")
channels.value = list.objects().map { ch ->
Channel(ch.optString("channelId"), ch.optString("channelNumber"), ch.optString("channelName"))
}
}
// ---- commands ---------------------------------------------------------
private fun command(block: suspend (WebOsClient) -> Unit) {
val c = connection.client ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { block(c) }.onFailure { e -> _messages.tryEmit(e.message ?: "Command failed") }
}
}
private fun call(uri: String, payload: JSONObject? = null) = command { it.request(uri, payload) }
/** Straight onto the open pointer socket when there is one: no coroutine, no hop. */
fun button(name: String) {
if (connection.client?.buttonNow(name) == true) return
command { it.pointer().button(name) }
}
fun move(dx: Int, dy: Int) = command { it.pointer().move(dx, dy) }
fun click() = command { it.pointer().click() }
fun scroll(dx: Int, dy: Int) = command { it.pointer().scroll(dx, dy) }
// Keys go over the pointer socket: no request/response round trip, so they feel instant.
fun volumeUp() = button("VOLUMEUP")
fun volumeDown() = button("VOLUMEDOWN")
fun setVolume(level: Int) = call("audio/setVolume", JSONObject().put("volume", level))
fun launch(app: TvAppItem) = call("system.launcher/launch", JSONObject().put("id", app.id))
fun switchInput(input: InputItem) = call("tv/switchInput", JSONObject().put("inputId", input.id))
fun openChannel(ch: Channel) = call("tv/openChannel", JSONObject().put("channelId", ch.id))
fun powerOff() {
if (listen.value != ListenService.Status.Off) ListenService.stop(getApplication())
call("system/turnOff")
}
fun screenOff() = command { c ->
val p = JSONObject().put("standbyMode", "active")
runCatching { c.request("com.webos.service.tvpower/power/turnOffScreen", p) }
.recoverCatching { c.request("com.webos.service.tv.power/turnOffScreen", p) }
.getOrThrow()
}
fun screenOn() = command { c ->
val p = JSONObject().put("standbyMode", "active")
runCatching { c.request("com.webos.service.tvpower/power/turnOnScreen", p) }
.recoverCatching { c.request("com.webos.service.tv.power/turnOnScreen", p) }
.getOrThrow()
}
/** On: turn off. Off or in standby (socket still open): Wake-on-LAN. */
fun togglePower() = if (connection.isLive) powerOff() else powerOn()
fun powerOn() {
val tv = connection.state.value.tv ?: store.find(store.lastUuid) ?: return
if (tv.macs.isEmpty()) {
_messages.tryEmit("TV's MAC address is unknown. Connect once while it's on.")
return
}
connection.wake()
}
fun typeText(text: String) = call("com.webos.service.ime/insertText", JSONObject().put("text", text).put("replace", 0))
fun deleteChars(count: Int) = call("com.webos.service.ime/deleteCharacters", JSONObject().put("count", count))
fun sendEnter() = call("com.webos.service.ime/sendEnterKey")
// ---- photos & videos ---------------------------------------------------
/** Title of what's showing on the TV from this phone, or null. */
val casting = MutableStateFlow<String?>(null)
private var mediaServer: MediaServer? = null
fun cast(uri: Uri) = command { c ->
stopCastNow(c)
casting.value = "Preparing…"
try {
val server = MediaServer.serve(getApplication(), c.ip, uri)
mediaServer = server
val title = if (server.mimeType.startsWith("image/")) "Photo" else "Video"
Dlna.play(c.ip, server.url, server.mimeType, title)
casting.value = title
} catch (e: Exception) {
stopCastNow(c)
throw e
}
}
fun stopCast() = command { stopCastNow(it) }
private suspend fun stopCastNow(c: WebOsClient) {
if (mediaServer != null) Dlna.stop(c.ip)
mediaServer?.close()
mediaServer = null
casting.value = null
}
override fun onCleared() {
mediaServer?.close()
}
fun startListening() = ListenService.start(getApplication())
fun stopListening() = ListenService.stop(getApplication())
/** App/input icons are served by the TV itself; anything off-LAN is never fetched. */
suspend fun icon(url: String): ImageBitmap? {
icons.get(url)?.let { return it }
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return null
if (!Lan.isPrivate(host)) return null
return withContext(Dispatchers.IO) {
runCatching {
Lan.http.newCall(Request.Builder().url(url).build()).execute().use { r ->
val bytes = r.body?.bytes() ?: return@use null
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}
}.getOrNull()?.also { icons.put(url, it) }
}
}
private companion object {
val PINNED_APPS = listOf(
"org.jellyfin.webos", // Jellyfin
"amazon", // Prime Video
"youtube.leanback.v4", // YouTube AdFree
"com.instantbits.cast.webvideo", // Web Video Caster
)
}
private fun JSONArray?.objects(): List<JSONObject> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
}
@@ -0,0 +1,50 @@
package app.tvremote.widget
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import app.tvremote.TvApp
import app.tvremote.audio.ListenService
import app.tvremote.ui.MainActivity
/** Quick-settings toggle for private listening. */
class ListenTile : TileService() {
override fun onStartListening() = refresh()
@SuppressLint("StartActivityAndCollapseDeprecated")
override fun onClick() {
try {
QuickActions.toggleListening(application as TvApp)
} catch (_: Exception) {
// Android refused a background start: open the app instead.
val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= 34) {
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
} else {
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
}
}
refresh()
}
private fun refresh() {
val tile = qsTile ?: return
val status = ListenService.status.value
tile.state = when (status) {
is ListenService.Status.Playing, is ListenService.Status.Starting -> Tile.STATE_ACTIVE
else -> Tile.STATE_INACTIVE
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
tile.subtitle = when (status) {
is ListenService.Status.Playing -> "On"
is ListenService.Status.Starting -> "Starting…"
is ListenService.Status.Failed -> "Failed"
ListenService.Status.Off -> "Off"
}
}
tile.updateTile()
}
}
@@ -0,0 +1,49 @@
package app.tvremote.widget
import app.tvremote.TvApp
import app.tvremote.audio.ListenService
import app.tvremote.net.volumeOf
import org.json.JSONObject
/** The commands the widget can send without opening the app. */
object QuickActions {
const val POWER = "power"
const val VOLUME_UP = "volume_up"
const val VOLUME_DOWN = "volume_down"
const val MUTE = "mute"
const val LISTEN = "listen"
suspend fun run(app: TvApp, action: String) {
when (action) {
LISTEN -> toggleListening(app)
POWER -> {
// The connect probe takes ~1.5 s to call an off TV off, then we wake it.
app.connection.ensureConnected(timeoutMs = 4_000)
val c = app.connection.client
if (c != null && app.connection.isLive) {
if (ListenService.status.value != ListenService.Status.Off) ListenService.stop(app)
c.request("system/turnOff")
} else {
app.connection.wake()
}
}
else -> {
val c = app.connection.ensureConnected() ?: return
when (action) {
VOLUME_UP -> c.request("audio/volumeUp")
VOLUME_DOWN -> c.request("audio/volumeDown")
MUTE -> {
val (_, muted) = volumeOf(c.request("audio/getVolume"))
c.request("audio/setMute", JSONObject().put("mute", !muted))
}
}
}
}
}
fun toggleListening(app: TvApp) {
val s = ListenService.status.value
if (s is ListenService.Status.Playing || s is ListenService.Status.Starting) ListenService.stop(app)
else ListenService.start(app)
}
}
@@ -0,0 +1,55 @@
package app.tvremote.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import app.tvremote.R
import app.tvremote.TvApp
import kotlinx.coroutines.launch
/** Home-screen strip: power, volume, mute and private listening. */
class RemoteWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
val views = RemoteViews(context.packageName, R.layout.widget_remote).apply {
bind(context, R.id.power, QuickActions.POWER)
bind(context, R.id.volume_down, QuickActions.VOLUME_DOWN)
bind(context, R.id.mute, QuickActions.MUTE)
bind(context, R.id.volume_up, QuickActions.VOLUME_UP)
bind(context, R.id.listen, QuickActions.LISTEN)
}
ids.forEach { manager.updateAppWidget(it, views) }
}
private fun RemoteViews.bind(context: Context, id: Int, action: String) {
val intent = Intent(context, WidgetActionReceiver::class.java).putExtra(WidgetActionReceiver.EXTRA, action)
setOnClickPendingIntent(id, PendingIntent.getBroadcast(context, id, intent, PendingIntent.FLAG_IMMUTABLE))
}
}
/** Not exported, so other apps can't drive the TV through it. */
class WidgetActionReceiver : BroadcastReceiver() {
companion object { const val EXTRA = "action" }
override fun onReceive(context: Context, intent: Intent) {
val action = intent.getStringExtra(EXTRA) ?: return
val app = context.applicationContext as TvApp
if (action == QuickActions.LISTEN) {
// Start the service now, while the tap still allows a foreground service.
QuickActions.toggleListening(app)
return
}
val pending = goAsync()
app.scope.launch {
try {
QuickActions.run(app, action)
} catch (_: Exception) {
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF"
android:pathData="M12,3a9,9 0,0 0,-9 9v7a2,2 0,0 0,2 2h2v-8H5v-1a7,7 0,0 1,14 0v1h-2v8h2a2,2 0,0 0,2 -2v-7a9,9 0,0 0,-9 -9z" />
</vector>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.42L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9c0,-2.74 -1.23,-5.18 -3.17,-6.83z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M18.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM5,9v6h4l5,5V4L9,9H5z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9H3v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18V4z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z" />
</vector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E61F2A44" />
<corners android:radius="28dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="#33FFFFFF">
<item android:id="@android:id/mask">
<shape android:shape="oval"><solid android:color="#FFFFFFFF" /></shape>
</item>
</ripple>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_bg"
android:gravity="center"
android:orientation="horizontal"
android:padding="6dp">
<ImageButton android:id="@+id/power" style="@style/WidgetButton"
android:contentDescription="Power" android:src="@drawable/ic_power" />
<ImageButton android:id="@+id/volume_down" style="@style/WidgetButton"
android:contentDescription="Volume down" android:src="@drawable/ic_volume_down" />
<ImageButton android:id="@+id/mute" style="@style/WidgetButton"
android:contentDescription="Mute" android:src="@drawable/ic_volume_off" />
<ImageButton android:id="@+id/volume_up" style="@style/WidgetButton"
android:contentDescription="Volume up" android:src="@drawable/ic_volume_up" />
<ImageButton android:id="@+id/listen" style="@style/WidgetButton"
android:contentDescription="Private listening" android:src="@drawable/ic_headphones" />
</LinearLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#B22551</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">LG Slim</string>
<string name="widget_description">TV power, volume and private listening</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<resources>
<style name="Theme.TvRemote" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="WidgetButton">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">48dp</item>
<item name="android:layout_weight">1</item>
<item name="android:background">@drawable/widget_button_bg</item>
<item name="android:scaleType">center</item>
</style>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- TVs serve app icons over plain HTTP on port 3000. Hosts are restricted to
private IP literals in code, so cleartext never leaves the LAN. -->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_remote"
android:previewLayout="@layout/widget_remote"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:resizeMode="horizontal"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />