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>
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#B22551</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">LG Slim</string>
|
||||
<string name="widget_description">TV power, volume and private listening</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||