Compare commits

...

7 Commits
webrtc ... main

25 changed files with 1124 additions and 309 deletions

View File

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<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">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" />

21
LICENSE.md Normal file
View 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.

View File

@ -61,6 +61,7 @@ dependencies {
implementation(libs.androidx.camera.mlkit.vision)
implementation(libs.androidx.camera.extensions)
implementation(libs.material3)
implementation(libs.stream.webrtc.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -12,6 +12,7 @@
<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" />
@ -27,6 +28,7 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.TVController">
<intent-filter>

View File

@ -5,62 +5,66 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.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.res.painterResource
import androidx.core.app.ActivityCompat
import androidx.lifecycle.lifecycleScope
import com.example.tvcontroller.client.WebClient
import com.example.tvcontroller.client.WebsocketClient
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.ui.views.CameraView
import com.example.tvcontroller.ui.views.RemoteView
import com.example.tvcontroller.ui.views.SettingsView
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
const val TAG = "MainActivity"
class MainActivity : ComponentActivity() {
private lateinit var bluetoothService: BluetoothService
private lateinit var deviceService: DeviceService
private lateinit var cameraService: CameraService
private lateinit var controllerService: ControllerService
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) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bluetoothService = BluetoothService(this.applicationContext)
deviceService = DeviceService(this.applicationContext)
cameraService = CameraService(this.applicationContext)
controllerService = ControllerService(this.applicationContext, bluetoothService)
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 {
TvControllerApp(
MainView(
deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService
bluetoothService = bluetoothService,
webRtcService = webRtcService,
cameraService = cameraService
)
}
}
@ -68,84 +72,13 @@ class MainActivity : ComponentActivity() {
private fun checkPermissions() {
Log.i(TAG, "Checking permissions")
if (!cameraService.hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0)
}
if (!bluetoothService.hasRequiredPermissions()) {
Log.i(TAG, "Requesting Bluetooth permissions")
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
}
}
}
@Composable
fun TvControllerApp(
navController: NavHostController = rememberNavController(),
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService
) {
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.Remote.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraView()
}
composable(route = Screen.Remote.name) {
RemoteView(
controllerService = controllerService
)
}
composable(route = Screen.Settings.name) {
SettingsView(
deviceService = deviceService,
bluetoothService = bluetoothService
)
}
if (!cameraService.hasRequiredPermissions()) {
Log.i(TAG, "Requesting Camera permissions")
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
}
}
}

View File

@ -1,7 +0,0 @@
package com.example.tvcontroller
enum class Screen {
Camera,
Remote,
Settings
}

View File

@ -1,8 +0,0 @@
package com.example.tvcontroller
object Settings {
enum class ConnectionState {
Unregistered,
Registered,
}
}

View File

@ -0,0 +1,54 @@
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())
}
}

View File

@ -0,0 +1,67 @@
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)
}
}

View File

@ -1,9 +1,7 @@
package com.example.tvcontroller.data
class RemoteCommand {
var functionName: String? = null
var protocol: String? = null
var device: String? = null
var subdevice: String? = null
var function: String? = null
var command: String? = null
}

View File

@ -2,16 +2,54 @@ 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 CAMERAX_PERMISSIONS.all {
return CAMERA_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
companion object {
val CAMERAX_PERMISSIONS = arrayOf(
val CAMERA_PERMISSIONS = arrayOf(
android.Manifest.permission.CAMERA,
android.Manifest.permission.RECORD_AUDIO
)

View File

@ -1,33 +1,33 @@
package com.example.tvcontroller.services
import android.content.Context
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 context: Context,
private val bluetoothService: BluetoothService
private val bluetoothService: BluetoothService,
private val webRtcService: WebRtcService
) {
private val samsungCommands = mutableMapOf<String, RemoteCommand>()
init {
loadCommands()
webRtcService.onDataChannelData(this::handleWebRtcData)
}
fun sendCommand(command: String) {
if (samsungCommands[command] == null) return
Log.i("ControllerService", "Sending command: $command")
val jsonString = remoteCommandToJsonString(samsungCommands[command]!!)
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("address", command.device)
commandObject.put("command", command.function)
commandObject.put("device", command.device)
commandObject.put("command", command.command)
return commandObject.toString()
}
@ -38,7 +38,27 @@ class ControllerService(
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"

View File

@ -3,94 +3,112 @@ 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.plugins.cookies.HttpCookies
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.HttpRequestBuilder
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.Cookie
import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.runBlocking
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";
private const val TAG = "DeviceService"
class DeviceService(private val context: Context) {
private var client = HttpClient(CIO) {
install(WebSockets)
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) }
}
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()
}
}
}
loadPreferences()
if (token.isEmpty()) return
getIntegration()?.let {
connect()
updateDefaultCookies()
val integration = getIntegration()
Log.i(TAG, "Integration: $integration")
status = if (integration != null) {
STATUS_REGISTERED
} else {
STATUS_UNREGISTERED
}
}
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, "Creating integration for $name with code $code at $serverAddress")
val requestJson = JSONObject()
requestJson.put("name", name)
requestJson.put("code", code)
token = ""
deviceId = ""
try {
val response: HttpResponse =
client.request("http://$serverAddress/api/integrations/register") {
method = HttpMethod.Post
setBody(requestJson.toString())
headers {
append("Content-Type", "application/json")
}
cookie(name = "token", value = token)
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 body: String = response.body()
val responseJson = JSONObject(body)
val responseJson = JSONObject(response.body<String>())
if (response.status.value != 200) {
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
}
token = responseJson.getString("token")
deviceId = responseJson.getString("id")
savePreferences()
status = STATUS_REGISTERED
}
Log.i(TAG, "Response: ${response.status.value} $body")
} catch (e: Exception) {
Log.e(TAG, "Error registering integration", e)
fun onStatusChanged(listener: (String) -> Unit) {
statusChangedListeners.add(listener)
}
@OptIn(DelicateCoroutinesApi::class)
fun connectWebsocket() {
GlobalScope.launch() {
websocketClient.connect(serverAddress, token)
}
}
suspend fun getIntegration(): Integration? {
Log.i(TAG, "Getting integration $deviceId at $serverAddress")
try {
val response: HttpResponse =
client.request("http://$serverAddress/api/integrations/$deviceId") {
method = HttpMethod.Get
headers {
append("Authorization", "Bearer $token")
}
cookie(name = "token", value = token)
}
val response =
client.sendRequest("http://$serverAddress/api/integrations/$deviceId", HttpMethod.Get)
?: return null
val body: String = response.body()
val responseJson = JSONObject(body)
val responseJson = JSONObject(response.body<String>())
if (response.status.value != 200) {
val error = responseJson.getString("error")
Log.e(TAG, "Error getting integration: ${response.status.value} $error")
@ -101,66 +119,37 @@ class DeviceService(private val context: Context) {
responseJson.getString("name")
)
return integration
} catch (e: Exception) {
Log.e(TAG, "Error getting integration", e)
}
return null
}
private fun loadPreferences() {
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
serverAddress = sharedPreferences.getString("server_address", "")!!
token = sharedPreferences.getString("token", "")!!
deviceId = sharedPreferences.getString("device_id", "")!!
serverAddress = sharedPreferences.getString(SERVER_ADDRESS_KEY, "")!!
token = sharedPreferences.getString(TOKEN_KEY, "")!!
deviceId = sharedPreferences.getString(DEVICE_ID_KEY, "")!!
Log.i(TAG, "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", serverAddress)
putString("token", token)
putString("device_id", deviceId)
putString(SERVER_ADDRESS_KEY, serverAddress)
putString(TOKEN_KEY, token)
putString(DEVICE_ID_KEY, deviceId)
apply()
}
}
fun connect() {
Log.i(TAG, "Connecting to websocket at $serverAddress")
runBlocking {
val (host, port) = serverAddress.split(":")
val portInt = if (port.isEmpty()) 80 else port.toInt()
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")
while (true) {
val frame = incoming.receive()
if (frame is Frame.Text) {
val message = frame.readText()
Log.i(TAG, "Received message: $message")
}
}
}
}
}
companion object {
const val STATUS_UNREGISTERED = "unregistered"
const val STATUS_REGISTERED = "registered"
const val STATUS_CONNECTED = "connected"
fun setServerAddress(url: String) {
serverAddress = url
}
fun getServerAddress(): String {
return serverAddress
}
fun getToken(): String {
return token
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"
}
}

View File

@ -0,0 +1,175 @@
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"
}
}

View File

@ -0,0 +1,136 @@
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"
}
}

View File

@ -3,23 +3,62 @@ 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 androidx.lifecycle.compose.LocalLifecycleOwner
import org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.VideoTrack
@Composable
fun CameraPreview(
controller: LifecycleCameraController,
modifier: Modifier = Modifier
eglBaseContext: EglBase.Context, videoTrack: VideoTrack, modifier: Modifier = Modifier
) {
val lifecycleOwner = LocalLifecycleOwner.current
val trackState: MutableState<VideoTrack?> = remember { mutableStateOf(null) }
var view: VideoTextureViewRenderer? by remember { mutableStateOf(null) }
AndroidView(
modifier = modifier,
factory = {
PreviewView(it).apply {
this.controller = controller
controller.bindToLifecycle(lifecycleOwner)
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
}
},
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)
}

View File

@ -0,0 +1,191 @@
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) {
""
}
}
}

View File

@ -1,34 +1,34 @@
package com.example.tvcontroller.ui.views
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.compose.foundation.layout.Arrangement
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.example.tvcontroller.ui.components.CameraPreview
import org.webrtc.EglBase
import org.webrtc.VideoTrack
@Composable
fun CameraView() {
val context = LocalContext.current
val controller = remember {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.VIDEO_CAPTURE)
}
}
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(
modifier = Modifier
.fillMaxSize()
.padding(all = 16.dp),
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CameraPreview(controller = controller, modifier = Modifier.fillMaxSize())
Box(modifier = if (isLandscape) Modifier.aspectRatio(ratio).fillMaxHeight() else Modifier.aspectRatio(ratio).fillMaxWidth()) {
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
}
}
}

View 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
)
}
}
}
}
}
}

View File

@ -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"
}
}

View File

@ -3,6 +3,7 @@ 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
@ -12,8 +13,10 @@ import org.json.JSONObject
class RemoteViewModel(
private val controllerService: ControllerService
) : ViewModel() {
private val commands = mutableMapOf<String, RemoteCommand>()
fun sendCommand(command: String) {
fun sendCommand(commandType: String) {
val command = commands[commandType]?: return
viewModelScope.launch(Dispatchers.IO) {
controllerService.sendCommand(command)
}

View File

@ -9,7 +9,6 @@ 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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -31,11 +30,10 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
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.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)
@Composable
@ -178,10 +176,12 @@ fun SettingsView(
}
@Composable
fun getConnectionStateString(state: Settings.ConnectionState): String {
fun getConnectionStateString(state: String): String {
return when (state) {
Settings.ConnectionState.Unregistered -> stringResource(id = R.string.connection_state_unregistered)
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_registered)
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"
}
}

View File

@ -8,24 +8,25 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.Settings
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.getServerAddress())
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(Settings.ConnectionState.Unregistered)
var connectionState by mutableStateOf(deviceService.status)
private set
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
private set
@ -43,36 +44,29 @@ class SettingsViewModel(
currentBluetoothDevice = bluetoothService.currentDevice
bluetoothConnectionState = it
}
deviceService.onStatusChanged { connectionState = it }
}
fun connect() {
//Log.i("SettingsScreen", "Save settings: $serverUrl, $deviceName, $registrationCode")
viewModelScope.launch {
deviceService.setServerAddress(serverAddress)
deviceService.serverAddress = serverAddress
deviceService.registerIntegration(deviceName, registrationCode)
updateConnectionState()
updateDeviceInfo()
deviceService.connect()
}
}
private fun updateConnectionState() {
Log.i("SettingsViewModel", "Device token: ${deviceService.getToken()}")
connectionState = if (deviceService.getToken().isEmpty()) {
Settings.ConnectionState.Unregistered
} else {
Settings.ConnectionState.Registered
}
Log.i(TAG, "Device token: ${deviceService.token}")
connectionState = deviceService.status
}
private suspend fun updateDeviceInfo() {
if (connectionState == Settings.ConnectionState.Unregistered) return
if (connectionState == DeviceService.STATUS_UNREGISTERED) return
val integration = deviceService.getIntegration()
if (integration == null) {
connectionState = Settings.ConnectionState.Unregistered
return
integration?.let {
deviceName = it.name
}
deviceName = integration.name
}
fun connectBluetoothDevice(device: BluetoothDevice) {

View File

@ -21,4 +21,5 @@
<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>

View File

@ -12,6 +12,7 @@ activityCompose = "1.10.1"
composeBom = "2025.03.01"
material3 = "1.4.0-alpha11"
navigationCompose = "2.8.9"
streamWebrtcAndroid = "1.3.8"
[libraries]
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCore" }
@ -40,6 +41,7 @@ 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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }