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,15 @@
|
||||
# Gradle / build output
|
||||
.gradle/
|
||||
.kotlin/
|
||||
build/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Local machine config (SDK path)
|
||||
local.properties
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
captures/
|
||||
@@ -0,0 +1,109 @@
|
||||
<p align="center">
|
||||
<img src="art/new-logo.png" alt="LG Slim logo" width="160">
|
||||
</p>
|
||||
|
||||
<h1 align="center">LG Slim</h1>
|
||||
|
||||
<p align="center">
|
||||
A slim, private Android remote for LG webOS TVs.<br>
|
||||
Everything the LG ThinQ app does for a TV, minus the bloat: no login, no cloud, no ads, no trackers.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/remote-dark.png" alt="Remote, dark mode" width="240">
|
||||
|
||||
<img src="docs/remote-light.png" alt="Remote, light mode" width="240">
|
||||
|
||||
<img src="docs/apps.png" alt="Apps" width="240">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
LG ThinQ is about 313 MB, needs an LG account, and ships with analytics, ad and attribution SDKs. To control a TV that sits on your own Wi-Fi, none of that is needed.
|
||||
|
||||
LG Slim talks **only to the TV on your local network**. The release APK is about **1.4 MB**.
|
||||
|
||||
## Features
|
||||
|
||||
- **Remote:** D-pad, OK, Back, Home, Settings, Info, Guide, Exit, channel rocker, number pad, colour keys, and media keys. Keys fire the moment your finger touches them, and holding repeats.
|
||||
- **Volume rocker:** fills with the TV's current level. Tap or hold to step, or drag to jump straight to a level. The phone's volume buttons control the TV too.
|
||||
- **Live status lamp:**
|
||||
- green: on
|
||||
- blue, pulsing: connecting or waking
|
||||
- dark: off or in standby
|
||||
|
||||
Power state comes from the TV itself, so standby is never mistaken for on.
|
||||
- **Power:**
|
||||
- **Off:** turns the TV off.
|
||||
- **On:** Wake-on-LAN, using the same packet LG's app sends, plus fast reconnect polling.
|
||||
- **Detection:** an off TV is noticed in about 1.5 s.
|
||||
- **Private listening:** TV sound plays on the phone, so you can use your headphones. The phone receives raw PCM straight from the TV over the LAN. It keeps playing with the phone screen off, and there's a notification with a Stop button.
|
||||
- **Touchpad:** pointer, tap to click, two-finger scroll.
|
||||
- **Keyboard:** live typing into the TV's text fields. It opens by itself when the TV asks for text.
|
||||
- **Apps:** large tiles for your pinned apps, with the on-screen app marked.
|
||||
- **Sources:** HDMI inputs and a searchable channel list.
|
||||
- **Photos & videos:** show phone media on the TV, served straight from the phone to the TV over DLNA. Photos are re-encoded first, which strips EXIF data such as GPS location.
|
||||
- **Quick-settings tile and home-screen widget:** power, volume, mute, and private listening.
|
||||
- **Multiple TVs:** auto-discovery (SSDP, with a subnet sweep as fallback), or add one by IP. You can rename TVs ("Living room", "Main TV" and so on).
|
||||
- **Dark and light themes:** soft, top-lit keys.
|
||||
|
||||
## Privacy
|
||||
|
||||
| | LG ThinQ | LG Slim |
|
||||
|---|---|---|
|
||||
| LG account / login | Required | None |
|
||||
| Cloud servers | Yes | **Never contacted** |
|
||||
| Analytics, ads, attribution SDKs | Yes | None |
|
||||
| ACR (screen content recognition) | Supported | Not implemented |
|
||||
| Voice / microphone | Yes | Not used |
|
||||
| Location permission | Yes | Not requested |
|
||||
|
||||
How it's enforced in code (`net/Lan.kt`):
|
||||
|
||||
- The only HTTP client can reach **private IPv4 addresses only**. Hostnames are refused before any DNS lookup, so the app never even resolves a domain.
|
||||
- **TLS with trust on first use:** the TV's certificate is remembered at pairing. If it changes, the app refuses to connect until you re-pair on the TV screen.
|
||||
- **Paired-TV data stays on the phone:** client keys and certificate fingerprints live in app-private storage, and Android backups are disabled.
|
||||
- **Excluded TV services:** the TV calls that report to LG are never used. That covers content recognition (`tv/getACRAuthToken`), account linking (`iotclient/*`), the LG server relay (`sdx/*`), and `user/setUserInfo`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- An LG TV running **webOS**, on the same network as the phone.
|
||||
- **Android 8.0+** (API 26).
|
||||
- To turn the TV on from the app, enable **Turn on via Wi-Fi** (or **Mobile TV On**) in the TV's settings.
|
||||
|
||||
## Build
|
||||
|
||||
Requires JDK 17 and the Android SDK (compileSdk 35).
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
The release build is minified and signed with your local debug key for sideloading.
|
||||
|
||||
## How it works
|
||||
|
||||
| Feature | Protocol |
|
||||
|---|---|
|
||||
| Control | webOS **SSAP**: JSON over WebSocket on `wss://tv:3001`, plus the pointer socket for buttons |
|
||||
| Power state | `com.webos.service.tvpower/power/getPowerState` subscription |
|
||||
| Wake | Wake-on-LAN magic packet with LG's `THINQ` trailer, sent to UDP 9 and 7 |
|
||||
| Private listening | `secondscreen.gateway/test/secure` → `soundshare/startStreamOut` → raw 48 kHz 16-bit stereo big-endian PCM on TCP 9000 |
|
||||
| Photos & videos | UPnP/DLNA **AVTransport** on the TV's MediaRenderer (port 1966), with the file served from the phone |
|
||||
| Discovery | SSDP `urn:lge-com:service:webos-second-screen:1` |
|
||||
|
||||
```
|
||||
app/src/main/java/app/tvremote/
|
||||
├── net/ SSAP client, LAN-only HTTP, discovery, Wake-on-LAN, DLNA, media server
|
||||
├── data/ Connection state, power state, paired TVs
|
||||
├── audio/ Private listening service
|
||||
├── widget/ Quick-settings tile and home-screen widget
|
||||
└── ui/ Compose UI: remote, touchpad, apps, sources, listen
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent, unofficial project. It is not affiliated with or endorsed by LG Electronics. "LG" and "webOS" are trademarks of their respective owners.
|
||||
@@ -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" />
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||
}
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,112 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||
@rem This can be removed once we can be reasonably confident that few people
|
||||
@rem will be migrating directly to this new wrapper.
|
||||
goto afterSafetyNet
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
goto exitWithErrorLevel
|
||||
:afterSafetyNet
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "LgSlim"
|
||||
include(":app")
|
||||