Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e140c6b32e | |||
| 8d14f3575f |
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-04-05T11:04:12.656433726Z">
|
||||
<DropdownSelection timestamp="2025-03-18T16:06:30.698647383Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" />
|
||||
|
||||
21
LICENSE.md
21
LICENSE.md
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Fritz Heiden
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -6,7 +6,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.example.tvcontroller"
|
||||
compileSdk = 35
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.tvcontroller"
|
||||
@ -52,7 +52,6 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
@ -60,8 +59,8 @@ dependencies {
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.mlkit.vision)
|
||||
implementation(libs.androidx.camera.extensions)
|
||||
implementation(libs.material3)
|
||||
implementation(libs.stream.webrtc.android)
|
||||
implementation(libs.stream.log)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@ -5,14 +5,11 @@
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
@ -28,7 +25,6 @@
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.TVController">
|
||||
<intent-filter>
|
||||
|
||||
@ -1,85 +1,157 @@
|
||||
package com.example.tvcontroller
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.ContentValues.TAG
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.tvcontroller.ui.theme.TVControllerTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.tvcontroller.client.WebClient
|
||||
import com.example.tvcontroller.client.WebsocketClient
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.tvcontroller.services.BluetoothService
|
||||
import com.example.tvcontroller.services.CameraService
|
||||
import com.example.tvcontroller.services.ControllerService
|
||||
import com.example.tvcontroller.services.DeviceService
|
||||
import com.example.tvcontroller.services.webrtc.WebRtcService
|
||||
import com.example.tvcontroller.ui.theme.TVControllerTheme
|
||||
import com.example.tvcontroller.ui.views.MainView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import com.example.tvcontroller.ui.AppViewModel
|
||||
import com.example.tvcontroller.ui.views.CameraView
|
||||
import com.example.tvcontroller.ui.views.SettingsView
|
||||
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val webClient by lazy { WebClient() }
|
||||
private val websocketClient by lazy { WebsocketClient(webClient.client) }
|
||||
private val cameraService by lazy { CameraService(applicationContext) }
|
||||
private val webRtcService by lazy {
|
||||
WebRtcService(
|
||||
applicationContext,
|
||||
websocketClient,
|
||||
cameraService
|
||||
)
|
||||
}
|
||||
private val bluetoothService by lazy { BluetoothService(applicationContext) }
|
||||
private val deviceService by lazy { DeviceService(applicationContext, webClient, websocketClient) }
|
||||
private val controllerService by lazy { ControllerService(bluetoothService, webRtcService) }
|
||||
private lateinit var bluetoothService: BluetoothService
|
||||
private lateinit var deviceService: DeviceService
|
||||
private lateinit var cameraService: CameraService
|
||||
private val appViewModel by viewModels<AppViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
bluetoothService = BluetoothService(this.applicationContext)
|
||||
bluetoothService.onBluetoothStateChanged { state -> appViewModel.setBluetoothEnabled(state == BluetoothAdapter.STATE_ON) }
|
||||
appViewModel.setBluetoothEnabled(bluetoothService.isBluetoothEnabled())
|
||||
deviceService = DeviceService(this.applicationContext)
|
||||
cameraService = CameraService(this.applicationContext)
|
||||
checkPermissions()
|
||||
|
||||
lifecycleScope.launch {
|
||||
deviceService.onStatusChanged {
|
||||
when (it) {
|
||||
DeviceService.STATUS_REGISTERED -> {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
websocketClient.connect(deviceService.serverAddress, deviceService.token)
|
||||
}
|
||||
webRtcService.connect()
|
||||
}
|
||||
DeviceService.STATUS_UNREGISTERED -> {
|
||||
Log.i(TAG, "Device unregistered")
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceService.initialize()
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
TVControllerTheme {
|
||||
MainView(
|
||||
deviceService = deviceService,
|
||||
controllerService = controllerService,
|
||||
TvControllerApp(
|
||||
appViewModel = appViewModel,
|
||||
bluetoothService = bluetoothService,
|
||||
webRtcService = webRtcService,
|
||||
cameraService = cameraService
|
||||
deviceService = deviceService
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPermissions() {
|
||||
Log.i(TAG, "Checking permissions")
|
||||
if (!bluetoothService.hasRequiredPermissions()) {
|
||||
Log.i(TAG, "Requesting Bluetooth permissions")
|
||||
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
|
||||
}
|
||||
if (!cameraService.hasRequiredPermissions()) {
|
||||
Log.i(TAG, "Requesting Camera permissions")
|
||||
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
|
||||
ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvControllerApp(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
appViewModel: AppViewModel = viewModel(),
|
||||
bluetoothService: BluetoothService? = null,
|
||||
deviceService: DeviceService
|
||||
) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentScreen = Screen.valueOf(backStackEntry?.destination?.route ?: Screen.Camera.name)
|
||||
val baselineCamera24 = painterResource(R.drawable.baseline_camera_24)
|
||||
val baselineRemote24 = painterResource(R.drawable.baseline_settings_remote_24)
|
||||
val baselineSettings24 = painterResource(R.drawable.baseline_settings_24)
|
||||
Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
onClick = { navController.navigate(Screen.Camera.name) },
|
||||
icon = {
|
||||
Icon(
|
||||
baselineCamera24,
|
||||
contentDescription = "Camera"
|
||||
)
|
||||
},
|
||||
label = { Text("Camera") },
|
||||
selected = currentScreen == Screen.Camera
|
||||
)
|
||||
NavigationBarItem(
|
||||
onClick = { navController.navigate(Screen.Remote.name) },
|
||||
icon = {
|
||||
Icon(
|
||||
baselineRemote24,
|
||||
contentDescription = "Remote"
|
||||
)
|
||||
},
|
||||
label = { Text("Remote") },
|
||||
selected = currentScreen == Screen.Remote
|
||||
)
|
||||
NavigationBarItem(
|
||||
onClick = { navController.navigate(Screen.Settings.name) },
|
||||
icon = {
|
||||
Icon(
|
||||
baselineSettings24,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
},
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == Screen.Settings
|
||||
)
|
||||
}
|
||||
}) { innerPadding ->
|
||||
Column {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Camera.name,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(route = Screen.Camera.name) {
|
||||
CameraView()
|
||||
}
|
||||
composable(route = Screen.Remote.name) {
|
||||
RemoteScreen()
|
||||
}
|
||||
composable(route = Screen.Settings.name) {
|
||||
SettingsView(appViewModel = appViewModel, deviceService = deviceService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoteScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = "Remote Screen", modifier = modifier)
|
||||
Button(onClick = { Log.i(TAG, "RemoteScreen: Button clicked") }) {
|
||||
Text(text = "Button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
app/src/main/java/com/example/tvcontroller/Screen.kt
Normal file
7
app/src/main/java/com/example/tvcontroller/Screen.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package com.example.tvcontroller
|
||||
|
||||
enum class Screen {
|
||||
Camera,
|
||||
Remote,
|
||||
Settings
|
||||
}
|
||||
8
app/src/main/java/com/example/tvcontroller/Settings.kt
Normal file
8
app/src/main/java/com/example/tvcontroller/Settings.kt
Normal file
@ -0,0 +1,8 @@
|
||||
package com.example.tvcontroller
|
||||
|
||||
object Settings {
|
||||
enum class ConnectionState {
|
||||
Unregistered,
|
||||
Registered,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.example.tvcontroller
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.tvcontroller.services.DeviceService
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(private val deviceService: DeviceService) : ViewModel() {
|
||||
var serverAddress by mutableStateOf(deviceService.getServerAddress())
|
||||
private set
|
||||
var deviceName by mutableStateOf(android.os.Build.MANUFACTURER + " " + android.os.Build.MODEL)
|
||||
private set
|
||||
var registrationCode by mutableStateOf("")
|
||||
private set
|
||||
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
|
||||
private set
|
||||
|
||||
init {
|
||||
updateConnectionState()
|
||||
viewModelScope.launch {
|
||||
updateDeviceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
//Log.i("SettingsScreen", "Save settings: $serverUrl, $deviceName, $registrationCode")
|
||||
viewModelScope.launch {
|
||||
deviceService.setServerAddress(serverAddress)
|
||||
deviceService.createIntegration(deviceName, registrationCode)
|
||||
updateConnectionState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateConnectionState() {
|
||||
connectionState = if (deviceService.getToken().isEmpty()) {
|
||||
Settings.ConnectionState.Unregistered
|
||||
return
|
||||
} else {
|
||||
Settings.ConnectionState.Registered
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateDeviceInfo() {
|
||||
if (connectionState == Settings.ConnectionState.Unregistered) return
|
||||
val integration = deviceService.getIntegration()
|
||||
if (integration == null) {
|
||||
connectionState = Settings.ConnectionState.Unregistered
|
||||
return
|
||||
}
|
||||
deviceName = integration.name
|
||||
}
|
||||
|
||||
fun onServerAddressChanged(url: String) {
|
||||
serverAddress = url
|
||||
}
|
||||
|
||||
fun onDeviceNameChanged(name: String) {
|
||||
deviceName = name
|
||||
}
|
||||
|
||||
fun onRegistrationCodeChanged(code: String) {
|
||||
registrationCode = code
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun provideFactory(
|
||||
deviceService: DeviceService,
|
||||
) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SettingsViewModel(deviceService) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
package com.example.tvcontroller.client
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.cookie
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.HttpMethod
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val TAG = "WebClient"
|
||||
|
||||
class WebClient {
|
||||
val client = HttpClient(CIO) {
|
||||
install(WebSockets)
|
||||
}
|
||||
var defaultCookies = mutableMapOf<String, String>()
|
||||
|
||||
suspend fun sendRequest(
|
||||
url: String,
|
||||
method: HttpMethod,
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
body: String? = ""
|
||||
): HttpResponse? {
|
||||
try {
|
||||
val response = client.request(url) {
|
||||
this.method = method
|
||||
setBody(body)
|
||||
headers {
|
||||
headers.forEach { (key, value) ->
|
||||
append(key, value)
|
||||
}
|
||||
}
|
||||
defaultCookies.forEach { (key, value) ->
|
||||
cookie(name = key, value = value)
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "error sending json request", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun sendJsonRequest(url: String, method: HttpMethod, json: JSONObject): HttpResponse? {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
headers.put("Content-Type", "application/json")
|
||||
return sendRequest(url, method, headers = headers, body = json.toString())
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package com.example.tvcontroller.client
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.client.request.cookie
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.websocket.DefaultWebSocketSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val TAG = "WebsocketClient"
|
||||
|
||||
class WebsocketClient(private val client: HttpClient) {
|
||||
private var websocket: DefaultWebSocketSession? = null
|
||||
private val dataHandlers = mutableListOf<(String) -> Unit>()
|
||||
private val connectedHandlers = mutableListOf<() -> Unit>()
|
||||
private val disconnectedHandlers = mutableListOf<() -> Unit>()
|
||||
|
||||
suspend fun connect(serverAddress: String, token: String) {
|
||||
Log.i(TAG, "Connecting to websocket at $serverAddress")
|
||||
val (host, port) = serverAddress.split(":")
|
||||
val portInt = if (port.isEmpty()) 80 else port.toInt()
|
||||
connectedHandlers.forEach { it() }
|
||||
client.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
host = host,
|
||||
port = portInt,
|
||||
path = "/ws",
|
||||
request = {
|
||||
cookie(name = "token", value = token)
|
||||
}
|
||||
) {
|
||||
Log.i(TAG, "Listening for incoming websocket messages")
|
||||
websocket = this
|
||||
while (true) {
|
||||
val frame = incoming.receive()
|
||||
Log.d(TAG, "Received frame: $frame")
|
||||
if (frame is Frame.Text) {
|
||||
val dataString = frame.readText()
|
||||
dataHandlers.forEach { it(dataString) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Websocket connection closed")
|
||||
disconnectedHandlers.forEach { it() }
|
||||
}
|
||||
|
||||
fun onConnected(handler: () -> Unit) {
|
||||
connectedHandlers.add(handler)
|
||||
}
|
||||
|
||||
fun onDisconnected(handler: () -> Unit) {
|
||||
disconnectedHandlers.add(handler)
|
||||
}
|
||||
|
||||
fun onData(handler: (String) -> Unit) {
|
||||
Log.d(TAG, "Adding data handler")
|
||||
dataHandlers.add(handler)
|
||||
}
|
||||
|
||||
suspend fun sendJson(json: JSONObject) {
|
||||
val frame = Frame.Text(json.toString())
|
||||
websocket?.send(frame)
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.example.tvcontroller.data
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class BluetoothDevice(name: String?, address: String?) {
|
||||
private var _name: String? = name
|
||||
private var _address: String? = address
|
||||
|
||||
fun getName(): String {
|
||||
return if (_name.isNullOrEmpty()) getAddress() else _name!!
|
||||
}
|
||||
|
||||
fun getAddress(): String {
|
||||
return if (_address == null) "" else _address!!
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromBluetoothDevice(device: android.bluetooth.BluetoothDevice): BluetoothDevice {
|
||||
try {
|
||||
return BluetoothDevice(device.name, device.address)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e("BluetoothDevice", "Error creating BluetoothDevice", e)
|
||||
}
|
||||
return BluetoothDevice(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package com.example.tvcontroller.data
|
||||
|
||||
class RemoteCommand {
|
||||
var protocol: String? = null
|
||||
var device: String? = null
|
||||
var command: String? = null
|
||||
}
|
||||
@ -1,191 +1,66 @@
|
||||
package com.example.tvcontroller.services
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import com.example.tvcontroller.data.BluetoothDevice
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
private const val SERIAL_PORT_SERVICE_UUID = "00001101-0000-1000-8000-00805F9B34FB"
|
||||
private const val TAG = "BluetoothService"
|
||||
|
||||
class BluetoothService(private val context: Context) {
|
||||
private var bluetoothManager: BluetoothManager =
|
||||
getSystemService(context, BluetoothManager::class.java)!!
|
||||
getSystemService(context, BluetoothManager::class.java)!!;
|
||||
private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
|
||||
|
||||
private val _pairedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
|
||||
var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow()
|
||||
private var clientSocket: BluetoothSocket? = null
|
||||
|
||||
private var bluetoothStateChangedCallbacks: MutableList<(String) -> Unit> = mutableListOf()
|
||||
private var bluetoothStateReceiver: BroadcastReceiver? = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val btState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
|
||||
Log.i(TAG, "Bluetooth state changed to: $btState")
|
||||
state = parseBluetoothState(btState)
|
||||
}
|
||||
}
|
||||
var currentDevice: BluetoothDevice? = null
|
||||
private set
|
||||
|
||||
var state: String = STATE_OFF
|
||||
private set(value) {
|
||||
if (value != STATE_CONNECTED && value != STATE_CONNECTING) currentDevice = null
|
||||
Log.i(TAG, "Bluetooth state changed to: $value")
|
||||
bluetoothStateChangedCallbacks.forEach { it(value) }
|
||||
field = value
|
||||
}
|
||||
private var bluetoothStateReceiver: BroadcastReceiver? = null
|
||||
private var bluetoothStateChangedCallbacks: MutableList<(Int) -> Unit> = mutableListOf()
|
||||
|
||||
init {
|
||||
state = parseBluetoothState(bluetoothAdapter.state)
|
||||
context.registerReceiver(
|
||||
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
)
|
||||
updatePairedDevices()
|
||||
}
|
||||
|
||||
private fun parseBluetoothState(state: Int): String {
|
||||
return when (state) {
|
||||
BluetoothAdapter.STATE_OFF -> STATE_OFF
|
||||
BluetoothAdapter.STATE_TURNING_OFF -> STATE_OFF
|
||||
BluetoothAdapter.STATE_TURNING_ON -> STATE_OFF
|
||||
BluetoothAdapter.STATE_ON -> STATE_DISCONNECTED
|
||||
else -> STATE_DISCONNECTED
|
||||
}
|
||||
registerBluetoothStateReceiver()
|
||||
}
|
||||
|
||||
fun isBluetoothEnabled(): Boolean {
|
||||
return bluetoothAdapter.isEnabled
|
||||
return bluetoothAdapter.isEnabled ?: false
|
||||
}
|
||||
|
||||
fun onBluetoothStateChanged(callback: (String) -> Unit) {
|
||||
private fun registerBluetoothStateReceiver() {
|
||||
bluetoothStateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
|
||||
bluetoothStateChangedCallbacks.forEach { it(state) }
|
||||
when (state) {
|
||||
BluetoothAdapter.STATE_ON -> {
|
||||
// Handle Bluetooth turned on
|
||||
println("SIGNAL: Bluetooth is enabled")
|
||||
}
|
||||
BluetoothAdapter.STATE_OFF -> {
|
||||
// Handle Bluetooth turned off
|
||||
println("SIGNAL: Bluetooth is disabled")
|
||||
}
|
||||
BluetoothAdapter.STATE_TURNING_ON -> {
|
||||
// Handle Bluetooth turning on
|
||||
println("SIGNAL: Bluetooth is turning on")
|
||||
}
|
||||
BluetoothAdapter.STATE_TURNING_OFF -> {
|
||||
// Handle Bluetooth turning off
|
||||
println("SIGNAL: Bluetooth is turning off")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||
}
|
||||
|
||||
fun onBluetoothStateChanged(callback: (Int) -> Unit) {
|
||||
bluetoothStateChangedCallbacks.add(callback)
|
||||
}
|
||||
|
||||
fun offBluetoothStateChanged(callback: (String) -> Unit) {
|
||||
fun offBluetoothStateChanged(callback: (Int) -> Unit) {
|
||||
bluetoothStateChangedCallbacks.remove(callback)
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
context.unregisterReceiver(bluetoothStateReceiver)
|
||||
bluetoothStateChangedCallbacks.clear()
|
||||
closeBluetoothConnection()
|
||||
}
|
||||
|
||||
fun updatePairedDevices() {
|
||||
try {
|
||||
_pairedDevices.update {
|
||||
bluetoothAdapter.bondedDevices.toList()
|
||||
.map { device -> BluetoothDevice.fromBluetoothDevice(device) }
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
println("Error updating paired devices: $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun connectToDevice(device: BluetoothDevice) {
|
||||
currentDevice = device
|
||||
state = STATE_CONNECTING
|
||||
Log.i(TAG, "Initiating connection process")
|
||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.e(TAG, "Bluetooth permission not granted")
|
||||
state = STATE_DISCONNECTED
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Connecting to device: $device")
|
||||
try {
|
||||
var androidBluetoothDevice = bluetoothAdapter.getRemoteDevice(device.getAddress())
|
||||
Log.i(TAG, "Creating socket to device: $device")
|
||||
clientSocket = androidBluetoothDevice.createRfcommSocketToServiceRecord(
|
||||
UUID.fromString(SERIAL_PORT_SERVICE_UUID)
|
||||
)
|
||||
Log.i(TAG, "Connecting to socket")
|
||||
clientSocket?.connect()
|
||||
currentDevice = device
|
||||
state = STATE_CONNECTED
|
||||
Log.i(TAG, "Connected to device: $device")
|
||||
listenForIncomingData()
|
||||
} catch (e: IOException) {
|
||||
state = STATE_DISCONNECTED
|
||||
Log.e(TAG, "Error connecting to device: $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRequiredPermissions(): Boolean {
|
||||
return BLUETOOTH_PERMISSIONS.all {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBluetoothConnection() {
|
||||
clientSocket?.close()
|
||||
clientSocket = null
|
||||
currentDevice = null
|
||||
state = STATE_DISCONNECTED
|
||||
}
|
||||
|
||||
private fun listenForIncomingData() {
|
||||
if (clientSocket == null || !clientSocket!!.isConnected) return
|
||||
Log.i(TAG, "Listening for incoming data")
|
||||
val buffer = ByteArray(1024)
|
||||
while (true) {
|
||||
try {
|
||||
val bytesRead = clientSocket?.inputStream?.read(buffer)
|
||||
if (bytesRead != null && bytesRead != -1) {
|
||||
val data = buffer.decodeToString(endIndex = bytesRead)
|
||||
Log.i(TAG, "Received data: $data")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error reading from socket: $e")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error receiving data: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendData(data: String) {
|
||||
if (clientSocket == null || !clientSocket!!.isConnected) return
|
||||
try {
|
||||
val bytes = data.encodeToByteArray()
|
||||
clientSocket?.outputStream?.write(bytes)
|
||||
Log.i(TAG, "Sent data: $data")
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error writing to socket: $e")
|
||||
closeBluetoothConnection()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STATE_OFF = "off"
|
||||
const val STATE_DISCONNECTED = "disconnected"
|
||||
const val STATE_CONNECTING = "connecting"
|
||||
const val STATE_CONNECTED = "connected"
|
||||
val BLUETOOTH_PERMISSIONS = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADMIN
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,54 +2,16 @@ package com.example.tvcontroller.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import org.webrtc.Camera2Capturer
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.HardwareVideoEncoderFactory
|
||||
import org.webrtc.SimulcastVideoEncoderFactory
|
||||
import org.webrtc.SoftwareVideoEncoderFactory
|
||||
|
||||
class CameraService(private val context: Context) {
|
||||
var eglBaseContext: EglBase.Context = EglBase.create().eglBaseContext
|
||||
val cameraManager by lazy { context.getSystemService(Context.CAMERA_SERVICE) as CameraManager }
|
||||
val videoDecoderFactory by lazy { DefaultVideoDecoderFactory(eglBaseContext) }
|
||||
val videoEncoderFactory by lazy {
|
||||
SimulcastVideoEncoderFactory(
|
||||
HardwareVideoEncoderFactory(eglBaseContext, true, true),
|
||||
SoftwareVideoEncoderFactory()
|
||||
)
|
||||
}
|
||||
|
||||
fun createCameraCapturer(): Camera2Capturer {
|
||||
val ids = cameraManager.cameraIdList
|
||||
var foundCamera = false;
|
||||
var cameraId = ""
|
||||
for (id in ids) {
|
||||
val characteristics = cameraManager.getCameraCharacteristics(id)
|
||||
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||
if (facing == CameraCharacteristics.LENS_FACING_BACK) {
|
||||
cameraId = id
|
||||
foundCamera = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundCamera) {
|
||||
cameraId = ids.first()
|
||||
}
|
||||
val cameraCapturer = Camera2Capturer(context, cameraId, null)
|
||||
return cameraCapturer
|
||||
}
|
||||
|
||||
fun hasRequiredPermissions(): Boolean {
|
||||
return CAMERA_PERMISSIONS.all {
|
||||
return CAMERAX_PERMISSIONS.all {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val CAMERA_PERMISSIONS = arrayOf(
|
||||
val CAMERAX_PERMISSIONS = arrayOf(
|
||||
android.Manifest.permission.CAMERA,
|
||||
android.Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
package com.example.tvcontroller.services
|
||||
|
||||
import android.util.Log
|
||||
import com.example.tvcontroller.data.RemoteCommand
|
||||
import com.example.tvcontroller.services.webrtc.WebRtcService
|
||||
import org.json.JSONObject
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
|
||||
class ControllerService(
|
||||
private val bluetoothService: BluetoothService,
|
||||
private val webRtcService: WebRtcService
|
||||
) {
|
||||
init {
|
||||
loadCommands()
|
||||
webRtcService.onDataChannelData(this::handleWebRtcData)
|
||||
}
|
||||
|
||||
fun sendCommand(command: RemoteCommand) {
|
||||
val jsonString = remoteCommandToJsonString(command)
|
||||
Log.i(TAG, "Sending command: $jsonString")
|
||||
sendData(jsonString)
|
||||
}
|
||||
|
||||
fun remoteCommandToJsonString(command: RemoteCommand): String {
|
||||
var commandObject = JSONObject()
|
||||
commandObject.put("protocol", command.protocol)
|
||||
commandObject.put("device", command.device)
|
||||
commandObject.put("command", command.command)
|
||||
return commandObject.toString()
|
||||
}
|
||||
|
||||
fun sendData(data: String) {
|
||||
bluetoothService.sendData(data)
|
||||
}
|
||||
|
||||
fun loadCommands() {
|
||||
}
|
||||
|
||||
fun handleWebRtcData(data: ByteBuffer) {
|
||||
val dataString = StandardCharsets.UTF_8.decode(data).toString()
|
||||
val json = JSONObject(dataString)
|
||||
if (!json.has("type") || json.getString("type") != MESSAGE_TYPE_COMMAND) return
|
||||
val commandJson = json.getJSONObject("data")
|
||||
val protocol = if (commandJson.has("protocol")) commandJson.getString("protocol") else null
|
||||
val device = if (commandJson.has("device")) commandJson.getString("device") else null
|
||||
val command = if (commandJson.has("commandNumber")) commandJson.getString("commandNumber") else null
|
||||
val remoteCommand = RemoteCommand().apply {
|
||||
this.protocol = protocol
|
||||
this.device = device
|
||||
this.command = command
|
||||
}
|
||||
sendCommand(remoteCommand)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ControllerService"
|
||||
|
||||
const val MESSAGE_TYPE_COMMAND = "command"
|
||||
|
||||
const val POWER = "POWER"
|
||||
const val CURSOR_UP = "CURSOR UP"
|
||||
const val CURSOR_DOWN = "CURSOR DOWN"
|
||||
const val CURSOR_LEFT = "CURSOR LEFT"
|
||||
const val CURSOR_RIGHT = "CURSOR RIGHT"
|
||||
const val ENTER = "ENTER"
|
||||
const val ONE = "1"
|
||||
const val TWO = "2"
|
||||
const val THREE = "3"
|
||||
const val FOUR = "4"
|
||||
const val FIVE = "5"
|
||||
const val SIX = "6"
|
||||
const val SEVEN = "7"
|
||||
const val EIGHT = "8"
|
||||
const val NINE = "9"
|
||||
const val ZERO = "0"
|
||||
const val CHANNEL_UP = "CHANNEL +"
|
||||
const val CHANNEL_DOWN = "CHANNEL -"
|
||||
const val VOLUME_UP = "VOLUME +"
|
||||
const val VOLUME_DOWN = "VOLUME -"
|
||||
const val MUTE = "MUTE"
|
||||
const val GUIDE = "GUIDE"
|
||||
const val INFO = "INFO"
|
||||
const val AD_SUBTITLE = "AD/SUBT"
|
||||
const val E_MANUAL = "E-MANUAL"
|
||||
const val TOOLS = "TOOLS"
|
||||
const val RETURN = "RETURN"
|
||||
const val MENU = "MENU"
|
||||
const val SMART_HUB = "SMART HUB"
|
||||
const val EXIT = "EXIT"
|
||||
const val CHANNEL_LIST = "CH LIST"
|
||||
const val HOME = "HOME"
|
||||
const val SETTINGS = "SETTINGS"
|
||||
const val RED = "RED"
|
||||
const val GREEN = "GREEN"
|
||||
const val YELLOW = "YELLOW"
|
||||
const val BLUE = "BLUE"
|
||||
const val INPUT_SOURCE = "INPUT SOURCE"
|
||||
const val REWIND = "REWIND"
|
||||
const val PLAY = "PLAY"
|
||||
const val PAUSE = "PAUSE"
|
||||
const val FORWARD = "FORWARD"
|
||||
}
|
||||
}
|
||||
@ -3,115 +3,77 @@ package com.example.tvcontroller.services
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.tvcontroller.client.WebClient
|
||||
import com.example.tvcontroller.client.WebsocketClient
|
||||
import com.example.tvcontroller.data.Integration
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.HeadersBuilder
|
||||
import io.ktor.http.HttpMethod
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val SHARED_PREFERENCES_NAME = "devices";
|
||||
|
||||
class DeviceService(
|
||||
private val context: Context,
|
||||
private val client: WebClient,
|
||||
private val websocketClient: WebsocketClient
|
||||
) {
|
||||
var serverAddress: String = ""
|
||||
var token: String = ""
|
||||
var status: String = STATUS_UNREGISTERED
|
||||
private set(status) {
|
||||
field = status
|
||||
statusChangedListeners.forEach { it(status) }
|
||||
}
|
||||
class DeviceService(private val context: Context) {
|
||||
private var client = HttpClient(CIO)
|
||||
private var serverAddress: String = ""
|
||||
private var token: String = ""
|
||||
private var deviceId: String = ""
|
||||
private val statusChangedListeners = mutableListOf<(String) -> Unit>()
|
||||
|
||||
suspend fun initialize() {
|
||||
websocketClient.onConnected { status = STATUS_CONNECTED }
|
||||
websocketClient.onDisconnected { status = STATUS_REGISTERED }
|
||||
onStatusChanged {
|
||||
when(it) {
|
||||
STATUS_UNREGISTERED -> {
|
||||
token = ""
|
||||
deviceId = ""
|
||||
updateDefaultCookies()
|
||||
savePreferences()
|
||||
}
|
||||
STATUS_REGISTERED -> {
|
||||
Log.i(TAG, "Device registered with id $deviceId")
|
||||
savePreferences()
|
||||
connectWebsocket()
|
||||
}
|
||||
}
|
||||
}
|
||||
init {
|
||||
loadPreferences()
|
||||
if (token.isEmpty()) return
|
||||
updateDefaultCookies()
|
||||
val integration = getIntegration()
|
||||
Log.i(TAG, "Integration: $integration")
|
||||
status = if (integration != null) {
|
||||
STATUS_REGISTERED
|
||||
} else {
|
||||
STATUS_UNREGISTERED
|
||||
}
|
||||
|
||||
suspend fun createIntegration(name: String, code: String) {
|
||||
Log.i("DeviceService", "Creating integration for $name with code $code at $serverAddress")
|
||||
val requestJson = JSONObject()
|
||||
requestJson.put("name", name)
|
||||
requestJson.put("code", code)
|
||||
try {
|
||||
val response: HttpResponse = client.request("http://$serverAddress/api/integrations") {
|
||||
method = HttpMethod.Post
|
||||
setBody(requestJson.toString())
|
||||
headers {
|
||||
append("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDefaultCookies() {
|
||||
Log.i(TAG, "Updating default cookies with token $token")
|
||||
client.defaultCookies["token"] = token
|
||||
Log.i(TAG, "Default cookies: ${client.defaultCookies}")
|
||||
}
|
||||
|
||||
suspend fun registerIntegration(name: String, code: String) {
|
||||
Log.i(TAG, "Registering integration for $name with code $code at $serverAddress")
|
||||
savePreferences()
|
||||
val requestJson = JSONObject().apply {
|
||||
put("name", name)
|
||||
put("code", code)
|
||||
}
|
||||
val response =
|
||||
client.sendJsonRequest(
|
||||
"http://$serverAddress/api/integrations/register",
|
||||
HttpMethod.Post,
|
||||
requestJson
|
||||
) ?: return
|
||||
|
||||
val responseJson = JSONObject(response.body<String>())
|
||||
val body: String = response.body()
|
||||
val responseJson = JSONObject(body)
|
||||
if (response.status.value != 200) {
|
||||
val error = responseJson.getString("error")
|
||||
Log.e(TAG, "Error registering integration: ${response.status.value} $error")
|
||||
Log.e("DeviceService", "Error getting integration: ${response.status.value} $error")
|
||||
return
|
||||
}
|
||||
token = responseJson.getString("token")
|
||||
deviceId = responseJson.getString("id")
|
||||
status = STATUS_REGISTERED
|
||||
}
|
||||
savePreferences()
|
||||
|
||||
fun onStatusChanged(listener: (String) -> Unit) {
|
||||
statusChangedListeners.add(listener)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun connectWebsocket() {
|
||||
GlobalScope.launch() {
|
||||
websocketClient.connect(serverAddress, token)
|
||||
Log.i("DeviceService", "Response: ${response.status.value} $body")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DeviceService", "Error creating integration", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getIntegration(): Integration? {
|
||||
val response =
|
||||
client.sendRequest("http://$serverAddress/api/integrations/$deviceId", HttpMethod.Get)
|
||||
?: return null
|
||||
Log.i("DeviceService", "Getting integration $deviceId at $serverAddress")
|
||||
try {
|
||||
val response: HttpResponse =
|
||||
client.request("http://$serverAddress/api/integrations/$deviceId") {
|
||||
method = HttpMethod.Get
|
||||
headers {
|
||||
append("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
val responseJson = JSONObject(response.body<String>())
|
||||
val body: String = response.body()
|
||||
val responseJson = JSONObject(body)
|
||||
if (response.status.value != 200) {
|
||||
val error = responseJson.getString("error")
|
||||
Log.e(TAG, "Error getting integration: ${response.status.value} $error")
|
||||
Log.e("DeviceService", "Error getting integration: ${response.status.value} $error")
|
||||
return null
|
||||
}
|
||||
val integration = Integration(
|
||||
@ -119,37 +81,40 @@ class DeviceService(
|
||||
responseJson.getString("name")
|
||||
)
|
||||
return integration
|
||||
} catch (e: Exception) {
|
||||
Log.e("DeviceService", "Error getting integration", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun loadPreferences() {
|
||||
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
||||
serverAddress = sharedPreferences.getString(SERVER_ADDRESS_KEY, "")!!
|
||||
token = sharedPreferences.getString(TOKEN_KEY, "")!!
|
||||
deviceId = sharedPreferences.getString(DEVICE_ID_KEY, "")!!
|
||||
Log.i(TAG, "Loaded preferences: $serverAddress $token")
|
||||
serverAddress = sharedPreferences.getString("server_address", "")!!
|
||||
token = sharedPreferences.getString("token", "")!!
|
||||
deviceId = sharedPreferences.getString("device_id", "")!!
|
||||
Log.i("DeviceService", "Loaded preferences: $serverAddress $token")
|
||||
}
|
||||
|
||||
private fun savePreferences() {
|
||||
Log.i(TAG, "Saving preferences")
|
||||
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
||||
val editor = sharedPreferences.edit()
|
||||
editor.apply {
|
||||
putString(SERVER_ADDRESS_KEY, serverAddress)
|
||||
putString(TOKEN_KEY, token)
|
||||
putString(DEVICE_ID_KEY, deviceId)
|
||||
putString("server_address", serverAddress)
|
||||
putString("token", token)
|
||||
putString("device_id", deviceId)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STATUS_UNREGISTERED = "unregistered"
|
||||
const val STATUS_REGISTERED = "registered"
|
||||
const val STATUS_CONNECTED = "connected"
|
||||
fun setServerAddress(url: String) {
|
||||
serverAddress = url
|
||||
}
|
||||
|
||||
private const val SHARED_PREFERENCES_NAME = "devices";
|
||||
private const val SERVER_ADDRESS_KEY = "server_address"
|
||||
private const val TOKEN_KEY = "token"
|
||||
private const val DEVICE_ID_KEY = "device_id"
|
||||
private const val TAG = "DeviceService"
|
||||
fun getServerAddress(): String {
|
||||
return serverAddress
|
||||
}
|
||||
|
||||
fun getToken(): String {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.example.tvcontroller.services
|
||||
|
||||
import android.util.Log
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
|
||||
class WebRtcService : ImageAnalysis.Analyzer {
|
||||
val sessionManager = LocalWebRtcSessionManager.current
|
||||
val localVideoTrackState by sessionManager.localVideoTrackFlow.collectAsState(null)
|
||||
val localVideoTrack = localVideoTrackState
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
Log.i("WebRtcService", "Image received")
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
package com.example.tvcontroller.services.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.tvcontroller.services.CameraService
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.MediaStreamTrack
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.PeerConnectionFactory.InitializationOptions
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val TAG = "RtcPeerConnection"
|
||||
|
||||
class RtcPeerConnection(private val context: Context, private val cameraService: CameraService) {
|
||||
private val peerConnectionFactory by lazy { initializeFactory() }
|
||||
private var iceServers = ArrayList<PeerConnection.IceServer>()
|
||||
var eglBaseContext: EglBase.Context = cameraService.eglBaseContext
|
||||
private val videoCapturer by lazy { cameraService.createCameraCapturer() }
|
||||
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
|
||||
private val videoSource by lazy { createVideoSource() }
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
|
||||
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
|
||||
|
||||
|
||||
fun initialize() {
|
||||
var observer = object : PeerConnection.Observer {
|
||||
override fun onSignalingChange(p0: PeerConnection.SignalingState?) {}
|
||||
override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {}
|
||||
override fun onIceConnectionReceivingChange(p0: Boolean) {}
|
||||
override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {}
|
||||
override fun onIceCandidate(iceCandidate: IceCandidate?) {
|
||||
iceCandidateHandlers.forEach { it(iceCandidate!!) }
|
||||
}
|
||||
|
||||
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {}
|
||||
override fun onAddStream(p0: MediaStream?) {}
|
||||
override fun onRemoveStream(p0: MediaStream?) {}
|
||||
override fun onDataChannel(channel: DataChannel?) {
|
||||
Log.i(TAG, "Data channel created: $channel")
|
||||
channel?.registerObserver(object : DataChannel.Observer {
|
||||
override fun onBufferedAmountChange(p0: Long) { }
|
||||
override fun onStateChange() { }
|
||||
override fun onMessage(p0: DataChannel.Buffer?) {
|
||||
dataChannelHandlers.forEach { it(p0?.data!!) }
|
||||
}
|
||||
})
|
||||
}
|
||||
override fun onRenegotiationNeeded() {}
|
||||
}
|
||||
var rtcConfig = PeerConnection.RTCConfiguration(iceServers)
|
||||
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, observer)
|
||||
}
|
||||
|
||||
fun setIceServers(iceServerStrings: Array<String>) {
|
||||
iceServers = ArrayList<PeerConnection.IceServer>()
|
||||
iceServerStrings.forEach {
|
||||
iceServers.add(
|
||||
PeerConnection.IceServer.builder(it).createIceServer()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createAudioTrack(): AudioTrack {
|
||||
val audioConstraints = MediaConstraints()
|
||||
val audioSource = peerConnectionFactory.createAudioSource(audioConstraints)
|
||||
val audioTrack = peerConnectionFactory.createAudioTrack("audio_track", audioSource)
|
||||
return audioTrack
|
||||
}
|
||||
|
||||
fun addTack(track: MediaStreamTrack) {
|
||||
peerConnection?.addTrack(track)
|
||||
}
|
||||
|
||||
fun createVideoTrack(): VideoTrack {
|
||||
val videoTrack = peerConnectionFactory.createVideoTrack("video_track", videoSource)
|
||||
return videoTrack
|
||||
}
|
||||
|
||||
fun setLocalDescription(sessionDescription: SessionDescription, callback: () -> Unit) {
|
||||
val onLocalDescriptionSet = object : SdpObserver {
|
||||
override fun onSetSuccess() {
|
||||
callback()
|
||||
}
|
||||
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
}
|
||||
peerConnection?.setLocalDescription(onLocalDescriptionSet, sessionDescription)
|
||||
}
|
||||
|
||||
fun setRemoteDescription(sessionDescription: SessionDescription, callback: () -> Unit) {
|
||||
val onRemoteDescriptionSet = object : SdpObserver {
|
||||
override fun onSetSuccess() {
|
||||
callback()
|
||||
}
|
||||
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
}
|
||||
peerConnection?.setRemoteDescription(onRemoteDescriptionSet, sessionDescription)
|
||||
}
|
||||
|
||||
fun createAnswer(mediaConstraints: MediaConstraints, callback: (SessionDescription) -> Unit) {
|
||||
val onAnswerCreated = object : SdpObserver {
|
||||
override fun onCreateSuccess(sessionDescription: SessionDescription?) {
|
||||
callback(sessionDescription!!)
|
||||
}
|
||||
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
}
|
||||
peerConnection?.createAnswer(onAnswerCreated, mediaConstraints)
|
||||
}
|
||||
|
||||
fun addIceCandidate(iceCandidate: IceCandidate) {
|
||||
Log.i(TAG, "Adding ice candidate $iceCandidate to $peerConnection")
|
||||
peerConnection?.addIceCandidate(iceCandidate)
|
||||
}
|
||||
|
||||
fun onIceCandidate(handler: (IceCandidate) -> Unit) {
|
||||
iceCandidateHandlers.add(handler)
|
||||
}
|
||||
|
||||
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
|
||||
dataChannelHandlers.add(handler)
|
||||
}
|
||||
|
||||
private fun initializeFactory(): PeerConnectionFactory {
|
||||
val initOptions = InitializationOptions.builder(context).createInitializationOptions()
|
||||
PeerConnectionFactory.initialize(initOptions)
|
||||
|
||||
val options = PeerConnectionFactory.Options()
|
||||
val peerConnectionFactory =
|
||||
PeerConnectionFactory.builder()
|
||||
.setVideoDecoderFactory(cameraService.videoDecoderFactory)
|
||||
.setVideoEncoderFactory(cameraService.videoEncoderFactory).setOptions(options)
|
||||
.createPeerConnectionFactory()
|
||||
return peerConnectionFactory
|
||||
}
|
||||
|
||||
private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
|
||||
val surfaceTextureHelper =
|
||||
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)
|
||||
return surfaceTextureHelper
|
||||
}
|
||||
|
||||
private fun createVideoSource(): VideoSource {
|
||||
val videoSource = peerConnectionFactory.createVideoSource(false)
|
||||
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver)
|
||||
videoCapturer.startCapture(1920, 1080, 30)
|
||||
return videoSource
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TYPE_OFFER = "offer"
|
||||
const val TYPE_ANSWER = "answer"
|
||||
const val TYPE_ICE_CANDIDATE = "ice_candidate"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package com.example.tvcontroller.services.webrtc
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import io.getstream.log.taggedLogger
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.util.logging.Logger
|
||||
import io.ktor.websocket.CloseReason
|
||||
import io.ktor.websocket.DefaultWebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
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 org.webrtc.Logging
|
||||
|
||||
const val SIGNALING_SERVER_IP_ADDRESS = "wss://signaling.stream.io"
|
||||
|
||||
class SignalingClient {
|
||||
private val logger by taggedLogger("Call:SignalingClient")
|
||||
private val signalingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val client = HttpClient {
|
||||
install(WebSockets)
|
||||
}
|
||||
|
||||
// opening web socket with signaling server
|
||||
private lateinit var ws: DefaultWebSocketSession
|
||||
|
||||
init {
|
||||
signalingScope.launch {
|
||||
try {
|
||||
ws = client.webSocketSession {
|
||||
url(SIGNALING_SERVER_IP_ADDRESS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e { "Error connecting to signaling server: ${e.message}" }
|
||||
_sessionStateFlow.value = WebRTCSessionState.Offline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session flow to send information about the session state to the subscribers
|
||||
private val _sessionStateFlow = MutableStateFlow(WebRTCSessionState.Offline)
|
||||
val sessionStateFlow: StateFlow<WebRTCSessionState> = _sessionStateFlow
|
||||
|
||||
// signaling commands to send commands to value pairs to the subscribers
|
||||
private val _signalingCommandFlow = MutableSharedFlow<Pair<SignalingCommand, String>>()
|
||||
val signalingCommandFlow: SharedFlow<Pair<SignalingCommand, String>> = _signalingCommandFlow
|
||||
|
||||
fun sendCommand(signalingCommand: SignalingCommand, message: String) {
|
||||
logger.d { "[sendCommand] $signalingCommand $message" }
|
||||
signalingScope.launch {
|
||||
ws.send("$signalingCommand $message")
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SignalingWebSocketListener : WebSocketListener() {
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
when {
|
||||
text.startsWith(SignalingCommand.STATE.toString(), true) ->
|
||||
handleStateMessage(text)
|
||||
text.startsWith(SignalingCommand.OFFER.toString(), true) ->
|
||||
handleSignalingCommand(SignalingCommand.OFFER, text)
|
||||
text.startsWith(SignalingCommand.ANSWER.toString(), true) ->
|
||||
handleSignalingCommand(SignalingCommand.ANSWER, text)
|
||||
text.startsWith(SignalingCommand.ICE.toString(), true) ->
|
||||
handleSignalingCommand(SignalingCommand.ICE, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStateMessage(message: String) {
|
||||
val state = getSeparatedMessage(message)
|
||||
_sessionStateFlow.value = WebRTCSessionState.valueOf(state)
|
||||
}
|
||||
|
||||
private fun handleSignalingCommand(command: SignalingCommand, text: String) {
|
||||
val value = getSeparatedMessage(text)
|
||||
logger.d { "received signaling: $command $value" }
|
||||
signalingScope.launch {
|
||||
_signalingCommandFlow.emit(command to value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSeparatedMessage(text: String) = text.substringAfter(' ')
|
||||
|
||||
fun dispose() {
|
||||
_sessionStateFlow.value = WebRTCSessionState.Offline
|
||||
signalingScope.cancel()
|
||||
ws.close(CloseReason(CloseReason.Codes.NORMAL, "Client is shutting down"))
|
||||
}
|
||||
}
|
||||
|
||||
enum class WebRTCSessionState {
|
||||
Active, // Offer and Answer messages has been sent
|
||||
Creating, // Creating session, offer has been sent
|
||||
Ready, // Both clients available and ready to initiate session
|
||||
Impossible, // We have less than two clients connected to the server
|
||||
Offline // unable to connect signaling server
|
||||
}
|
||||
|
||||
enum class SignalingCommand {
|
||||
STATE, // Command for WebRTCSessionState
|
||||
OFFER, // to send or receive offer
|
||||
ANSWER, // to send or receive answer
|
||||
ICE // to send and receive ice candidates
|
||||
}
|
||||
@ -1,136 +0,0 @@
|
||||
package com.example.tvcontroller.services.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.tvcontroller.client.WebsocketClient
|
||||
import com.example.tvcontroller.services.CameraService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.SessionDescription
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class WebRtcService(
|
||||
private val context: Context,
|
||||
private val websocketClient: WebsocketClient,
|
||||
private val cameraService: CameraService
|
||||
) {
|
||||
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
|
||||
private var rtcPeerConnection: RtcPeerConnection = createRtcPeerConnection()
|
||||
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
|
||||
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
|
||||
private var peerId: String = ""
|
||||
|
||||
fun connect() {
|
||||
Log.i(TAG, "Connecting to signaling server")
|
||||
websocketClient.onData(this::handleData)
|
||||
}
|
||||
|
||||
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
|
||||
dataChannelHandlers.add(handler)
|
||||
}
|
||||
|
||||
private fun createRtcPeerConnection(): RtcPeerConnection {
|
||||
val iceServers = arrayOf("stun:stun.l.google.com:19302")
|
||||
val webRtcService = this
|
||||
val rtcPeerConnection = RtcPeerConnection(context, cameraService).apply {
|
||||
setIceServers(iceServers)
|
||||
onIceCandidate(webRtcService::sendIceCandidate)
|
||||
initialize()
|
||||
}
|
||||
dataChannelHandlers.forEach { rtcPeerConnection.onDataChannelData(it) }
|
||||
return rtcPeerConnection
|
||||
}
|
||||
|
||||
private fun handleOffer(sdp: String) {
|
||||
rtcPeerConnection = createRtcPeerConnection()
|
||||
var mediaConstraints = MediaConstraints()
|
||||
val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp)
|
||||
rtcPeerConnection.apply {
|
||||
addTack(audioTrack)
|
||||
addTack(videoTrack)
|
||||
setRemoteDescription(remoteSessionDescription) {
|
||||
createAnswer(mediaConstraints) { localSessionDescription ->
|
||||
setLocalDescription(localSessionDescription) {
|
||||
sendAnswer(
|
||||
peerId, localSessionDescription.description ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleData(data: String) {
|
||||
Log.d(TAG, "Received data: $data")
|
||||
val dataJson = JSONObject(data)
|
||||
val senderId = dataJson.getString("sender")
|
||||
val message = dataJson.getJSONObject("message")
|
||||
val type = message.getString("type")
|
||||
when (type) {
|
||||
RtcPeerConnection.TYPE_OFFER -> {
|
||||
Log.i(TAG, "Received offer from $senderId")
|
||||
val sdp = message.getString("sdp")
|
||||
peerId = senderId
|
||||
handleOffer(sdp)
|
||||
}
|
||||
|
||||
RtcPeerConnection.TYPE_ICE_CANDIDATE -> {
|
||||
val candidateString = message.getString("candidate")
|
||||
handleReceiveIceCandidate(candidateString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveIceCandidate(candidateString: String) {
|
||||
Log.i(TAG, "Received ice candidate")
|
||||
val candidateJson = JSONObject(candidateString)
|
||||
val sdpMid = candidateJson.getString("sdpMid")
|
||||
val sdpMLineIndex = candidateJson.getInt("sdpMLineIndex")
|
||||
val sdp = candidateJson.getString("candidate")
|
||||
val candidate = IceCandidate(sdpMid, sdpMLineIndex, sdp)
|
||||
Log.i(TAG, "Candidate: $candidate")
|
||||
rtcPeerConnection.addIceCandidate(candidate)
|
||||
}
|
||||
|
||||
private fun sendIceCandidate(iceCandidate: IceCandidate) {
|
||||
val messageJson = JSONObject()
|
||||
messageJson.put("type", TYPE_SIGNALING)
|
||||
messageJson.put("target", peerId)
|
||||
messageJson.put("message", JSONObject().apply {
|
||||
put("candidate", JSONObject().apply {
|
||||
put("sdpMid", iceCandidate.sdpMid)
|
||||
put("sdpMLineIndex", iceCandidate.sdpMLineIndex)
|
||||
put("candidate", iceCandidate.sdp)
|
||||
})
|
||||
put("type", RtcPeerConnection.TYPE_ICE_CANDIDATE)
|
||||
})
|
||||
|
||||
runBlocking {
|
||||
Log.i(TAG, "Sending ice candidate")
|
||||
websocketClient.sendJson(messageJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAnswer(targetId: String, sdp: String) {
|
||||
val messageJson = JSONObject()
|
||||
messageJson.put("type", TYPE_SIGNALING)
|
||||
messageJson.put("target", targetId)
|
||||
messageJson.put("message", JSONObject().apply {
|
||||
put("sdp", sdp)
|
||||
put("type", RtcPeerConnection.TYPE_ANSWER)
|
||||
})
|
||||
|
||||
runBlocking {
|
||||
Log.i(TAG, "Sending answer")
|
||||
websocketClient.sendJson(messageJson)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TYPE_SIGNALING = "signaling"
|
||||
|
||||
private const val TAG = "WebRtcService"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,338 @@
|
||||
package com.example.tvcontroller.services.webrtc.peer
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import io.getstream.log.taggedLogger
|
||||
import com.example.tvcontroller.services.webrtc.utils.addRtcIceCandidate
|
||||
import com.example.tvcontroller.services.webrtc.utils.createValue
|
||||
import com.example.tvcontroller.services.webrtc.utils.setValue
|
||||
import com.example.tvcontroller.services.webrtc.utils.stringify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.webrtc.CandidatePairChangeEvent
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.IceCandidateErrorEvent
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.MediaStreamTrack
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.RTCStatsReport
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.RtpTransceiver
|
||||
import org.webrtc.SessionDescription
|
||||
|
||||
/**
|
||||
* Wrapper around the WebRTC connection that contains tracks.
|
||||
*
|
||||
* @param coroutineScope The scope used to listen to stats events.
|
||||
* @param type The internal type of the PeerConnection. Check [StreamPeerType].
|
||||
* @param mediaConstraints Constraints used for the connections.
|
||||
* @param onStreamAdded Handler when a new [MediaStream] gets added.
|
||||
* @param onNegotiationNeeded Handler when there's a new negotiation.
|
||||
* @param onIceCandidate Handler whenever we receive [IceCandidate]s.
|
||||
*/
|
||||
class StreamPeerConnection(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val type: StreamPeerType,
|
||||
private val mediaConstraints: MediaConstraints,
|
||||
private val onStreamAdded: ((MediaStream) -> Unit)?,
|
||||
private val onNegotiationNeeded: ((StreamPeerConnection, StreamPeerType) -> Unit)?,
|
||||
private val onIceCandidate: ((IceCandidate, StreamPeerType) -> Unit)?,
|
||||
private val onVideoTrack: ((RtpTransceiver?) -> Unit)?
|
||||
) : PeerConnection.Observer {
|
||||
|
||||
private val typeTag = type.stringify()
|
||||
|
||||
private val logger by taggedLogger("Call:PeerConnection")
|
||||
|
||||
/**
|
||||
* The wrapped connection for all the WebRTC communication.
|
||||
*/
|
||||
lateinit var connection: PeerConnection
|
||||
private set
|
||||
|
||||
/**
|
||||
* Used to manage the stats observation lifecycle.
|
||||
*/
|
||||
private var statsJob: Job? = null
|
||||
|
||||
/**
|
||||
* Used to pool together and store [IceCandidate]s before consuming them.
|
||||
*/
|
||||
private val pendingIceMutex = Mutex()
|
||||
private val pendingIceCandidates = mutableListOf<IceCandidate>()
|
||||
|
||||
/**
|
||||
* Contains stats events for observation.
|
||||
*/
|
||||
private val statsFlow: MutableStateFlow<RTCStatsReport?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
logger.i { "<init> #sfu; #$typeTag; mediaConstraints: $mediaConstraints" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a [StreamPeerConnection] using a WebRTC [PeerConnection].
|
||||
*
|
||||
* @param peerConnection The connection that holds audio and video tracks.
|
||||
*/
|
||||
fun initialize(peerConnection: PeerConnection) {
|
||||
logger.d { "[initialize] #sfu; #$typeTag; peerConnection: $peerConnection" }
|
||||
this.connection = peerConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to create an offer whenever there's a negotiation that we need to process on the
|
||||
* publisher side.
|
||||
*
|
||||
* @return [Result] wrapper of the [SessionDescription] for the publisher.
|
||||
*/
|
||||
suspend fun createOffer(): Result<SessionDescription> {
|
||||
logger.d { "[createOffer] #sfu; #$typeTag; no args" }
|
||||
return createValue { connection.createOffer(it, mediaConstraints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to create an answer whenever there's a subscriber offer.
|
||||
*
|
||||
* @return [Result] wrapper of the [SessionDescription] for the subscriber.
|
||||
*/
|
||||
suspend fun createAnswer(): Result<SessionDescription> {
|
||||
logger.d { "[createAnswer] #sfu; #$typeTag; no args" }
|
||||
return createValue { connection.createAnswer(it, mediaConstraints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set up the SDP on underlying connections and to add [pendingIceCandidates] to the
|
||||
* connection for listening.
|
||||
*
|
||||
* @param sessionDescription That contains the remote SDP.
|
||||
* @return An empty [Result], if the operation has been successful or not.
|
||||
*/
|
||||
suspend fun setRemoteDescription(sessionDescription: SessionDescription): Result<Unit> {
|
||||
logger.d { "[setRemoteDescription] #sfu; #$typeTag; answerSdp: ${sessionDescription.stringify()}" }
|
||||
return setValue {
|
||||
connection.setRemoteDescription(
|
||||
it,
|
||||
SessionDescription(
|
||||
sessionDescription.type,
|
||||
sessionDescription.description.mungeCodecs()
|
||||
)
|
||||
)
|
||||
}.also {
|
||||
pendingIceMutex.withLock {
|
||||
pendingIceCandidates.forEach { iceCandidate ->
|
||||
logger.i { "[setRemoteDescription] #sfu; #subscriber; pendingRtcIceCandidate: $iceCandidate" }
|
||||
connection.addRtcIceCandidate(iceCandidate)
|
||||
}
|
||||
pendingIceCandidates.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the local description for a connection either for the subscriber or publisher based on
|
||||
* the flow.
|
||||
*
|
||||
* @param sessionDescription That contains the subscriber or publisher SDP.
|
||||
* @return An empty [Result], if the operation has been successful or not.
|
||||
*/
|
||||
suspend fun setLocalDescription(sessionDescription: SessionDescription): Result<Unit> {
|
||||
val sdp = SessionDescription(
|
||||
sessionDescription.type,
|
||||
sessionDescription.description.mungeCodecs()
|
||||
)
|
||||
logger.d { "[setLocalDescription] #sfu; #$typeTag; offerSdp: ${sessionDescription.stringify()}" }
|
||||
return setValue { connection.setLocalDescription(it, sdp) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an [IceCandidate] to the underlying [connection] if it's already been set up, or stores
|
||||
* it for later consumption.
|
||||
*
|
||||
* @param iceCandidate To process and add to the connection.
|
||||
* @return An empty [Result], if the operation has been successful or not.
|
||||
*/
|
||||
suspend fun addIceCandidate(iceCandidate: IceCandidate): Result<Unit> {
|
||||
if (connection.remoteDescription == null) {
|
||||
logger.w { "[addIceCandidate] #sfu; #$typeTag; postponed (no remoteDescription): $iceCandidate" }
|
||||
pendingIceMutex.withLock {
|
||||
pendingIceCandidates.add(iceCandidate)
|
||||
}
|
||||
return Result.failure(RuntimeException("RemoteDescription is not set"))
|
||||
}
|
||||
logger.d { "[addIceCandidate] #sfu; #$typeTag; rtcIceCandidate: $iceCandidate" }
|
||||
return connection.addRtcIceCandidate(iceCandidate).also {
|
||||
logger.v { "[addIceCandidate] #sfu; #$typeTag; completed: $it" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer connection listeners.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Triggered whenever there's a new [RtcIceCandidate] for the call. Used to update our tracks
|
||||
* and subscriptions.
|
||||
*
|
||||
* @param candidate The new candidate.
|
||||
*/
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
logger.i { "[onIceCandidate] #sfu; #$typeTag; candidate: $candidate" }
|
||||
if (candidate == null) return
|
||||
|
||||
onIceCandidate?.invoke(candidate, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered whenever there's a new [MediaStream] that was added to the connection.
|
||||
*
|
||||
* @param stream The stream that contains audio or video.
|
||||
*/
|
||||
override fun onAddStream(stream: MediaStream?) {
|
||||
logger.i { "[onAddStream] #sfu; #$typeTag; stream: $stream" }
|
||||
if (stream != null) {
|
||||
onStreamAdded?.invoke(stream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered whenever there's a new [MediaStream] or [MediaStreamTrack] that's been added
|
||||
* to the call. It contains all audio and video tracks for a given session.
|
||||
*
|
||||
* @param receiver The receiver of tracks.
|
||||
* @param mediaStreams The streams that were added containing their appropriate tracks.
|
||||
*/
|
||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out MediaStream>?) {
|
||||
logger.i { "[onAddTrack] #sfu; #$typeTag; receiver: $receiver, mediaStreams: $mediaStreams" }
|
||||
mediaStreams?.forEach { mediaStream ->
|
||||
logger.v { "[onAddTrack] #sfu; #$typeTag; mediaStream: $mediaStream" }
|
||||
mediaStream.audioTracks?.forEach { remoteAudioTrack ->
|
||||
logger.v { "[onAddTrack] #sfu; #$typeTag; remoteAudioTrack: ${remoteAudioTrack.stringify()}" }
|
||||
remoteAudioTrack.setEnabled(true)
|
||||
}
|
||||
onStreamAdded?.invoke(mediaStream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered whenever there's a new negotiation needed for the active [PeerConnection].
|
||||
*/
|
||||
override fun onRenegotiationNeeded() {
|
||||
logger.i { "[onRenegotiationNeeded] #sfu; #$typeTag; no args" }
|
||||
onNegotiationNeeded?.invoke(this, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered whenever a [MediaStream] was removed.
|
||||
*
|
||||
* @param stream The stream that was removed from the connection.
|
||||
*/
|
||||
override fun onRemoveStream(stream: MediaStream?) {}
|
||||
|
||||
/**
|
||||
* Triggered when the connection state changes. Used to start and stop the stats observing.
|
||||
*
|
||||
* @param newState The new state of the [PeerConnection].
|
||||
*/
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
logger.i { "[onIceConnectionChange] #sfu; #$typeTag; newState: $newState" }
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CLOSED,
|
||||
PeerConnection.IceConnectionState.FAILED,
|
||||
PeerConnection.IceConnectionState.DISCONNECTED -> statsJob?.cancel()
|
||||
PeerConnection.IceConnectionState.CONNECTED -> statsJob = observeStats()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The [RTCStatsReport] for the active connection.
|
||||
*/
|
||||
fun getStats(): StateFlow<RTCStatsReport?> {
|
||||
return statsFlow
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the local connection stats and emits it to [statsFlow] that users can consume.
|
||||
*/
|
||||
private fun observeStats() = coroutineScope.launch {
|
||||
while (isActive) {
|
||||
delay(10_000L)
|
||||
connection.getStats {
|
||||
logger.v { "[observeStats] #sfu; #$typeTag; stats: $it" }
|
||||
statsFlow.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||
logger.i { "[onTrack] #sfu; #$typeTag; transceiver: $transceiver" }
|
||||
onVideoTrack?.invoke(transceiver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain - [PeerConnection] and [PeerConnection.Observer] related callbacks.
|
||||
*/
|
||||
override fun onRemoveTrack(receiver: RtpReceiver?) {
|
||||
logger.i { "[onRemoveTrack] #sfu; #$typeTag; receiver: $receiver" }
|
||||
}
|
||||
|
||||
override fun onSignalingChange(newState: PeerConnection.SignalingState?) {
|
||||
logger.d { "[onSignalingChange] #sfu; #$typeTag; newState: $newState" }
|
||||
}
|
||||
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) {
|
||||
logger.i { "[onIceConnectionReceivingChange] #sfu; #$typeTag; receiving: $receiving" }
|
||||
}
|
||||
|
||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
|
||||
logger.i { "[onIceGatheringChange] #sfu; #$typeTag; newState: $newState" }
|
||||
}
|
||||
|
||||
override fun onIceCandidatesRemoved(iceCandidates: Array<out org.webrtc.IceCandidate>?) {
|
||||
logger.i { "[onIceCandidatesRemoved] #sfu; #$typeTag; iceCandidates: $iceCandidates" }
|
||||
}
|
||||
|
||||
override fun onIceCandidateError(event: IceCandidateErrorEvent?) {
|
||||
logger.e { "[onIceCandidateError] #sfu; #$typeTag; event: ${event?.stringify()}" }
|
||||
}
|
||||
|
||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||
logger.i { "[onConnectionChange] #sfu; #$typeTag; newState: $newState" }
|
||||
}
|
||||
|
||||
override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) {
|
||||
logger.i { "[onSelectedCandidatePairChanged] #sfu; #$typeTag; event: $event" }
|
||||
}
|
||||
|
||||
override fun onDataChannel(channel: DataChannel?): Unit = Unit
|
||||
|
||||
override fun toString(): String =
|
||||
"StreamPeerConnection(type='$typeTag', constraints=$mediaConstraints)"
|
||||
|
||||
private fun String.mungeCodecs(): String {
|
||||
return this.replace("vp9", "VP9").replace("vp8", "VP8").replace("h264", "H264")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,287 @@
|
||||
package com.example.tvcontroller.services.webrtc.peer
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.getstream.log.taggedLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.HardwareVideoEncoderFactory
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.Logging
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpTransceiver
|
||||
import org.webrtc.SimulcastVideoEncoderFactory
|
||||
import org.webrtc.SoftwareVideoEncoderFactory
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import org.webrtc.audio.JavaAudioDeviceModule
|
||||
|
||||
class StreamPeerConnectionFactory constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
private val webRtcLogger by taggedLogger("Call:WebRTC")
|
||||
private val audioLogger by taggedLogger("Call:AudioTrackCallback")
|
||||
|
||||
val eglBaseContext: EglBase.Context by lazy {
|
||||
EglBase.create().eglBaseContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Default video decoder factory used to unpack video from the remote tracks.
|
||||
*/
|
||||
private val videoDecoderFactory by lazy {
|
||||
DefaultVideoDecoderFactory(
|
||||
eglBaseContext
|
||||
)
|
||||
}
|
||||
|
||||
// rtcConfig contains STUN and TURN servers list
|
||||
val rtcConfig = PeerConnection.RTCConfiguration(
|
||||
arrayListOf(
|
||||
// adding google's standard server
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
).apply {
|
||||
// it's very important to use new unified sdp semantics PLAN_B is deprecated
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
}
|
||||
|
||||
/**
|
||||
* Default encoder factory that supports Simulcast, used to send video tracks to the server.
|
||||
*/
|
||||
private val videoEncoderFactory by lazy {
|
||||
val hardwareEncoder = HardwareVideoEncoderFactory(eglBaseContext, true, true)
|
||||
SimulcastVideoEncoderFactory(hardwareEncoder, SoftwareVideoEncoderFactory())
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that builds all the connections based on the extensive configuration provided under
|
||||
* the hood.
|
||||
*/
|
||||
private val factory by lazy {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.setInjectableLogger({ message, severity, label ->
|
||||
when (severity) {
|
||||
Logging.Severity.LS_VERBOSE -> {
|
||||
webRtcLogger.v { "[onLogMessage] label: $label, message: $message" }
|
||||
}
|
||||
|
||||
Logging.Severity.LS_INFO -> {
|
||||
webRtcLogger.i { "[onLogMessage] label: $label, message: $message" }
|
||||
}
|
||||
|
||||
Logging.Severity.LS_WARNING -> {
|
||||
webRtcLogger.w { "[onLogMessage] label: $label, message: $message" }
|
||||
}
|
||||
|
||||
Logging.Severity.LS_ERROR -> {
|
||||
webRtcLogger.e { "[onLogMessage] label: $label, message: $message" }
|
||||
}
|
||||
|
||||
Logging.Severity.LS_NONE -> {
|
||||
webRtcLogger.d { "[onLogMessage] label: $label, message: $message" }
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}, Logging.Severity.LS_VERBOSE)
|
||||
.createInitializationOptions()
|
||||
)
|
||||
|
||||
PeerConnectionFactory.builder()
|
||||
.setVideoDecoderFactory(videoDecoderFactory)
|
||||
.setVideoEncoderFactory(videoEncoderFactory)
|
||||
.setAudioDeviceModule(
|
||||
JavaAudioDeviceModule
|
||||
.builder(context)
|
||||
.setUseHardwareAcousticEchoCanceler(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
.setUseHardwareNoiseSuppressor(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
.setAudioRecordErrorCallback(object :
|
||||
JavaAudioDeviceModule.AudioRecordErrorCallback {
|
||||
override fun onWebRtcAudioRecordInitError(p0: String?) {
|
||||
audioLogger.w { "[onWebRtcAudioRecordInitError] $p0" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioRecordStartError(
|
||||
p0: JavaAudioDeviceModule.AudioRecordStartErrorCode?,
|
||||
p1: String?
|
||||
) {
|
||||
audioLogger.w { "[onWebRtcAudioRecordInitError] $p1" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioRecordError(p0: String?) {
|
||||
audioLogger.w { "[onWebRtcAudioRecordError] $p0" }
|
||||
}
|
||||
})
|
||||
.setAudioTrackErrorCallback(object :
|
||||
JavaAudioDeviceModule.AudioTrackErrorCallback {
|
||||
override fun onWebRtcAudioTrackInitError(p0: String?) {
|
||||
audioLogger.w { "[onWebRtcAudioTrackInitError] $p0" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioTrackStartError(
|
||||
p0: JavaAudioDeviceModule.AudioTrackStartErrorCode?,
|
||||
p1: String?
|
||||
) {
|
||||
audioLogger.w { "[onWebRtcAudioTrackStartError] $p0" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioTrackError(p0: String?) {
|
||||
audioLogger.w { "[onWebRtcAudioTrackError] $p0" }
|
||||
}
|
||||
})
|
||||
.setAudioRecordStateCallback(object :
|
||||
JavaAudioDeviceModule.AudioRecordStateCallback {
|
||||
override fun onWebRtcAudioRecordStart() {
|
||||
audioLogger.d { "[onWebRtcAudioRecordStart] no args" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioRecordStop() {
|
||||
audioLogger.d { "[onWebRtcAudioRecordStop] no args" }
|
||||
}
|
||||
})
|
||||
.setAudioTrackStateCallback(object :
|
||||
JavaAudioDeviceModule.AudioTrackStateCallback {
|
||||
override fun onWebRtcAudioTrackStart() {
|
||||
audioLogger.d { "[onWebRtcAudioTrackStart] no args" }
|
||||
}
|
||||
|
||||
override fun onWebRtcAudioTrackStop() {
|
||||
audioLogger.d { "[onWebRtcAudioTrackStop] no args" }
|
||||
}
|
||||
})
|
||||
.createAudioDeviceModule().also {
|
||||
it.setMicrophoneMute(false)
|
||||
it.setSpeakerMute(false)
|
||||
}
|
||||
)
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [StreamPeerConnection] that wraps the WebRTC [PeerConnection] and exposes several
|
||||
* helpful handlers.
|
||||
*
|
||||
* @param coroutineScope Scope used for asynchronous operations.
|
||||
* @param configuration The [PeerConnection.RTCConfiguration] used to set up the connection.
|
||||
* @param type The type of connection, either a subscriber of a publisher.
|
||||
* @param mediaConstraints Constraints used for audio and video tracks in the connection.
|
||||
* @param onStreamAdded Handler when a new [MediaStream] gets added.
|
||||
* @param onNegotiationNeeded Handler when there's a new negotiation.
|
||||
* @param onIceCandidateRequest Handler whenever we receive [IceCandidate]s.
|
||||
* @return [StreamPeerConnection] That's fully set up and can be observed and used to send and
|
||||
* receive tracks.
|
||||
*/
|
||||
fun makePeerConnection(
|
||||
coroutineScope: CoroutineScope,
|
||||
configuration: PeerConnection.RTCConfiguration,
|
||||
type: StreamPeerType,
|
||||
mediaConstraints: MediaConstraints,
|
||||
onStreamAdded: ((MediaStream) -> Unit)? = null,
|
||||
onNegotiationNeeded: ((StreamPeerConnection, StreamPeerType) -> Unit)? = null,
|
||||
onIceCandidateRequest: ((IceCandidate, StreamPeerType) -> Unit)? = null,
|
||||
onVideoTrack: ((RtpTransceiver?) -> Unit)? = null
|
||||
): StreamPeerConnection {
|
||||
val peerConnection = StreamPeerConnection(
|
||||
coroutineScope = coroutineScope,
|
||||
type = type,
|
||||
mediaConstraints = mediaConstraints,
|
||||
onStreamAdded = onStreamAdded,
|
||||
onNegotiationNeeded = onNegotiationNeeded,
|
||||
onIceCandidate = onIceCandidateRequest,
|
||||
onVideoTrack = onVideoTrack
|
||||
)
|
||||
val connection = makePeerConnectionInternal(
|
||||
configuration = configuration,
|
||||
observer = peerConnection
|
||||
)
|
||||
return peerConnection.apply { initialize(connection) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [PeerConnection] internally that connects to the server and is able to send and
|
||||
* receive tracks.
|
||||
*
|
||||
* @param configuration The [PeerConnection.RTCConfiguration] used to set up the connection.
|
||||
* @param observer Handler used to observe different states of the connection.
|
||||
* @return [PeerConnection] that's fully set up.
|
||||
*/
|
||||
private fun makePeerConnectionInternal(
|
||||
configuration: PeerConnection.RTCConfiguration,
|
||||
observer: PeerConnection.Observer?
|
||||
): PeerConnection {
|
||||
return requireNotNull(
|
||||
factory.createPeerConnection(
|
||||
configuration,
|
||||
observer
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [VideoSource] from the [factory] that can be used for regular video share (camera)
|
||||
* or screen sharing.
|
||||
*
|
||||
* @param isScreencast If we're screen sharing using this source.
|
||||
* @return [VideoSource] that can be used to build tracks.
|
||||
*/
|
||||
fun makeVideoSource(isScreencast: Boolean): VideoSource =
|
||||
factory.createVideoSource(isScreencast)
|
||||
|
||||
/**
|
||||
* Builds a [VideoTrack] from the [factory] that can be used for regular video share (camera)
|
||||
* or screen sharing.
|
||||
*
|
||||
* @param source The [VideoSource] used for the track.
|
||||
* @param trackId The unique ID for this track.
|
||||
* @return [VideoTrack] That represents a video feed.
|
||||
*/
|
||||
fun makeVideoTrack(
|
||||
source: VideoSource,
|
||||
trackId: String
|
||||
): VideoTrack = factory.createVideoTrack(trackId, source)
|
||||
|
||||
/**
|
||||
* Builds an [AudioSource] from the [factory] that can be used for audio sharing.
|
||||
*
|
||||
* @param constraints The constraints used to change the way the audio behaves.
|
||||
* @return [AudioSource] that can be used to build tracks.
|
||||
*/
|
||||
fun makeAudioSource(constraints: MediaConstraints = MediaConstraints()): AudioSource =
|
||||
factory.createAudioSource(constraints)
|
||||
|
||||
/**
|
||||
* Builds an [AudioTrack] from the [factory] that can be used for regular video share (camera)
|
||||
* or screen sharing.
|
||||
*
|
||||
* @param source The [AudioSource] used for the track.
|
||||
* @param trackId The unique ID for this track.
|
||||
* @return [AudioTrack] That represents an audio feed.
|
||||
*/
|
||||
fun makeAudioTrack(
|
||||
source: AudioSource,
|
||||
trackId: String
|
||||
): AudioTrack = factory.createAudioTrack(trackId, source)
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.example.tvcontroller.services.webrtc.peer
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The type of peer connections, either a [PUBLISHER] that sends data to the call or a [SUBSCRIBER]
|
||||
* that receives and decodes the data from the server.
|
||||
*/
|
||||
enum class StreamPeerType {
|
||||
PUBLISHER,
|
||||
SUBSCRIBER
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.example.tvcontroller.services.webrtc.utils
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import org.webrtc.AddIceObserver
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.PeerConnection
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend fun PeerConnection.addRtcIceCandidate(iceCandidate: IceCandidate): Result<Unit> {
|
||||
return suspendCoroutine { cont ->
|
||||
addIceCandidate(
|
||||
iceCandidate,
|
||||
object : AddIceObserver {
|
||||
override fun onAddSuccess() {
|
||||
cont.resume(Result.success(Unit))
|
||||
}
|
||||
|
||||
override fun onAddFailure(error: String?) {
|
||||
cont.resume(Result.failure(RuntimeException(error)))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.example.tvcontroller.services.webrtc.utils
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend inline fun createValue(
|
||||
crossinline call: (SdpObserver) -> Unit
|
||||
): Result<SessionDescription> = suspendCoroutine {
|
||||
val observer = object : SdpObserver {
|
||||
|
||||
/**
|
||||
* Handling of create values.
|
||||
*/
|
||||
override fun onCreateSuccess(description: SessionDescription?) {
|
||||
if (description != null) {
|
||||
it.resume(Result.success(description))
|
||||
} else {
|
||||
it.resume(Result.failure(RuntimeException("SessionDescription is null!")))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateFailure(message: String?) =
|
||||
it.resume(Result.failure(RuntimeException(message)))
|
||||
|
||||
/**
|
||||
* We ignore set results.
|
||||
*/
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onSetFailure(p0: String?) = Unit
|
||||
}
|
||||
|
||||
call(observer)
|
||||
}
|
||||
|
||||
suspend inline fun setValue(
|
||||
crossinline call: (SdpObserver) -> Unit
|
||||
): Result<Unit> = suspendCoroutine {
|
||||
val observer = object : SdpObserver {
|
||||
/**
|
||||
* We ignore create results.
|
||||
*/
|
||||
override fun onCreateFailure(p0: String?) = Unit
|
||||
override fun onCreateSuccess(p0: SessionDescription?) = Unit
|
||||
|
||||
/**
|
||||
* Handling of set values.
|
||||
*/
|
||||
override fun onSetSuccess() = it.resume(Result.success(Unit))
|
||||
override fun onSetFailure(message: String?) =
|
||||
it.resume(Result.failure(RuntimeException(message)))
|
||||
}
|
||||
|
||||
call(observer)
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.example.tvcontroller.services.webrtc.utils
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
import com.example.tvcontroller.services.webrtc.peer.StreamPeerType
|
||||
import org.webrtc.IceCandidateErrorEvent
|
||||
import org.webrtc.MediaStreamTrack
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.audio.JavaAudioDeviceModule
|
||||
|
||||
fun SessionDescription.stringify(): String =
|
||||
"SessionDescription(type=$type, description=$description)"
|
||||
|
||||
fun MediaStreamTrack.stringify(): String {
|
||||
return "MediaStreamTrack(id=${id()}, kind=${kind()}, enabled: ${enabled()}, state=${state()})"
|
||||
}
|
||||
|
||||
fun IceCandidateErrorEvent.stringify(): String {
|
||||
return "IceCandidateErrorEvent(errorCode=$errorCode, $errorText, address=$address, port=$port, url=$url)"
|
||||
}
|
||||
|
||||
fun JavaAudioDeviceModule.AudioSamples.stringify(): String {
|
||||
return "AudioSamples(audioFormat=$audioFormat, channelCount=$channelCount" +
|
||||
", sampleRate=$sampleRate, data.size=${data.size})"
|
||||
}
|
||||
|
||||
fun StreamPeerType.stringify() = when (this) {
|
||||
StreamPeerType.PUBLISHER -> "publisher"
|
||||
StreamPeerType.SUBSCRIBER -> "subscriber"
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.example.tvcontroller.ui
|
||||
|
||||
import com.example.tvcontroller.Screen
|
||||
|
||||
data class AppUiState(
|
||||
val currentScreen: Screen = Screen.Settings,
|
||||
)
|
||||
@ -0,0 +1,16 @@
|
||||
package com.example.tvcontroller.ui
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class AppViewModel() : ViewModel() {
|
||||
private var isBluetoothEnabled = mutableStateOf(false)
|
||||
|
||||
fun setBluetoothEnabled(enabled: Boolean) {
|
||||
isBluetoothEnabled.value = enabled
|
||||
}
|
||||
|
||||
fun isBluetoothEnabled(): Boolean {
|
||||
return isBluetoothEnabled.value
|
||||
}
|
||||
}
|
||||
@ -3,62 +3,23 @@ package com.example.tvcontroller.ui.components
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.RendererCommon
|
||||
import org.webrtc.VideoTrack
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
eglBaseContext: EglBase.Context, videoTrack: VideoTrack, modifier: Modifier = Modifier
|
||||
controller: LifecycleCameraController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val trackState: MutableState<VideoTrack?> = remember { mutableStateOf(null) }
|
||||
var view: VideoTextureViewRenderer? by remember { mutableStateOf(null) }
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
VideoTextureViewRenderer(it).apply {
|
||||
init(
|
||||
eglBaseContext,
|
||||
object : RendererCommon.RendererEvents {
|
||||
override fun onFirstFrameRendered() = Unit
|
||||
override fun onFrameResolutionChanged(p0: Int, p1: Int, p2: Int) = Unit
|
||||
}
|
||||
)
|
||||
setupVideo(trackState, videoTrack, this)
|
||||
view = this
|
||||
PreviewView(it).apply {
|
||||
this.controller = controller
|
||||
controller.bindToLifecycle(lifecycleOwner)
|
||||
}
|
||||
},
|
||||
update = { v -> setupVideo(trackState, videoTrack, v) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanTrack(
|
||||
view: VideoTextureViewRenderer?,
|
||||
trackState: MutableState<VideoTrack?>
|
||||
) {
|
||||
view?.let { trackState.value?.removeSink(it) }
|
||||
trackState.value = null
|
||||
}
|
||||
|
||||
private fun setupVideo(
|
||||
trackState: MutableState<VideoTrack?>,
|
||||
track: VideoTrack,
|
||||
renderer: VideoTextureViewRenderer
|
||||
) {
|
||||
if (trackState.value == track) {
|
||||
return
|
||||
}
|
||||
|
||||
cleanTrack(renderer, trackState)
|
||||
|
||||
trackState.value = track
|
||||
track.addSink(renderer)
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
package com.example.tvcontroller.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RemoteButton(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String? = "",
|
||||
onClick: () -> Unit,
|
||||
icon: Painter? = null,
|
||||
width: Dp = 64.dp,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick)
|
||||
.then(modifier)
|
||||
) {
|
||||
if (!text.isNullOrBlank()) Text(text = text)
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
package com.example.tvcontroller.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RemoteButtonPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(64.dp)
|
||||
.then(modifier)
|
||||
) {}
|
||||
}
|
||||
@ -1,191 +0,0 @@
|
||||
package com.example.tvcontroller.ui.components
|
||||
/*
|
||||
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.AttributeSet
|
||||
import android.view.TextureView
|
||||
import android.view.TextureView.SurfaceTextureListener
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.EglRenderer
|
||||
import org.webrtc.GlRectDrawer
|
||||
import org.webrtc.RendererCommon.RendererEvents
|
||||
import org.webrtc.ThreadUtils
|
||||
import org.webrtc.VideoFrame
|
||||
import org.webrtc.VideoSink
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
/**
|
||||
* Custom [TextureView] used to render local/incoming videos on the screen.
|
||||
*/
|
||||
open class VideoTextureViewRenderer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : TextureView(context, attrs), VideoSink, SurfaceTextureListener {
|
||||
|
||||
/**
|
||||
* Cached resource name.
|
||||
*/
|
||||
private val resourceName: String = getResourceName()
|
||||
|
||||
/**
|
||||
* Renderer used to render the video.
|
||||
*/
|
||||
private val eglRenderer: EglRenderer = EglRenderer(resourceName)
|
||||
|
||||
/**
|
||||
* Callback used for reporting render events.
|
||||
*/
|
||||
private var rendererEvents: RendererEvents? = null
|
||||
|
||||
/**
|
||||
* Handler to access the UI thread.
|
||||
*/
|
||||
private val uiThreadHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Whether the first frame has been rendered or not.
|
||||
*/
|
||||
private var isFirstFrameRendered = false
|
||||
|
||||
/**
|
||||
* The rotated [VideoFrame] width.
|
||||
*/
|
||||
private var rotatedFrameWidth = 0
|
||||
|
||||
/**
|
||||
* The rotated [VideoFrame] height.
|
||||
*/
|
||||
private var rotatedFrameHeight = 0
|
||||
|
||||
/**
|
||||
* The rotated [VideoFrame] rotation.
|
||||
*/
|
||||
private var frameRotation = 0
|
||||
|
||||
init {
|
||||
surfaceTextureListener = this
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new frame is received. Sends the frame to be rendered.
|
||||
*
|
||||
* @param videoFrame The [VideoFrame] received from WebRTC connection to draw on the screen.
|
||||
*/
|
||||
override fun onFrame(videoFrame: VideoFrame) {
|
||||
eglRenderer.onFrame(videoFrame)
|
||||
updateFrameData(videoFrame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the frame data and notifies [rendererEvents] about the changes.
|
||||
*/
|
||||
private fun updateFrameData(videoFrame: VideoFrame) {
|
||||
if (isFirstFrameRendered) {
|
||||
rendererEvents?.onFirstFrameRendered()
|
||||
isFirstFrameRendered = true
|
||||
}
|
||||
|
||||
if (videoFrame.rotatedWidth != rotatedFrameWidth ||
|
||||
videoFrame.rotatedHeight != rotatedFrameHeight ||
|
||||
videoFrame.rotation != frameRotation
|
||||
) {
|
||||
rotatedFrameWidth = videoFrame.rotatedWidth
|
||||
rotatedFrameHeight = videoFrame.rotatedHeight
|
||||
frameRotation = videoFrame.rotation
|
||||
|
||||
uiThreadHandler.post {
|
||||
rendererEvents?.onFrameResolutionChanged(
|
||||
rotatedFrameWidth,
|
||||
rotatedFrameHeight,
|
||||
frameRotation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the view is laid out we need to set the correct layout aspect ratio to the renderer so that the image
|
||||
* is scaled correctly.
|
||||
*/
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
eglRenderer.setLayoutAspectRatio((right - left) / (bottom.toFloat() - top))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the renderer. Should be called from the main thread.
|
||||
*
|
||||
* @param sharedContext [EglBase.Context]
|
||||
* @param rendererEvents Sets the render event listener.
|
||||
*/
|
||||
fun init(
|
||||
sharedContext: EglBase.Context,
|
||||
rendererEvents: RendererEvents
|
||||
) {
|
||||
ThreadUtils.checkIsOnMainThread()
|
||||
this.rendererEvents = rendererEvents
|
||||
eglRenderer.init(sharedContext, EglBase.CONFIG_PLAIN, GlRectDrawer())
|
||||
}
|
||||
|
||||
/**
|
||||
* [SurfaceTextureListener] callback that lets us know when a surface texture is ready and we can draw on it.
|
||||
*/
|
||||
override fun onSurfaceTextureAvailable(
|
||||
surfaceTexture: SurfaceTexture,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
eglRenderer.createEglSurface(surfaceTexture)
|
||||
}
|
||||
|
||||
/**
|
||||
* [SurfaceTextureListener] callback that lets us know when a surface texture is destroyed we need to stop drawing
|
||||
* on it.
|
||||
*/
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
val completionLatch = CountDownLatch(1)
|
||||
eglRenderer.releaseEglSurface { completionLatch.countDown() }
|
||||
ThreadUtils.awaitUninterruptibly(completionLatch)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(
|
||||
surfaceTexture: SurfaceTexture,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
eglRenderer.release()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun getResourceName(): String {
|
||||
return try {
|
||||
resources.getResourceEntryName(id) + ": "
|
||||
} catch (e: Resources.NotFoundException) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,34 +1,34 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.camera.view.CameraController
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.tvcontroller.services.WebRtcService
|
||||
import com.example.tvcontroller.ui.components.CameraPreview
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.VideoTrack
|
||||
|
||||
@Composable
|
||||
fun CameraView(eglBaseContext: EglBase.Context, videoTrack: VideoTrack) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
var ratio = if (isLandscape) 16/9f else 9/16f
|
||||
fun CameraView() {
|
||||
val webRtcService = remember { WebRtcService() }
|
||||
val context = LocalContext.current
|
||||
val controller = remember {
|
||||
LifecycleCameraController(context).apply {
|
||||
setEnabledUseCases(CameraController.IMAGE_ANALYSIS)
|
||||
setImageAnalysisAnalyzer(ContextCompat.getMainExecutor(context), webRtcService)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
.fillMaxSize()
|
||||
.padding(all = 16.dp),
|
||||
) {
|
||||
Box(modifier = if (isLandscape) Modifier.aspectRatio(ratio).fillMaxHeight() else Modifier.aspectRatio(ratio).fillMaxWidth()) {
|
||||
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
CameraPreview(controller = controller, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.tvcontroller.R
|
||||
import com.example.tvcontroller.services.BluetoothService
|
||||
import com.example.tvcontroller.services.CameraService
|
||||
import com.example.tvcontroller.services.ControllerService
|
||||
import com.example.tvcontroller.services.DeviceService
|
||||
import com.example.tvcontroller.services.webrtc.WebRtcService
|
||||
import com.example.tvcontroller.ui.views.MainViewModel.Companion.CAMERA_VIEW
|
||||
import com.example.tvcontroller.ui.views.MainViewModel.Companion.REMOTE_VIEW
|
||||
import com.example.tvcontroller.ui.views.MainViewModel.Companion.SETTINGS_VIEW
|
||||
|
||||
private data class NavigationItem(
|
||||
var onClick: () -> Unit = {},
|
||||
var icon: @Composable () -> Unit = {},
|
||||
var label: @Composable () -> Unit = {},
|
||||
var selected: Boolean = false
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MainView(
|
||||
deviceService: DeviceService,
|
||||
controllerService: ControllerService,
|
||||
bluetoothService: BluetoothService,
|
||||
webRtcService: WebRtcService,
|
||||
cameraService: CameraService
|
||||
) {
|
||||
val viewModel = viewModel<MainViewModel>()
|
||||
val navController = rememberNavController()
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentView = backStackEntry?.destination?.route
|
||||
val baselineCamera24 = painterResource(R.drawable.baseline_camera_24)
|
||||
val baselineRemote24 = painterResource(R.drawable.baseline_settings_remote_24)
|
||||
val baselineSettings24 = painterResource(R.drawable.baseline_settings_24)
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val navigationItems = listOf(
|
||||
NavigationItem(
|
||||
onClick = { navController.navigate(CAMERA_VIEW) },
|
||||
icon = { Icon(baselineCamera24, contentDescription = "Camera") },
|
||||
label = { Text("Camera") },
|
||||
selected = currentView == CAMERA_VIEW
|
||||
), NavigationItem(
|
||||
onClick = { navController.navigate(REMOTE_VIEW) },
|
||||
icon = { Icon(baselineRemote24, contentDescription = "Remote") },
|
||||
label = { Text("Remote") },
|
||||
selected = currentView == REMOTE_VIEW
|
||||
), NavigationItem(
|
||||
onClick = { navController.navigate(SETTINGS_VIEW) },
|
||||
icon = { Icon(baselineSettings24, contentDescription = "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentView == SETTINGS_VIEW
|
||||
)
|
||||
)
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
bottomBar = {
|
||||
if (!isLandscape) {
|
||||
NavigationBar {
|
||||
navigationItems.forEach { item ->
|
||||
NavigationBarItem(
|
||||
onClick = item.onClick,
|
||||
icon = item.icon,
|
||||
label = item.label,
|
||||
selected = item.selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { innerPadding ->
|
||||
Row(Modifier.padding(innerPadding)) {
|
||||
if (isLandscape) {
|
||||
NavigationRail(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(64.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
12.dp, Alignment.CenterVertically
|
||||
)
|
||||
) {
|
||||
navigationItems.forEach { item ->
|
||||
NavigationRailItem(
|
||||
onClick = item.onClick,
|
||||
icon = item.icon,
|
||||
label = item.label,
|
||||
selected = item.selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = viewModel.currentView.value,
|
||||
) {
|
||||
composable(route = CAMERA_VIEW) {
|
||||
CameraView(
|
||||
eglBaseContext = cameraService.eglBaseContext,
|
||||
videoTrack = webRtcService.videoTrack
|
||||
)
|
||||
}
|
||||
composable(route = REMOTE_VIEW) {
|
||||
RemoteView(
|
||||
controllerService = controllerService
|
||||
)
|
||||
}
|
||||
composable(route = SETTINGS_VIEW) {
|
||||
SettingsView(
|
||||
deviceService = deviceService, bluetoothService = bluetoothService
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
var currentView = mutableStateOf(CAMERA_VIEW)
|
||||
|
||||
companion object {
|
||||
const val CAMERA_VIEW = "camera_view"
|
||||
const val REMOTE_VIEW = "remote_view"
|
||||
const val SETTINGS_VIEW = "settings_view"
|
||||
}
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.tvcontroller.R
|
||||
import com.example.tvcontroller.services.ControllerService
|
||||
import com.example.tvcontroller.ui.components.RemoteButton
|
||||
import com.example.tvcontroller.ui.components.RemoteButtonPlaceholder
|
||||
|
||||
@Composable
|
||||
fun RemoteView(modifier: Modifier = Modifier, controllerService: ControllerService) {
|
||||
val viewModel = viewModel<RemoteViewModel>(
|
||||
factory = RemoteViewModel.provideFactory(
|
||||
controllerService
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.POWER) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_power_settings_new_24)
|
||||
)
|
||||
RemoteButtonPlaceholder()
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.INPUT_SOURCE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_login_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.ONE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "1"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.TWO) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "2"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.THREE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "3"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.FOUR) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "4"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.FIVE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "5"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.SIX) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "6"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.SEVEN) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "7"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.EIGHT) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "8"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.NINE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "9"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.VOLUME_UP) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon=painterResource(R.drawable.baseline_volume_up_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.ZERO) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "0"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_UP) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_keyboard_arrow_up_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.VOLUME_DOWN) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon=painterResource(R.drawable.baseline_volume_down_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.MUTE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_volume_off_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_DOWN) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_keyboard_arrow_down_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.MENU) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "MENU"
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.HOME) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_home_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.SETTINGS) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_settings_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_LIST) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_list_alt_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CURSOR_UP) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_arrow_upward_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.INFO) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_info_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CURSOR_LEFT) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_arrow_back_24)
|
||||
)
|
||||
RemoteButton(
|
||||
text = "OK",
|
||||
onClick = { viewModel.sendCommand(ControllerService.ENTER) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CURSOR_RIGHT) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_arrow_forward_24)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.RETURN) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_keyboard_return_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.CURSOR_DOWN) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
icon = painterResource(id = R.drawable.baseline_arrow_downward_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.EXIT) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
text = "EXIT"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val width = 46.dp
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.RED) },
|
||||
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Red).clip(
|
||||
RoundedCornerShape(12.dp)
|
||||
),
|
||||
width = width,
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.GREEN) },
|
||||
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Green).clip(
|
||||
RoundedCornerShape(12.dp)
|
||||
),
|
||||
width = width,
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.YELLOW) },
|
||||
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Yellow).clip(
|
||||
RoundedCornerShape(12.dp)
|
||||
),
|
||||
width = width,
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.BLUE) },
|
||||
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Blue).clip(
|
||||
RoundedCornerShape(12.dp)
|
||||
),
|
||||
width = width,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val width = 46.dp
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.REWIND) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
width = width,
|
||||
icon = painterResource(id = R.drawable.baseline_fast_rewind_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.PLAY) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
width = width,
|
||||
icon = painterResource(id = R.drawable.baseline_play_arrow_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.PAUSE) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
width = width,
|
||||
icon = painterResource(id = R.drawable.baseline_pause_24)
|
||||
)
|
||||
RemoteButton(
|
||||
onClick = { viewModel.sendCommand(ControllerService.FORWARD) },
|
||||
modifier = Modifier.aspectRatio(1.5f),
|
||||
width = width,
|
||||
icon = painterResource(id = R.drawable.baseline_fast_forward_24)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.tvcontroller.data.RemoteCommand
|
||||
import com.example.tvcontroller.services.BluetoothService
|
||||
import com.example.tvcontroller.services.ControllerService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
class RemoteViewModel(
|
||||
private val controllerService: ControllerService
|
||||
) : ViewModel() {
|
||||
private val commands = mutableMapOf<String, RemoteCommand>()
|
||||
|
||||
fun sendCommand(commandType: String) {
|
||||
val command = commands[commandType]?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
controllerService.sendCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun provideFactory(
|
||||
controllerService: ControllerService
|
||||
) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return RemoteViewModel(controllerService) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,55 +1,32 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.tvcontroller.R
|
||||
import com.example.tvcontroller.services.BluetoothService
|
||||
import com.example.tvcontroller.Settings
|
||||
import com.example.tvcontroller.SettingsViewModel
|
||||
import com.example.tvcontroller.services.DeviceService
|
||||
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.CONNECT_CONTROLLER_VIEW
|
||||
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.MAIN_SETTINGS_VIEW
|
||||
import com.example.tvcontroller.ui.AppViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsView(
|
||||
deviceService: DeviceService,
|
||||
bluetoothService: BluetoothService
|
||||
) {
|
||||
val viewModel = viewModel<SettingsViewModel>(
|
||||
factory = SettingsViewModel.provideFactory(
|
||||
deviceService, bluetoothService
|
||||
)
|
||||
)
|
||||
val navController = rememberNavController()
|
||||
fun SettingsView(deviceService: DeviceService, appViewModel: AppViewModel) {
|
||||
val viewModel =
|
||||
viewModel<SettingsViewModel>(factory = SettingsViewModel.Companion.provideFactory(deviceService))
|
||||
|
||||
@Composable
|
||||
fun MainSettingsView() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp, 16.dp)
|
||||
@ -68,23 +45,27 @@ fun SettingsView(
|
||||
Text(
|
||||
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
|
||||
viewModel.connectionState
|
||||
), style = MaterialTheme.typography.bodyMedium
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.serverAddress,
|
||||
onValueChange = viewModel::onServerAddressChanged,
|
||||
label = { Text(stringResource(id = R.string.server_address_label)) })
|
||||
label = { Text(stringResource(id = R.string.server_address_label)) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.deviceName,
|
||||
onValueChange = viewModel::onDeviceNameChanged,
|
||||
label = { Text(stringResource(id = R.string.device_name_label)) })
|
||||
label = { Text(stringResource(id = R.string.device_name_label)) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = viewModel.registrationCode,
|
||||
onValueChange = viewModel::onRegistrationCodeChanged,
|
||||
label = { Text(stringResource(id = R.string.registration_code_label)) })
|
||||
label = { Text(stringResource(id = R.string.registration_code_label)) }
|
||||
)
|
||||
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(id = R.string.connect_button_label)
|
||||
@ -95,103 +76,24 @@ fun SettingsView(
|
||||
text = stringResource(id = R.string.controller_settings_heading),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (appViewModel.isBluetoothEnabled()) {
|
||||
Text(
|
||||
text = "Controller status: " + getBluetoothConnectionStateString(viewModel.bluetoothConnectionState) + ".",
|
||||
text = "Controller settings: Bluetooth is enabled.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED) OutlinedButton(
|
||||
onClick = viewModel::disconnectBluetoothDevice, modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
} else {
|
||||
Text(
|
||||
stringResource(id = R.string.disconnect_button_label)
|
||||
text = "Bluetooth is disabled. Please enable it in settings.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
else OutlinedButton(
|
||||
onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) },
|
||||
enabled = viewModel.bluetoothConnectionState != BluetoothService.STATE_OFF,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.connect_button_label)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectControllerView() {
|
||||
val pairedDevices = viewModel.pairedDevices.collectAsState()
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.connect_controller_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
navController.navigate(MAIN_SETTINGS_VIEW.toString())
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.baseline_arrow_back_24),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.paired_devices_label),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
pairedDevices.value.forEach { device ->
|
||||
if (device == null) return
|
||||
ListItem(headlineContent = { Text(device.getName()) }, supportingContent = {
|
||||
if (device.getName() != device.getAddress()) Text(
|
||||
device.getAddress()
|
||||
)
|
||||
}, modifier = Modifier.clickable(onClick = {
|
||||
viewModel.connectBluetoothDevice(
|
||||
device
|
||||
)
|
||||
}), trailingContent = {
|
||||
if (device == viewModel.currentBluetoothDevice) if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED) Icon(
|
||||
painterResource(id = R.drawable.baseline_check_24),
|
||||
contentDescription = "state"
|
||||
)
|
||||
else CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = MAIN_SETTINGS_VIEW.toString()) {
|
||||
composable(route = MAIN_SETTINGS_VIEW.toString()) { MainSettingsView() }
|
||||
composable(route = CONNECT_CONTROLLER_VIEW.toString()) { ConnectControllerView() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getConnectionStateString(state: String): String {
|
||||
fun getConnectionStateString(state: Settings.ConnectionState): String {
|
||||
return when (state) {
|
||||
DeviceService.STATUS_UNREGISTERED -> stringResource(id = R.string.connection_state_unregistered)
|
||||
DeviceService.STATUS_REGISTERED -> stringResource(id = R.string.connection_state_registered)
|
||||
DeviceService.STATUS_CONNECTED -> stringResource(id = R.string.connection_state_connected)
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getBluetoothConnectionStateString(state: String): String {
|
||||
return when (state) {
|
||||
BluetoothService.STATE_OFF -> stringResource(id = R.string.controller_state_bluetooth_disabled)
|
||||
BluetoothService.STATE_DISCONNECTED -> stringResource(id = R.string.bluetooth_state_disconnected)
|
||||
BluetoothService.STATE_CONNECTING -> stringResource(id = R.string.bluetooth_state_connecting)
|
||||
BluetoothService.STATE_CONNECTED -> stringResource(id = R.string.bluetooth_state_connected)
|
||||
else -> "Unknown"
|
||||
Settings.ConnectionState.Unregistered -> stringResource(id = R.string.connection_state_unregistered)
|
||||
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_registered)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
package com.example.tvcontroller.ui.views
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.tvcontroller.data.BluetoothDevice
|
||||
import com.example.tvcontroller.services.BluetoothService
|
||||
import com.example.tvcontroller.services.DeviceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "SettingsViewModel"
|
||||
|
||||
class SettingsViewModel(
|
||||
private val deviceService: DeviceService,
|
||||
private val bluetoothService: BluetoothService
|
||||
) : ViewModel() {
|
||||
var serverAddress by mutableStateOf(deviceService.serverAddress)
|
||||
private set
|
||||
var deviceName by mutableStateOf(Build.MANUFACTURER + " " + Build.MODEL)
|
||||
private set
|
||||
var registrationCode by mutableStateOf("")
|
||||
private set
|
||||
var connectionState by mutableStateOf(deviceService.status)
|
||||
private set
|
||||
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
|
||||
private set
|
||||
var currentBluetoothDevice by mutableStateOf<BluetoothDevice?>(bluetoothService.currentDevice)
|
||||
private set
|
||||
|
||||
var pairedDevices = bluetoothService.pairedDevices
|
||||
|
||||
init {
|
||||
updateConnectionState()
|
||||
viewModelScope.launch {
|
||||
updateDeviceInfo()
|
||||
}
|
||||
bluetoothService.onBluetoothStateChanged {
|
||||
currentBluetoothDevice = bluetoothService.currentDevice
|
||||
bluetoothConnectionState = it
|
||||
}
|
||||
deviceService.onStatusChanged { connectionState = it }
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
viewModelScope.launch {
|
||||
deviceService.serverAddress = serverAddress
|
||||
deviceService.registerIntegration(deviceName, registrationCode)
|
||||
updateConnectionState()
|
||||
updateDeviceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateConnectionState() {
|
||||
Log.i(TAG, "Device token: ${deviceService.token}")
|
||||
connectionState = deviceService.status
|
||||
}
|
||||
|
||||
private suspend fun updateDeviceInfo() {
|
||||
if (connectionState == DeviceService.STATUS_UNREGISTERED) return
|
||||
val integration = deviceService.getIntegration()
|
||||
integration?.let {
|
||||
deviceName = it.name
|
||||
}
|
||||
}
|
||||
|
||||
fun connectBluetoothDevice(device: BluetoothDevice) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
bluetoothService.connectToDevice(device)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnectBluetoothDevice() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
bluetoothService.closeBluetoothConnection()
|
||||
}
|
||||
}
|
||||
|
||||
fun onServerAddressChanged(url: String) {
|
||||
serverAddress = url
|
||||
}
|
||||
|
||||
fun onDeviceNameChanged(name: String) {
|
||||
deviceName = name
|
||||
}
|
||||
|
||||
fun onRegistrationCodeChanged(code: String) {
|
||||
registrationCode = code
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAIN_SETTINGS_VIEW = "main_settings_view"
|
||||
const val CONNECT_CONTROLLER_VIEW = "connect_controller_view"
|
||||
|
||||
fun provideFactory(
|
||||
deviceService: DeviceService,
|
||||
bluetoothService: BluetoothService
|
||||
) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SettingsViewModel(deviceService, bluetoothService) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,12l-1.41,-1.41L13,16.17V4h-2v12.17l-5.58,-5.59L4,12l8,8 8,-8z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M4,12l1.41,1.41L11,7.83V20h2V7.83l5.58,5.59L20,12l-8,-8 -8,8z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M11,18L11,6l-8.5,6 8.5,6zM11.5,12l8.5,6L20,6l-8.5,6z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M7.41,15.41L12,10.83l4.59,4.58L18,14l-6,-6 -6,6z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19,7v4H5.83l3.58,-3.59L8,6l-6,6 6,6 1.41,-1.41L5.83,13H21V7z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19,5v14L5,19L5,5h14m1.1,-2L3.9,3c-0.5,0 -0.9,0.4 -0.9,0.9v16.2c0,0.4 0.4,0.9 0.9,0.9h16.2c0.4,0 0.9,-0.5 0.9,-0.9L21,3.9c0,-0.5 -0.5,-0.9 -0.9,-0.9zM11,7h6v2h-6L11,7zM11,11h6v2h-6v-2zM11,15h6v2h-6zM7,7h2v2L7,9zM7,11h2v2L7,13zM7,15h2v2L7,17z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M11,7L9.6,8.4l2.6,2.6H2v2h10.2l-2.6,2.6L11,17l5,-5L11,7zM20,19h-8v2h8c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2h-8v2h8V19z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M8,5v14l11,-7z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" 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>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" 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>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" 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,9L3,9v6h4l5,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.18L12,4z"/>
|
||||
|
||||
</vector>
|
||||
@ -1,5 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.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>
|
||||
@ -13,13 +13,4 @@
|
||||
<string name="server_address_label">Server address</string>
|
||||
<string name="connection_state_unregistered">unregistered</string>
|
||||
<string name="connection_state_registered">registered</string>
|
||||
<string name="controller_state_bluetooth_disabled">Bluetooth is turned off</string>
|
||||
<string name="paired_devices_label">Paired devices</string>
|
||||
<string name="scanned_devices_label">Scanned devices</string>
|
||||
<string name="connect_controller_title">Connect controller</string>
|
||||
<string name="bluetooth_state_connecting">connecting</string>
|
||||
<string name="bluetooth_state_connected">connected</string>
|
||||
<string name="bluetooth_state_disconnected">disconnected</string>
|
||||
<string name="disconnect_button_label">Disconnect</string>
|
||||
<string name="connection_state_connected">connected</string>
|
||||
</resources>
|
||||
@ -1,17 +1,17 @@
|
||||
[versions]
|
||||
agp = "8.9.1"
|
||||
cameraCore = "1.4.2"
|
||||
streamLog = "1.1.4"
|
||||
agp = "8.9.0"
|
||||
cameraCore = "1.4.1"
|
||||
kotlin = "2.0.0"
|
||||
coreKtx = "1.15.0"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
ktor = "3.1.0"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.10.1"
|
||||
composeBom = "2025.03.01"
|
||||
material3 = "1.4.0-alpha11"
|
||||
navigationCompose = "2.8.9"
|
||||
lifecycleRuntimeKtx = "2.6.1"
|
||||
activityCompose = "1.8.0"
|
||||
composeBom = "2024.04.01"
|
||||
navigationCompose = "2.8.4"
|
||||
streamWebrtcAndroid = "1.3.8"
|
||||
|
||||
[libraries]
|
||||
@ -39,9 +39,9 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||
material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
|
||||
stream-webrtc-android = { module = "io.getstream:stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
||||
stream-log = { group = "io.getstream", name = "stream-log-android", version.ref = "streamLog" }
|
||||
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user