Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a3be7b40 | |||
| c8a0c4160c | |||
| 22570e0e6d | |||
| 8e1e8dc4e6 | |||
| c0a86c5635 | |||
| c6f3a0b0f9 | |||
| 0b5c2a303c |
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-03-18T16:06:30.698647383Z">
|
<DropdownSelection timestamp="2025-04-05T11:04:12.656433726Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" />
|
||||||
|
|||||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
@ -28,6 +28,7 @@
|
|||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.TVController">
|
android:theme="@style/Theme.TVController">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@ -5,34 +5,17 @@ import android.util.Log
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
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.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
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.client.WebClient
|
import com.example.tvcontroller.client.WebClient
|
||||||
import com.example.tvcontroller.client.WebsocketClient
|
import com.example.tvcontroller.client.WebsocketClient
|
||||||
import com.example.tvcontroller.services.BluetoothService
|
import com.example.tvcontroller.services.BluetoothService
|
||||||
|
import com.example.tvcontroller.services.CameraService
|
||||||
import com.example.tvcontroller.services.ControllerService
|
import com.example.tvcontroller.services.ControllerService
|
||||||
import com.example.tvcontroller.services.DeviceService
|
import com.example.tvcontroller.services.DeviceService
|
||||||
import com.example.tvcontroller.services.webrtc.WebRtcService
|
import com.example.tvcontroller.services.webrtc.WebRtcService
|
||||||
import com.example.tvcontroller.ui.theme.TVControllerTheme
|
import com.example.tvcontroller.ui.theme.TVControllerTheme
|
||||||
import com.example.tvcontroller.ui.views.CameraView
|
import com.example.tvcontroller.ui.views.MainView
|
||||||
import com.example.tvcontroller.ui.views.RemoteView
|
|
||||||
import com.example.tvcontroller.ui.views.SettingsView
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -41,31 +24,47 @@ const val TAG = "MainActivity"
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val webClient by lazy { WebClient() }
|
private val webClient by lazy { WebClient() }
|
||||||
private val websocketClient by lazy { WebsocketClient(webClient.client) }
|
private val websocketClient by lazy { WebsocketClient(webClient.client) }
|
||||||
private val webRtcService by lazy { WebRtcService(applicationContext, websocketClient) }
|
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 bluetoothService by lazy { BluetoothService(applicationContext) }
|
||||||
private val deviceService by lazy { DeviceService(applicationContext, webClient) }
|
private val deviceService by lazy { DeviceService(applicationContext, webClient, websocketClient) }
|
||||||
private val controllerService by lazy { ControllerService(bluetoothService) }
|
private val controllerService by lazy { ControllerService(bluetoothService, webRtcService) }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
checkPermissions()
|
checkPermissions()
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
deviceService.initialize()
|
deviceService.onStatusChanged {
|
||||||
if (deviceService.serverAddress.isEmpty() || deviceService.token.isEmpty()) return@launch
|
when (it) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
DeviceService.STATUS_REGISTERED -> {
|
||||||
websocketClient.connect(deviceService.serverAddress, deviceService.token)
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
websocketClient.connect(deviceService.serverAddress, deviceService.token)
|
||||||
|
}
|
||||||
|
webRtcService.connect()
|
||||||
|
}
|
||||||
|
DeviceService.STATUS_UNREGISTERED -> {
|
||||||
|
Log.i(TAG, "Device unregistered")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
webRtcService.connect()
|
deviceService.initialize()
|
||||||
}
|
}
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
TVControllerTheme {
|
TVControllerTheme {
|
||||||
TvControllerApp(
|
MainView(
|
||||||
deviceService = deviceService,
|
deviceService = deviceService,
|
||||||
controllerService = controllerService,
|
controllerService = controllerService,
|
||||||
bluetoothService = bluetoothService,
|
bluetoothService = bluetoothService,
|
||||||
webRtcService = webRtcService
|
webRtcService = webRtcService,
|
||||||
|
cameraService = cameraService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,68 +76,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
Log.i(TAG, "Requesting Bluetooth permissions")
|
Log.i(TAG, "Requesting Bluetooth permissions")
|
||||||
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
|
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
|
||||||
}
|
}
|
||||||
}
|
if (!cameraService.hasRequiredPermissions()) {
|
||||||
}
|
Log.i(TAG, "Requesting Camera permissions")
|
||||||
|
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
|
||||||
@Composable
|
|
||||||
fun TvControllerApp(
|
|
||||||
navController: NavHostController = rememberNavController(),
|
|
||||||
deviceService: DeviceService,
|
|
||||||
controllerService: ControllerService,
|
|
||||||
bluetoothService: BluetoothService,
|
|
||||||
webRtcService: WebRtcService
|
|
||||||
) {
|
|
||||||
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 ->
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = Screen.Settings.name,
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
) {
|
|
||||||
composable(route = Screen.Camera.name) {
|
|
||||||
CameraView(
|
|
||||||
eglBaseContext = webRtcService.eglBaseContext,
|
|
||||||
videoTrack = webRtcService.videoTrack
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(route = Screen.Remote.name) {
|
|
||||||
RemoteView(
|
|
||||||
controllerService = controllerService
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(route = Screen.Settings.name) {
|
|
||||||
SettingsView(
|
|
||||||
deviceService = deviceService, bluetoothService = bluetoothService
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
package com.example.tvcontroller
|
|
||||||
|
|
||||||
enum class Screen {
|
|
||||||
Camera,
|
|
||||||
Remote,
|
|
||||||
Settings
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package com.example.tvcontroller
|
|
||||||
|
|
||||||
object Settings {
|
|
||||||
enum class ConnectionState {
|
|
||||||
Unregistered,
|
|
||||||
Registered,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,11 +15,14 @@ private const val TAG = "WebsocketClient"
|
|||||||
class WebsocketClient(private val client: HttpClient) {
|
class WebsocketClient(private val client: HttpClient) {
|
||||||
private var websocket: DefaultWebSocketSession? = null
|
private var websocket: DefaultWebSocketSession? = null
|
||||||
private val dataHandlers = mutableListOf<(String) -> Unit>()
|
private val dataHandlers = mutableListOf<(String) -> Unit>()
|
||||||
|
private val connectedHandlers = mutableListOf<() -> Unit>()
|
||||||
|
private val disconnectedHandlers = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
suspend fun connect(serverAddress: String, token: String) {
|
suspend fun connect(serverAddress: String, token: String) {
|
||||||
Log.i(TAG, "Connecting to websocket at $serverAddress")
|
Log.i(TAG, "Connecting to websocket at $serverAddress")
|
||||||
val (host, port) = serverAddress.split(":")
|
val (host, port) = serverAddress.split(":")
|
||||||
val portInt = if (port.isEmpty()) 80 else port.toInt()
|
val portInt = if (port.isEmpty()) 80 else port.toInt()
|
||||||
|
connectedHandlers.forEach { it() }
|
||||||
client.webSocket(
|
client.webSocket(
|
||||||
method = HttpMethod.Get,
|
method = HttpMethod.Get,
|
||||||
host = host,
|
host = host,
|
||||||
@ -40,6 +43,16 @@ class WebsocketClient(private val client: HttpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) {
|
fun onData(handler: (String) -> Unit) {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
package com.example.tvcontroller.data
|
package com.example.tvcontroller.data
|
||||||
|
|
||||||
class RemoteCommand {
|
class RemoteCommand {
|
||||||
var functionName: String? = null
|
|
||||||
var protocol: String? = null
|
var protocol: String? = null
|
||||||
var device: String? = null
|
var device: String? = null
|
||||||
var subdevice: String? = null
|
var command: String? = null
|
||||||
var function: String? = null
|
|
||||||
}
|
}
|
||||||
@ -2,16 +2,54 @@ package com.example.tvcontroller.services
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
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) {
|
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 {
|
fun hasRequiredPermissions(): Boolean {
|
||||||
return CAMERAX_PERMISSIONS.all {
|
return CAMERA_PERMISSIONS.all {
|
||||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val CAMERAX_PERMISSIONS = arrayOf(
|
val CAMERA_PERMISSIONS = arrayOf(
|
||||||
android.Manifest.permission.CAMERA,
|
android.Manifest.permission.CAMERA,
|
||||||
android.Manifest.permission.RECORD_AUDIO
|
android.Manifest.permission.RECORD_AUDIO
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,32 +1,33 @@
|
|||||||
package com.example.tvcontroller.services
|
package com.example.tvcontroller.services
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.example.tvcontroller.data.RemoteCommand
|
import com.example.tvcontroller.data.RemoteCommand
|
||||||
|
import com.example.tvcontroller.services.webrtc.WebRtcService
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
|
||||||
class ControllerService(
|
class ControllerService(
|
||||||
private val bluetoothService: BluetoothService
|
private val bluetoothService: BluetoothService,
|
||||||
|
private val webRtcService: WebRtcService
|
||||||
) {
|
) {
|
||||||
private val samsungCommands = mutableMapOf<String, RemoteCommand>()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadCommands()
|
loadCommands()
|
||||||
|
webRtcService.onDataChannelData(this::handleWebRtcData)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendCommand(command: String) {
|
fun sendCommand(command: RemoteCommand) {
|
||||||
if (samsungCommands[command] == null) return
|
val jsonString = remoteCommandToJsonString(command)
|
||||||
Log.i("ControllerService", "Sending command: $command")
|
Log.i(TAG, "Sending command: $jsonString")
|
||||||
val jsonString = remoteCommandToJsonString(samsungCommands[command]!!)
|
|
||||||
sendData(jsonString)
|
sendData(jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remoteCommandToJsonString(command: RemoteCommand): String {
|
fun remoteCommandToJsonString(command: RemoteCommand): String {
|
||||||
var commandObject = JSONObject()
|
var commandObject = JSONObject()
|
||||||
commandObject.put("protocol", command.protocol)
|
commandObject.put("protocol", command.protocol)
|
||||||
commandObject.put("address", command.device)
|
commandObject.put("device", command.device)
|
||||||
commandObject.put("command", command.function)
|
commandObject.put("command", command.command)
|
||||||
return commandObject.toString()
|
return commandObject.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +38,27 @@ class ControllerService(
|
|||||||
fun loadCommands() {
|
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 {
|
companion object {
|
||||||
|
private const val TAG = "ControllerService"
|
||||||
|
|
||||||
|
const val MESSAGE_TYPE_COMMAND = "command"
|
||||||
|
|
||||||
const val POWER = "POWER"
|
const val POWER = "POWER"
|
||||||
const val CURSOR_UP = "CURSOR UP"
|
const val CURSOR_UP = "CURSOR UP"
|
||||||
const val CURSOR_DOWN = "CURSOR DOWN"
|
const val CURSOR_DOWN = "CURSOR DOWN"
|
||||||
|
|||||||
@ -3,28 +3,62 @@ package com.example.tvcontroller.services
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.example.tvcontroller.client.WebClient
|
import com.example.tvcontroller.client.WebClient
|
||||||
|
import com.example.tvcontroller.client.WebsocketClient
|
||||||
import com.example.tvcontroller.data.Integration
|
import com.example.tvcontroller.data.Integration
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.http.HttpMethod
|
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
|
import org.json.JSONObject
|
||||||
|
|
||||||
private const val SHARED_PREFERENCES_NAME = "devices";
|
|
||||||
private const val TAG = "DeviceService"
|
|
||||||
|
|
||||||
class DeviceService(private val context: Context, private val client: WebClient) {
|
class DeviceService(
|
||||||
|
private val context: Context,
|
||||||
|
private val client: WebClient,
|
||||||
|
private val websocketClient: WebsocketClient
|
||||||
|
) {
|
||||||
var serverAddress: String = ""
|
var serverAddress: String = ""
|
||||||
var token: String = ""
|
var token: String = ""
|
||||||
private set(value) {
|
var status: String = STATUS_UNREGISTERED
|
||||||
field = value
|
private set(status) {
|
||||||
updateDefaultCookies()
|
field = status
|
||||||
|
statusChangedListeners.forEach { it(status) }
|
||||||
}
|
}
|
||||||
private var deviceId: String = ""
|
private var deviceId: String = ""
|
||||||
|
private val statusChangedListeners = mutableListOf<(String) -> Unit>()
|
||||||
|
|
||||||
suspend fun initialize() {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
if (token.isEmpty()) return
|
if (token.isEmpty()) return
|
||||||
getIntegration()
|
updateDefaultCookies()
|
||||||
|
val integration = getIntegration()
|
||||||
|
Log.i(TAG, "Integration: $integration")
|
||||||
|
status = if (integration != null) {
|
||||||
|
STATUS_REGISTERED
|
||||||
|
} else {
|
||||||
|
STATUS_UNREGISTERED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDefaultCookies() {
|
private fun updateDefaultCookies() {
|
||||||
@ -34,14 +68,15 @@ class DeviceService(private val context: Context, private val client: WebClient)
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun registerIntegration(name: String, code: String) {
|
suspend fun registerIntegration(name: String, code: String) {
|
||||||
Log.i(TAG, "Creating integration for $name with code $code at $serverAddress")
|
Log.i(TAG, "Registering integration for $name with code $code at $serverAddress")
|
||||||
|
savePreferences()
|
||||||
val requestJson = JSONObject().apply {
|
val requestJson = JSONObject().apply {
|
||||||
put("name", name)
|
put("name", name)
|
||||||
put("code", code)
|
put("code", code)
|
||||||
}
|
}
|
||||||
val response =
|
val response =
|
||||||
client.sendJsonRequest(
|
client.sendJsonRequest(
|
||||||
"http://$serverAddress/api/integrations",
|
"http://$serverAddress/api/integrations/register",
|
||||||
HttpMethod.Post,
|
HttpMethod.Post,
|
||||||
requestJson
|
requestJson
|
||||||
) ?: return
|
) ?: return
|
||||||
@ -49,12 +84,23 @@ class DeviceService(private val context: Context, private val client: WebClient)
|
|||||||
val responseJson = JSONObject(response.body<String>())
|
val responseJson = JSONObject(response.body<String>())
|
||||||
if (response.status.value != 200) {
|
if (response.status.value != 200) {
|
||||||
val error = responseJson.getString("error")
|
val error = responseJson.getString("error")
|
||||||
Log.e(TAG, "Error getting integration: ${response.status.value} $error")
|
Log.e(TAG, "Error registering integration: ${response.status.value} $error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token = responseJson.getString("token")
|
token = responseJson.getString("token")
|
||||||
deviceId = responseJson.getString("id")
|
deviceId = responseJson.getString("id")
|
||||||
savePreferences()
|
status = STATUS_REGISTERED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStatusChanged(listener: (String) -> Unit) {
|
||||||
|
statusChangedListeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
fun connectWebsocket() {
|
||||||
|
GlobalScope.launch() {
|
||||||
|
websocketClient.connect(serverAddress, token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getIntegration(): Integration? {
|
suspend fun getIntegration(): Integration? {
|
||||||
@ -77,24 +123,33 @@ class DeviceService(private val context: Context, private val client: WebClient)
|
|||||||
|
|
||||||
private fun loadPreferences() {
|
private fun loadPreferences() {
|
||||||
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
||||||
serverAddress = sharedPreferences.getString("server_address", "")!!
|
serverAddress = sharedPreferences.getString(SERVER_ADDRESS_KEY, "")!!
|
||||||
token = sharedPreferences.getString("token", "")!!
|
token = sharedPreferences.getString(TOKEN_KEY, "")!!
|
||||||
deviceId = sharedPreferences.getString("device_id", "")!!
|
deviceId = sharedPreferences.getString(DEVICE_ID_KEY, "")!!
|
||||||
Log.i(TAG, "Loaded preferences: $serverAddress $token")
|
Log.i(TAG, "Loaded preferences: $serverAddress $token")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun savePreferences() {
|
private fun savePreferences() {
|
||||||
|
Log.i(TAG, "Saving preferences")
|
||||||
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
|
||||||
val editor = sharedPreferences.edit()
|
val editor = sharedPreferences.edit()
|
||||||
editor.apply {
|
editor.apply {
|
||||||
putString("server_address", serverAddress)
|
putString(SERVER_ADDRESS_KEY, serverAddress)
|
||||||
putString("token", token)
|
putString(TOKEN_KEY, token)
|
||||||
putString("device_id", deviceId)
|
putString(DEVICE_ID_KEY, deviceId)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TYPE_SIGNALING = "signaling"
|
const val STATUS_UNREGISTERED = "unregistered"
|
||||||
|
const val STATUS_REGISTERED = "registered"
|
||||||
|
const val STATUS_CONNECTED = "connected"
|
||||||
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
package com.example.tvcontroller.services.webrtc
|
package com.example.tvcontroller.services.webrtc
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.camera2.CameraCharacteristics
|
|
||||||
import android.hardware.camera2.CameraManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.example.tvcontroller.services.CameraService
|
||||||
import org.webrtc.AudioTrack
|
import org.webrtc.AudioTrack
|
||||||
import org.webrtc.Camera2Capturer
|
|
||||||
import org.webrtc.DataChannel
|
import org.webrtc.DataChannel
|
||||||
import org.webrtc.DefaultVideoDecoderFactory
|
|
||||||
import org.webrtc.EglBase
|
import org.webrtc.EglBase
|
||||||
import org.webrtc.HardwareVideoEncoderFactory
|
|
||||||
import org.webrtc.IceCandidate
|
import org.webrtc.IceCandidate
|
||||||
import org.webrtc.MediaConstraints
|
import org.webrtc.MediaConstraints
|
||||||
import org.webrtc.MediaStream
|
import org.webrtc.MediaStream
|
||||||
@ -19,31 +15,24 @@ import org.webrtc.PeerConnectionFactory
|
|||||||
import org.webrtc.PeerConnectionFactory.InitializationOptions
|
import org.webrtc.PeerConnectionFactory.InitializationOptions
|
||||||
import org.webrtc.SdpObserver
|
import org.webrtc.SdpObserver
|
||||||
import org.webrtc.SessionDescription
|
import org.webrtc.SessionDescription
|
||||||
import org.webrtc.SimulcastVideoEncoderFactory
|
|
||||||
import org.webrtc.SoftwareVideoEncoderFactory
|
|
||||||
import org.webrtc.SurfaceTextureHelper
|
import org.webrtc.SurfaceTextureHelper
|
||||||
import org.webrtc.VideoSource
|
import org.webrtc.VideoSource
|
||||||
import org.webrtc.VideoTrack
|
import org.webrtc.VideoTrack
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
private const val TAG = "RtcPeerConnection"
|
private const val TAG = "RtcPeerConnection"
|
||||||
|
|
||||||
class RtcPeerConnection(private val context: Context) {
|
class RtcPeerConnection(private val context: Context, private val cameraService: CameraService) {
|
||||||
private val peerConnectionFactory by lazy { initializeFactory() }
|
private val peerConnectionFactory by lazy { initializeFactory() }
|
||||||
private var iceServers = ArrayList<PeerConnection.IceServer>()
|
private var iceServers = ArrayList<PeerConnection.IceServer>()
|
||||||
var eglBaseContext: EglBase.Context = EglBase.create().eglBaseContext
|
var eglBaseContext: EglBase.Context = cameraService.eglBaseContext
|
||||||
private val cameraManager by lazy { context.getSystemService(Context.CAMERA_SERVICE) as CameraManager }
|
private val videoCapturer by lazy { cameraService.createCameraCapturer() }
|
||||||
private val videoCapturer by lazy { createCameraCapturer() }
|
|
||||||
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
|
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
|
||||||
private val videoSource by lazy { createVideoSource() }
|
private val videoSource by lazy { createVideoSource() }
|
||||||
private val videoDecoderFactory by lazy { DefaultVideoDecoderFactory(eglBaseContext) }
|
|
||||||
private val videoEncoderFactory by lazy {
|
|
||||||
SimulcastVideoEncoderFactory(
|
|
||||||
HardwareVideoEncoderFactory(eglBaseContext, true, true),
|
|
||||||
SoftwareVideoEncoderFactory()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private var peerConnection: PeerConnection? = null
|
private var peerConnection: PeerConnection? = null
|
||||||
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
|
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
|
||||||
|
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
|
||||||
|
|
||||||
|
|
||||||
fun initialize() {
|
fun initialize() {
|
||||||
@ -55,10 +44,20 @@ class RtcPeerConnection(private val context: Context) {
|
|||||||
override fun onIceCandidate(iceCandidate: IceCandidate?) {
|
override fun onIceCandidate(iceCandidate: IceCandidate?) {
|
||||||
iceCandidateHandlers.forEach { it(iceCandidate!!) }
|
iceCandidateHandlers.forEach { it(iceCandidate!!) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {}
|
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {}
|
||||||
override fun onAddStream(p0: MediaStream?) {}
|
override fun onAddStream(p0: MediaStream?) {}
|
||||||
override fun onRemoveStream(p0: MediaStream?) {}
|
override fun onRemoveStream(p0: MediaStream?) {}
|
||||||
override fun onDataChannel(p0: DataChannel?) {}
|
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() {}
|
override fun onRenegotiationNeeded() {}
|
||||||
}
|
}
|
||||||
var rtcConfig = PeerConnection.RTCConfiguration(iceServers)
|
var rtcConfig = PeerConnection.RTCConfiguration(iceServers)
|
||||||
@ -138,38 +137,23 @@ class RtcPeerConnection(private val context: Context) {
|
|||||||
iceCandidateHandlers.add(handler)
|
iceCandidateHandlers.add(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
|
||||||
|
dataChannelHandlers.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
private fun initializeFactory(): PeerConnectionFactory {
|
private fun initializeFactory(): PeerConnectionFactory {
|
||||||
val initOptions = InitializationOptions.builder(context).createInitializationOptions()
|
val initOptions = InitializationOptions.builder(context).createInitializationOptions()
|
||||||
PeerConnectionFactory.initialize(initOptions)
|
PeerConnectionFactory.initialize(initOptions)
|
||||||
|
|
||||||
val options = PeerConnectionFactory.Options()
|
val options = PeerConnectionFactory.Options()
|
||||||
val peerConnectionFactory =
|
val peerConnectionFactory =
|
||||||
PeerConnectionFactory.builder().setVideoDecoderFactory(videoDecoderFactory)
|
PeerConnectionFactory.builder()
|
||||||
.setVideoEncoderFactory(videoEncoderFactory).setOptions(options)
|
.setVideoDecoderFactory(cameraService.videoDecoderFactory)
|
||||||
|
.setVideoEncoderFactory(cameraService.videoEncoderFactory).setOptions(options)
|
||||||
.createPeerConnectionFactory()
|
.createPeerConnectionFactory()
|
||||||
return peerConnectionFactory
|
return peerConnectionFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
|
private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
|
||||||
val surfaceTextureHelper =
|
val surfaceTextureHelper =
|
||||||
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)
|
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)
|
||||||
|
|||||||
@ -3,22 +3,23 @@ package com.example.tvcontroller.services.webrtc
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.example.tvcontroller.client.WebsocketClient
|
import com.example.tvcontroller.client.WebsocketClient
|
||||||
import com.example.tvcontroller.services.DeviceService
|
import com.example.tvcontroller.services.CameraService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.webrtc.EglBase
|
|
||||||
import org.webrtc.IceCandidate
|
import org.webrtc.IceCandidate
|
||||||
import org.webrtc.MediaConstraints
|
import org.webrtc.MediaConstraints
|
||||||
import org.webrtc.SessionDescription
|
import org.webrtc.SessionDescription
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
private const val TAG = "WebRtcService"
|
class WebRtcService(
|
||||||
|
private val context: Context,
|
||||||
class WebRtcService(private val context: Context, private val websocketClient: WebsocketClient) {
|
private val websocketClient: WebsocketClient,
|
||||||
private val rtcPeerConnection by lazy { createRtcPeerConnection() }
|
private val cameraService: CameraService
|
||||||
|
) {
|
||||||
|
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
|
||||||
|
private var rtcPeerConnection: RtcPeerConnection = createRtcPeerConnection()
|
||||||
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
|
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
|
||||||
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
|
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
|
||||||
val eglBaseContext: EglBase.Context
|
|
||||||
get() = rtcPeerConnection.eglBaseContext
|
|
||||||
private var peerId: String = ""
|
private var peerId: String = ""
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
@ -26,18 +27,24 @@ class WebRtcService(private val context: Context, private val websocketClient: W
|
|||||||
websocketClient.onData(this::handleData)
|
websocketClient.onData(this::handleData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
|
||||||
|
dataChannelHandlers.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createRtcPeerConnection(): RtcPeerConnection {
|
private fun createRtcPeerConnection(): RtcPeerConnection {
|
||||||
val iceServers = arrayOf("stun:stun.l.google.com:19302")
|
val iceServers = arrayOf("stun:stun.l.google.com:19302")
|
||||||
val webRtcService = this
|
val webRtcService = this
|
||||||
val rtcPeerConnection = RtcPeerConnection(context).apply {
|
val rtcPeerConnection = RtcPeerConnection(context, cameraService).apply {
|
||||||
setIceServers(iceServers)
|
setIceServers(iceServers)
|
||||||
onIceCandidate(webRtcService::sendIceCandidate)
|
onIceCandidate(webRtcService::sendIceCandidate)
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
dataChannelHandlers.forEach { rtcPeerConnection.onDataChannelData(it) }
|
||||||
return rtcPeerConnection
|
return rtcPeerConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOffer(sdp: String) {
|
private fun handleOffer(sdp: String) {
|
||||||
|
rtcPeerConnection = createRtcPeerConnection()
|
||||||
var mediaConstraints = MediaConstraints()
|
var mediaConstraints = MediaConstraints()
|
||||||
val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp)
|
val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp)
|
||||||
rtcPeerConnection.apply {
|
rtcPeerConnection.apply {
|
||||||
@ -89,7 +96,7 @@ class WebRtcService(private val context: Context, private val websocketClient: W
|
|||||||
|
|
||||||
private fun sendIceCandidate(iceCandidate: IceCandidate) {
|
private fun sendIceCandidate(iceCandidate: IceCandidate) {
|
||||||
val messageJson = JSONObject()
|
val messageJson = JSONObject()
|
||||||
messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
|
messageJson.put("type", TYPE_SIGNALING)
|
||||||
messageJson.put("target", peerId)
|
messageJson.put("target", peerId)
|
||||||
messageJson.put("message", JSONObject().apply {
|
messageJson.put("message", JSONObject().apply {
|
||||||
put("candidate", JSONObject().apply {
|
put("candidate", JSONObject().apply {
|
||||||
@ -108,7 +115,7 @@ class WebRtcService(private val context: Context, private val websocketClient: W
|
|||||||
|
|
||||||
private fun sendAnswer(targetId: String, sdp: String) {
|
private fun sendAnswer(targetId: String, sdp: String) {
|
||||||
val messageJson = JSONObject()
|
val messageJson = JSONObject()
|
||||||
messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
|
messageJson.put("type", TYPE_SIGNALING)
|
||||||
messageJson.put("target", targetId)
|
messageJson.put("target", targetId)
|
||||||
messageJson.put("message", JSONObject().apply {
|
messageJson.put("message", JSONObject().apply {
|
||||||
put("sdp", sdp)
|
put("sdp", sdp)
|
||||||
@ -120,4 +127,10 @@ class WebRtcService(private val context: Context, private val websocketClient: W
|
|||||||
websocketClient.sendJson(messageJson)
|
websocketClient.sendJson(messageJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TYPE_SIGNALING = "signaling"
|
||||||
|
|
||||||
|
private const val TAG = "WebRtcService"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,10 +1,17 @@
|
|||||||
package com.example.tvcontroller.ui.views
|
package com.example.tvcontroller.ui.views
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.tvcontroller.ui.components.CameraPreview
|
import com.example.tvcontroller.ui.components.CameraPreview
|
||||||
import org.webrtc.EglBase
|
import org.webrtc.EglBase
|
||||||
@ -12,11 +19,16 @@ import org.webrtc.VideoTrack
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CameraView(eglBaseContext: EglBase.Context, videoTrack: VideoTrack) {
|
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
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize(),
|
||||||
.padding(all = 16.dp),
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
|
Box(modifier = if (isLandscape) Modifier.aspectRatio(ratio).fillMaxHeight() else Modifier.aspectRatio(ratio).fillMaxWidth()) {
|
||||||
|
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
app/src/main/java/com/example/tvcontroller/ui/views/MainView.kt
Normal file
152
app/src/main/java/com/example/tvcontroller/ui/views/MainView.kt
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package com.example.tvcontroller.ui.views
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.tvcontroller.data.RemoteCommand
|
||||||
import com.example.tvcontroller.services.BluetoothService
|
import com.example.tvcontroller.services.BluetoothService
|
||||||
import com.example.tvcontroller.services.ControllerService
|
import com.example.tvcontroller.services.ControllerService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -12,8 +13,10 @@ import org.json.JSONObject
|
|||||||
class RemoteViewModel(
|
class RemoteViewModel(
|
||||||
private val controllerService: ControllerService
|
private val controllerService: ControllerService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val commands = mutableMapOf<String, RemoteCommand>()
|
||||||
|
|
||||||
fun sendCommand(command: String) {
|
fun sendCommand(commandType: String) {
|
||||||
|
val command = commands[commandType]?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
controllerService.sendCommand(command)
|
controllerService.sendCommand(command)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@ -31,11 +30,10 @@ import androidx.navigation.compose.NavHost
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.example.tvcontroller.R
|
import com.example.tvcontroller.R
|
||||||
import com.example.tvcontroller.Settings
|
|
||||||
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.services.BluetoothService
|
import com.example.tvcontroller.services.BluetoothService
|
||||||
import com.example.tvcontroller.services.DeviceService
|
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
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -178,10 +176,12 @@ fun SettingsView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun getConnectionStateString(state: Settings.ConnectionState): String {
|
fun getConnectionStateString(state: String): String {
|
||||||
return when (state) {
|
return when (state) {
|
||||||
Settings.ConnectionState.Unregistered -> stringResource(id = R.string.connection_state_unregistered)
|
DeviceService.STATUS_UNREGISTERED -> stringResource(id = R.string.connection_state_unregistered)
|
||||||
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_registered)
|
DeviceService.STATUS_REGISTERED -> stringResource(id = R.string.connection_state_registered)
|
||||||
|
DeviceService.STATUS_CONNECTED -> stringResource(id = R.string.connection_state_connected)
|
||||||
|
else -> "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.tvcontroller.Settings
|
|
||||||
import com.example.tvcontroller.data.BluetoothDevice
|
import com.example.tvcontroller.data.BluetoothDevice
|
||||||
import com.example.tvcontroller.services.BluetoothService
|
import com.example.tvcontroller.services.BluetoothService
|
||||||
import com.example.tvcontroller.services.DeviceService
|
import com.example.tvcontroller.services.DeviceService
|
||||||
@ -27,7 +26,7 @@ class SettingsViewModel(
|
|||||||
private set
|
private set
|
||||||
var registrationCode by mutableStateOf("")
|
var registrationCode by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
|
var connectionState by mutableStateOf(deviceService.status)
|
||||||
private set
|
private set
|
||||||
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
|
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
|
||||||
private set
|
private set
|
||||||
@ -45,10 +44,10 @@ class SettingsViewModel(
|
|||||||
currentBluetoothDevice = bluetoothService.currentDevice
|
currentBluetoothDevice = bluetoothService.currentDevice
|
||||||
bluetoothConnectionState = it
|
bluetoothConnectionState = it
|
||||||
}
|
}
|
||||||
|
deviceService.onStatusChanged { connectionState = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
//Log.i("SettingsScreen", "Save settings: $serverUrl, $deviceName, $registrationCode")
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
deviceService.serverAddress = serverAddress
|
deviceService.serverAddress = serverAddress
|
||||||
deviceService.registerIntegration(deviceName, registrationCode)
|
deviceService.registerIntegration(deviceName, registrationCode)
|
||||||
@ -59,21 +58,15 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private fun updateConnectionState() {
|
private fun updateConnectionState() {
|
||||||
Log.i(TAG, "Device token: ${deviceService.token}")
|
Log.i(TAG, "Device token: ${deviceService.token}")
|
||||||
connectionState = if (deviceService.token.isEmpty()) {
|
connectionState = deviceService.status
|
||||||
Settings.ConnectionState.Unregistered
|
|
||||||
} else {
|
|
||||||
Settings.ConnectionState.Registered
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateDeviceInfo() {
|
private suspend fun updateDeviceInfo() {
|
||||||
if (connectionState == Settings.ConnectionState.Unregistered) return
|
if (connectionState == DeviceService.STATUS_UNREGISTERED) return
|
||||||
val integration = deviceService.getIntegration()
|
val integration = deviceService.getIntegration()
|
||||||
if (integration == null) {
|
integration?.let {
|
||||||
connectionState = Settings.ConnectionState.Unregistered
|
deviceName = it.name
|
||||||
return
|
|
||||||
}
|
}
|
||||||
deviceName = integration.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectBluetoothDevice(device: BluetoothDevice) {
|
fun connectBluetoothDevice(device: BluetoothDevice) {
|
||||||
|
|||||||
@ -21,4 +21,5 @@
|
|||||||
<string name="bluetooth_state_connected">connected</string>
|
<string name="bluetooth_state_connected">connected</string>
|
||||||
<string name="bluetooth_state_disconnected">disconnected</string>
|
<string name="bluetooth_state_disconnected">disconnected</string>
|
||||||
<string name="disconnect_button_label">Disconnect</string>
|
<string name="disconnect_button_label">Disconnect</string>
|
||||||
|
<string name="connection_state_connected">connected</string>
|
||||||
</resources>
|
</resources>
|
||||||
Loading…
Reference in New Issue
Block a user