Dashboard settings, game icons, default execution mode, fix native lib linking
- Add AppPreferences for persisted default streaming mode (IN_GAME/APP_STREAMING) - Add GameInfoProvider to resolve package names to icons via PackageManager - Add GameInfoRow composable used across dashboard, plans, and clients screens - Show backend version in dashboard status card - Default execution mode toggle on dashboard, picked up by new plans - Plan edit mode with full CRUD support - Fix CMake IMPORTED_NO_SONAME to prevent absolute Windows paths in DT_NEEDED - Catch Throwable (not just Exception) for UnsatisfiedLinkError in streaming start
This commit is contained in:
@@ -21,19 +21,24 @@ target_include_directories(lck_streaming PRIVATE
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Import pre-built librtmp from jniLibs
|
# Import pre-built librtmp from jniLibs
|
||||||
|
# IMPORTED_NO_SONAME prevents CMake from embedding the absolute build path
|
||||||
|
# as DT_NEEDED — the Android linker will find the .so by name in the APK.
|
||||||
add_library(rtmp SHARED IMPORTED)
|
add_library(rtmp SHARED IMPORTED)
|
||||||
set_target_properties(rtmp PROPERTIES
|
set_target_properties(rtmp PROPERTIES
|
||||||
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/librtmp.so
|
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/librtmp.so
|
||||||
|
IMPORTED_NO_SONAME TRUE
|
||||||
)
|
)
|
||||||
|
|
||||||
add_library(ssl SHARED IMPORTED)
|
add_library(ssl SHARED IMPORTED)
|
||||||
set_target_properties(ssl PROPERTIES
|
set_target_properties(ssl PROPERTIES
|
||||||
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libssl.so
|
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libssl.so
|
||||||
|
IMPORTED_NO_SONAME TRUE
|
||||||
)
|
)
|
||||||
|
|
||||||
add_library(crypto SHARED IMPORTED)
|
add_library(crypto SHARED IMPORTED)
|
||||||
set_target_properties(crypto PROPERTIES
|
set_target_properties(crypto PROPERTIES
|
||||||
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libcrypto.so
|
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libcrypto.so
|
||||||
|
IMPORTED_NO_SONAME TRUE
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lck_streaming
|
target_link_libraries(lck_streaming
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.omixlab.lckcontrol.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AppPreferences @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences("lck_control_app_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun getDefaultExecutionMode(): String =
|
||||||
|
prefs.getString(KEY_DEFAULT_EXECUTION_MODE, "IN_GAME") ?: "IN_GAME"
|
||||||
|
|
||||||
|
fun setDefaultExecutionMode(mode: String) {
|
||||||
|
prefs.edit().putString(KEY_DEFAULT_EXECUTION_MODE, mode).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_DEFAULT_EXECUTION_MODE = "default_execution_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.squareup.moshi.JsonClass
|
|||||||
data class HealthResponse(
|
data class HealthResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
|
val version: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────
|
||||||
@@ -72,6 +73,14 @@ data class CreateStreamPlanRequest(
|
|||||||
val destinations: List<CreateDestinationRequest>,
|
val destinations: List<CreateDestinationRequest>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class UpdateStreamPlanRequest(
|
||||||
|
val name: String? = null,
|
||||||
|
val executionMode: String? = null,
|
||||||
|
val gameId: String? = null,
|
||||||
|
val destinations: List<CreateDestinationRequest>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class CreateDestinationRequest(
|
data class CreateDestinationRequest(
|
||||||
val linkedAccountId: String,
|
val linkedAccountId: String,
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface LckApiService {
|
|||||||
@GET("streams/plans/{id}")
|
@GET("streams/plans/{id}")
|
||||||
suspend fun getStreamPlan(@Path("id") id: String): StreamPlanResponse
|
suspend fun getStreamPlan(@Path("id") id: String): StreamPlanResponse
|
||||||
|
|
||||||
|
@PUT("streams/plans/{id}")
|
||||||
|
suspend fun updateStreamPlan(@Path("id") id: String, @Body body: UpdateStreamPlanRequest): StreamPlanResponse
|
||||||
|
|
||||||
@DELETE("streams/plans/{id}")
|
@DELETE("streams/plans/{id}")
|
||||||
suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse
|
suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.omixlab.lckcontrol.data.remote.CreateStreamPlanRequest
|
|||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||||
import com.omixlab.lckcontrol.data.remote.PrepareResponse
|
import com.omixlab.lckcontrol.data.remote.PrepareResponse
|
||||||
import com.omixlab.lckcontrol.data.remote.StreamPlanResponse
|
import com.omixlab.lckcontrol.data.remote.StreamPlanResponse
|
||||||
|
import com.omixlab.lckcontrol.data.remote.UpdateStreamPlanRequest
|
||||||
import com.omixlab.lckcontrol.shared.StreamDestination
|
import com.omixlab.lckcontrol.shared.StreamDestination
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -68,6 +69,34 @@ class StreamPlanRepository @Inject constructor(
|
|||||||
return planDao.getById(response.id)!!.toStreamPlan()
|
return planDao.getById(response.id)!!.toStreamPlan()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a DRAFT plan via backend and refresh local cache */
|
||||||
|
suspend fun updatePlan(
|
||||||
|
planId: String,
|
||||||
|
name: String,
|
||||||
|
destinations: List<StreamDestination>,
|
||||||
|
executionMode: String,
|
||||||
|
gameId: String,
|
||||||
|
): StreamPlan {
|
||||||
|
val request = UpdateStreamPlanRequest(
|
||||||
|
name = name,
|
||||||
|
executionMode = executionMode,
|
||||||
|
gameId = gameId.ifBlank { null },
|
||||||
|
destinations = destinations.map { dest ->
|
||||||
|
CreateDestinationRequest(
|
||||||
|
linkedAccountId = dest.linkedAccountId,
|
||||||
|
title = dest.title,
|
||||||
|
description = dest.description,
|
||||||
|
privacyStatus = dest.privacyStatus,
|
||||||
|
gameId = dest.gameId,
|
||||||
|
tags = dest.tags.joinToString(","),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val response = apiService.updateStreamPlan(planId, request)
|
||||||
|
cacheRemotePlan(response)
|
||||||
|
return planDao.getById(response.id)!!.toStreamPlan()
|
||||||
|
}
|
||||||
|
|
||||||
/** Prepare plan via backend — returns RTMP info */
|
/** Prepare plan via backend — returns RTMP info */
|
||||||
suspend fun preparePlan(planId: String): PrepareResponse {
|
suspend fun preparePlan(planId: String): PrepareResponse {
|
||||||
val response = apiService.prepareStreamPlan(planId)
|
val response = apiService.prepareStreamPlan(planId)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.meta.horizon.platform.ovr.Core
|
|||||||
import com.meta.horizon.platform.ovr.requests.Request
|
import com.meta.horizon.platform.ovr.requests.Request
|
||||||
import com.meta.horizon.platform.ovr.requests.Users
|
import com.meta.horizon.platform.ovr.requests.Users
|
||||||
import com.omixlab.lckcontrol.R
|
import com.omixlab.lckcontrol.R
|
||||||
|
import com.omixlab.lckcontrol.data.local.AppPreferences
|
||||||
import com.omixlab.lckcontrol.data.local.TokenStore
|
import com.omixlab.lckcontrol.data.local.TokenStore
|
||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||||
import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest
|
import com.omixlab.lckcontrol.data.remote.MetaCallbackRequest
|
||||||
@@ -54,6 +55,7 @@ class LckControlService : Service() {
|
|||||||
private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING"
|
private const val ACTION_BIND_STREAMING = "com.omixlab.lckcontrol.BIND_STREAMING"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject lateinit var appPreferences: AppPreferences
|
||||||
@Inject lateinit var accountRepository: AccountRepository
|
@Inject lateinit var accountRepository: AccountRepository
|
||||||
@Inject lateinit var streamPlanRepository: StreamPlanRepository
|
@Inject lateinit var streamPlanRepository: StreamPlanRepository
|
||||||
@Inject lateinit var tokenStore: TokenStore
|
@Inject lateinit var tokenStore: TokenStore
|
||||||
@@ -115,6 +117,8 @@ class LckControlService : Service() {
|
|||||||
val accounts = accountRepository.getAccounts().filter { it.isEnabled }
|
val accounts = accountRepository.getAccounts().filter { it.isEnabled }
|
||||||
val gameId = clientTracker.getAll()
|
val gameId = clientTracker.getAll()
|
||||||
.find { it.clientName == clientName }?.packageName ?: ""
|
.find { it.clientName == clientName }?.packageName ?: ""
|
||||||
|
val execMode = appPreferences.getDefaultExecutionMode()
|
||||||
|
Log.d(TAG, "createDefaultPlan: clientName=$clientName, executionMode=$execMode, accounts=${accounts.size}")
|
||||||
val destinations = accounts.map { account ->
|
val destinations = accounts.map { account ->
|
||||||
StreamDestination(
|
StreamDestination(
|
||||||
service = account.serviceId,
|
service = account.serviceId,
|
||||||
@@ -126,8 +130,10 @@ class LckControlService : Service() {
|
|||||||
val plan = streamPlanRepository.createPlan(
|
val plan = streamPlanRepository.createPlan(
|
||||||
name = "$clientName Stream",
|
name = "$clientName Stream",
|
||||||
destinations = destinations,
|
destinations = destinations,
|
||||||
|
executionMode = execMode,
|
||||||
gameId = gameId,
|
gameId = gameId,
|
||||||
)
|
)
|
||||||
|
Log.d(TAG, "createDefaultPlan: created plan ${plan.planId} with executionMode=${plan.executionMode}")
|
||||||
broadcastPlansChanged()
|
broadcastPlansChanged()
|
||||||
plan
|
plan
|
||||||
}
|
}
|
||||||
@@ -167,7 +173,10 @@ class LckControlService : Service() {
|
|||||||
|
|
||||||
if (updated != null) broadcastPlanUpdated(updated)
|
if (updated != null) broadcastPlanUpdated(updated)
|
||||||
true
|
true
|
||||||
} catch (_: Exception) { false }
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "startStreamPlan failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun endStreamPlan(planId: String): Boolean = runBlocking {
|
override fun endStreamPlan(planId: String): Boolean = runBlocking {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class StreamingManager @Inject constructor() {
|
|||||||
_error.value = "Failed to start streaming engine"
|
_error.value = "Failed to start streaming engine"
|
||||||
_state.value = StreamingState.ERROR
|
_state.value = StreamingState.ERROR
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Failed to start streaming", e)
|
Log.e(TAG, "Failed to start streaming", e)
|
||||||
_error.value = e.message ?: "Unknown error"
|
_error.value = e.message ?: "Unknown error"
|
||||||
_state.value = StreamingState.ERROR
|
_state.value = StreamingState.ERROR
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.omixlab.lckcontrol.ui.components.GameInfoRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ActiveClientsScreen(
|
fun ActiveClientsScreen(
|
||||||
|
onNavigateToPlan: (String) -> Unit = {},
|
||||||
viewModel: ActiveClientsViewModel = hiltViewModel(),
|
viewModel: ActiveClientsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val clients by viewModel.clients.collectAsStateWithLifecycle()
|
val clients by viewModel.clients.collectAsStateWithLifecycle()
|
||||||
@@ -116,10 +118,11 @@ fun ActiveClientsScreen(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(client.clientName, style = MaterialTheme.typography.titleSmall)
|
Text(client.clientName, style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
Spacer(Modifier.height(4.dp))
|
||||||
client.packageName,
|
GameInfoRow(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
packageName = client.packageName,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
gameInfoProvider = viewModel.gameInfoProvider,
|
||||||
|
iconSize = 24.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (client.activePlanId != null) {
|
if (client.activePlanId != null) {
|
||||||
@@ -133,7 +136,12 @@ fun ActiveClientsScreen(
|
|||||||
if (client.activePlanId == null) {
|
if (client.activePlanId == null) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.createDefaultPlan(client.clientName) },
|
onClick = {
|
||||||
|
val plan = viewModel.createDefaultPlan(client.clientName)
|
||||||
|
if (plan != null) {
|
||||||
|
onNavigateToPlan(plan.planId)
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text("Create Default Plan")
|
Text("Create Default Plan")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.omixlab.lckcontrol.shared.ConnectedClientInfo
|
|||||||
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
import com.omixlab.lckcontrol.shared.ILckControlService
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,6 +21,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ActiveClientsViewModel @Inject constructor(
|
class ActiveClientsViewModel @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
val gameInfoProvider: GameInfoProvider,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private var service: ILckControlService? = null
|
private var service: ILckControlService? = null
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.omixlab.lckcontrol.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GameInfoRow(
|
||||||
|
packageName: String,
|
||||||
|
gameInfoProvider: GameInfoProvider,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
showPackageName: Boolean = false,
|
||||||
|
) {
|
||||||
|
val gameInfo = remember(packageName) { gameInfoProvider.resolve(packageName) }
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (gameInfo != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = gameInfo.icon,
|
||||||
|
contentDescription = gameInfo.label,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(gameInfo.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (showPackageName) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
"(${gameInfo.packageName})",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.SportsEsports,
|
||||||
|
contentDescription = "Game",
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
packageName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -34,6 +35,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
|
import com.omixlab.lckcontrol.ui.components.GameInfoRow
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,6 +47,8 @@ fun DashboardScreen(
|
|||||||
) {
|
) {
|
||||||
val plans by viewModel.plans.collectAsStateWithLifecycle()
|
val plans by viewModel.plans.collectAsStateWithLifecycle()
|
||||||
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
|
val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle()
|
||||||
|
val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle()
|
||||||
|
val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -86,11 +91,39 @@ fun DashboardScreen(
|
|||||||
Column {
|
Column {
|
||||||
Text("Backend", style = MaterialTheme.typography.titleSmall)
|
Text("Backend", style = MaterialTheme.typography.titleSmall)
|
||||||
Text(label, style = MaterialTheme.typography.bodySmall, color = color)
|
Text(label, style = MaterialTheme.typography.bodySmall, color = color)
|
||||||
|
if (backendVersion != null) {
|
||||||
|
Text(
|
||||||
|
"v$backendVersion",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = defaultExecutionMode == "IN_GAME",
|
||||||
|
onClick = { viewModel.setDefaultExecutionMode("IN_GAME") },
|
||||||
|
label = { Text("In-Game") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = defaultExecutionMode == "APP_STREAMING",
|
||||||
|
onClick = { viewModel.setDefaultExecutionMode("APP_STREAMING") },
|
||||||
|
label = { Text("App Streaming") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
|
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
|
||||||
@@ -114,7 +147,11 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(plans, key = { it.planId }) { plan ->
|
items(plans, key = { it.planId }) { plan ->
|
||||||
PlanCard(plan = plan, onClick = { onNavigateToPlan(plan.planId) })
|
PlanCard(
|
||||||
|
plan = plan,
|
||||||
|
gameInfoProvider = viewModel.gameInfoProvider,
|
||||||
|
onClick = { onNavigateToPlan(plan.planId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +161,11 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) {
|
private fun PlanCard(
|
||||||
|
plan: StreamPlan,
|
||||||
|
gameInfoProvider: GameInfoProvider,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -145,6 +186,14 @@ private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
if (plan.gameId.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
GameInfoRow(
|
||||||
|
packageName = plan.gameId,
|
||||||
|
gameInfoProvider = gameInfoProvider,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.omixlab.lckcontrol.ui.dashboard
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.omixlab.lckcontrol.data.local.AppPreferences
|
||||||
import com.omixlab.lckcontrol.data.remote.LckApiService
|
import com.omixlab.lckcontrol.data.remote.LckApiService
|
||||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,6 +21,8 @@ import javax.inject.Inject
|
|||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
private val streamPlanRepository: StreamPlanRepository,
|
private val streamPlanRepository: StreamPlanRepository,
|
||||||
private val apiService: LckApiService,
|
private val apiService: LckApiService,
|
||||||
|
private val appPreferences: AppPreferences,
|
||||||
|
val gameInfoProvider: GameInfoProvider,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
|
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
|
||||||
@@ -27,20 +31,32 @@ class DashboardViewModel @Inject constructor(
|
|||||||
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
|
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
|
||||||
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
|
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
|
||||||
|
|
||||||
|
private val _backendVersion = MutableStateFlow<String?>(null)
|
||||||
|
val backendVersion: StateFlow<String?> = _backendVersion.asStateFlow()
|
||||||
|
|
||||||
|
private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
|
||||||
|
val defaultExecutionMode: StateFlow<String> = _defaultExecutionMode.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
|
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
_backendHealthy.value = try {
|
try {
|
||||||
apiService.healthCheck()
|
val response = apiService.healthCheck()
|
||||||
true
|
_backendHealthy.value = true
|
||||||
|
_backendVersion.value = response.version
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
_backendHealthy.value = false
|
||||||
}
|
}
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultExecutionMode(mode: String) {
|
||||||
|
_defaultExecutionMode.value = mode
|
||||||
|
appPreferences.setDefaultExecutionMode(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
|
|||||||
}
|
}
|
||||||
composable(Screen.Dashboard.route) {
|
composable(Screen.Dashboard.route) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.route) },
|
onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.createRoute()) },
|
||||||
onNavigateToPlan = { planId ->
|
onNavigateToPlan = { planId ->
|
||||||
navController.navigate(Screen.PlanDetail.createRoute(planId))
|
navController.navigate(Screen.PlanDetail.createRoute(planId))
|
||||||
},
|
},
|
||||||
@@ -119,7 +119,14 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
|
|||||||
composable(Screen.Accounts.route) {
|
composable(Screen.Accounts.route) {
|
||||||
AccountsScreen()
|
AccountsScreen()
|
||||||
}
|
}
|
||||||
composable(Screen.CreatePlan.route) {
|
composable(
|
||||||
|
route = Screen.CreatePlan.route,
|
||||||
|
arguments = listOf(navArgument("planId") {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
}),
|
||||||
|
) {
|
||||||
CreatePlanScreen(
|
CreatePlanScreen(
|
||||||
onPlanCreated = { planId ->
|
onPlanCreated = { planId ->
|
||||||
navController.navigate(Screen.PlanDetail.createRoute(planId)) {
|
navController.navigate(Screen.PlanDetail.createRoute(planId)) {
|
||||||
@@ -137,10 +144,17 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) {
|
|||||||
PlanDetailScreen(
|
PlanDetailScreen(
|
||||||
planId = planId,
|
planId = planId,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onNavigateToEditPlan = { id ->
|
||||||
|
navController.navigate(Screen.CreatePlan.createRoute(id))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.ActiveClients.route) {
|
composable(Screen.ActiveClients.route) {
|
||||||
ActiveClientsScreen()
|
ActiveClientsScreen(
|
||||||
|
onNavigateToPlan = { planId ->
|
||||||
|
navController.navigate(Screen.PlanDetail.createRoute(planId))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ sealed class Screen(val route: String) {
|
|||||||
data object Login : Screen("login")
|
data object Login : Screen("login")
|
||||||
data object Dashboard : Screen("dashboard")
|
data object Dashboard : Screen("dashboard")
|
||||||
data object Accounts : Screen("accounts")
|
data object Accounts : Screen("accounts")
|
||||||
data object CreatePlan : Screen("create_plan")
|
data object CreatePlan : Screen("create_plan?planId={planId}") {
|
||||||
|
fun createRoute(planId: String? = null) =
|
||||||
|
if (planId != null) "create_plan?planId=$planId" else "create_plan"
|
||||||
|
}
|
||||||
data object PlanDetail : Screen("plan_detail/{planId}") {
|
data object PlanDetail : Screen("plan_detail/{planId}") {
|
||||||
fun createRoute(planId: String) = "plan_detail/$planId"
|
fun createRoute(planId: String) = "plan_detail/$planId"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.omixlab.lckcontrol.shared.LinkedAccount
|
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||||
|
import com.omixlab.lckcontrol.ui.components.GameInfoRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -51,9 +52,11 @@ fun CreatePlanScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: CreatePlanViewModel = hiltViewModel(),
|
viewModel: CreatePlanViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val isEditMode = viewModel.isEditMode
|
||||||
val planName by viewModel.planName.collectAsStateWithLifecycle()
|
val planName by viewModel.planName.collectAsStateWithLifecycle()
|
||||||
val executionMode by viewModel.executionMode.collectAsStateWithLifecycle()
|
val executionMode by viewModel.executionMode.collectAsStateWithLifecycle()
|
||||||
val gameId by viewModel.gameId.collectAsStateWithLifecycle()
|
val gameId by viewModel.gameId.collectAsStateWithLifecycle()
|
||||||
|
val connectedClients by viewModel.connectedClients.collectAsStateWithLifecycle()
|
||||||
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
|
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
|
||||||
val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle()
|
val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle()
|
||||||
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
|
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
|
||||||
@@ -70,7 +73,7 @@ fun CreatePlanScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Create Stream Plan") },
|
title = { Text(if (isEditMode) "Edit Stream Plan" else "Create Stream Plan") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
@@ -128,14 +131,21 @@ fun CreatePlanScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
Text("Game", style = MaterialTheme.typography.titleMedium)
|
||||||
value = gameId,
|
Spacer(Modifier.height(4.dp))
|
||||||
onValueChange = viewModel::setGameId,
|
if (gameId.isNotBlank()) {
|
||||||
label = { Text("Game Package ID") },
|
GameInfoRow(
|
||||||
placeholder = { Text("com.example.game") },
|
packageName = gameId,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
gameInfoProvider = viewModel.gameInfoProvider,
|
||||||
singleLine = true,
|
showPackageName = true,
|
||||||
)
|
)
|
||||||
|
} else if (connectedClients.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No game connected",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
@@ -165,11 +175,18 @@ fun CreatePlanScreen(
|
|||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.createPlan(onPlanCreated) },
|
onClick = { viewModel.savePlan(onPlanCreated) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
) {
|
) {
|
||||||
Text(if (isCreating) "Creating..." else "Create Plan")
|
Text(
|
||||||
|
when {
|
||||||
|
isCreating && isEditMode -> "Saving..."
|
||||||
|
isCreating -> "Creating..."
|
||||||
|
isEditMode -> "Save"
|
||||||
|
else -> "Create Plan"
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
package com.omixlab.lckcontrol.ui.plans
|
package com.omixlab.lckcontrol.ui.plans
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.omixlab.lckcontrol.data.repository.AccountRepository
|
import com.omixlab.lckcontrol.data.repository.AccountRepository
|
||||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||||
|
import com.omixlab.lckcontrol.shared.ConnectedClientInfo
|
||||||
|
import com.omixlab.lckcontrol.shared.ILckControlCallback
|
||||||
|
import com.omixlab.lckcontrol.shared.ILckControlService
|
||||||
import com.omixlab.lckcontrol.shared.LinkedAccount
|
import com.omixlab.lckcontrol.shared.LinkedAccount
|
||||||
import com.omixlab.lckcontrol.shared.StreamDestination
|
import com.omixlab.lckcontrol.shared.StreamDestination
|
||||||
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
|
import com.omixlab.lckcontrol.data.local.AppPreferences
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -27,22 +40,32 @@ data class DestinationInput(
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CreatePlanViewModel @Inject constructor(
|
class CreatePlanViewModel @Inject constructor(
|
||||||
accountRepository: AccountRepository,
|
@ApplicationContext private val context: Context,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
private val streamPlanRepository: StreamPlanRepository,
|
private val streamPlanRepository: StreamPlanRepository,
|
||||||
|
private val appPreferences: AppPreferences,
|
||||||
|
val gameInfoProvider: GameInfoProvider,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val editingPlanId: String? = savedStateHandle.get<String>("planId")
|
||||||
|
val isEditMode: Boolean = editingPlanId != null
|
||||||
|
|
||||||
val linkedAccounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
|
val linkedAccounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
private val _planName = MutableStateFlow("")
|
private val _planName = MutableStateFlow("")
|
||||||
val planName: StateFlow<String> = _planName.asStateFlow()
|
val planName: StateFlow<String> = _planName.asStateFlow()
|
||||||
|
|
||||||
private val _executionMode = MutableStateFlow("IN_GAME")
|
private val _executionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
|
||||||
val executionMode: StateFlow<String> = _executionMode.asStateFlow()
|
val executionMode: StateFlow<String> = _executionMode.asStateFlow()
|
||||||
|
|
||||||
private val _gameId = MutableStateFlow("")
|
private val _gameId = MutableStateFlow("")
|
||||||
val gameId: StateFlow<String> = _gameId.asStateFlow()
|
val gameId: StateFlow<String> = _gameId.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectedClients = MutableStateFlow<List<ConnectedClientInfo>>(emptyList())
|
||||||
|
val connectedClients: StateFlow<List<ConnectedClientInfo>> = _connectedClients.asStateFlow()
|
||||||
|
|
||||||
private val _destinations = MutableStateFlow<List<DestinationInput>>(emptyList())
|
private val _destinations = MutableStateFlow<List<DestinationInput>>(emptyList())
|
||||||
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
|
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
|
||||||
|
|
||||||
@@ -52,6 +75,84 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
private val _error = MutableStateFlow<String?>(null)
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
val error: StateFlow<String?> = _error.asStateFlow()
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
private var service: ILckControlService? = null
|
||||||
|
|
||||||
|
private val callback = object : ILckControlCallback.Stub() {
|
||||||
|
override fun onStreamPlansChanged(plans: List<StreamPlan>) {}
|
||||||
|
override fun onStreamPlanUpdated(plan: StreamPlan) {}
|
||||||
|
override fun onClientRegistered(clientId: String) { refreshClients() }
|
||||||
|
override fun onClientUnregistered(clientId: String) { refreshClients() }
|
||||||
|
override fun onAuthStateChanged(authenticated: Boolean) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
|
service = ILckControlService.Stub.asInterface(binder)
|
||||||
|
service?.registerCallback(callback)
|
||||||
|
refreshClients()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
service = null
|
||||||
|
_connectedClients.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
bindToService()
|
||||||
|
if (isEditMode) {
|
||||||
|
loadExistingPlan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindToService() {
|
||||||
|
val intent = Intent().apply {
|
||||||
|
component = ComponentName(
|
||||||
|
context.packageName,
|
||||||
|
"com.omixlab.lckcontrol.service.LckControlService",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshClients() {
|
||||||
|
val clients = service?.connectedClients ?: emptyList()
|
||||||
|
_connectedClients.value = clients
|
||||||
|
// Auto-fill gameId from first connected client when empty
|
||||||
|
if (_gameId.value.isBlank() && clients.isNotEmpty()) {
|
||||||
|
_gameId.value = clients.first().packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadExistingPlan() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val plan = streamPlanRepository.getPlan(editingPlanId!!) ?: return@launch
|
||||||
|
_planName.value = plan.name
|
||||||
|
_executionMode.value = plan.executionMode
|
||||||
|
_gameId.value = plan.gameId
|
||||||
|
|
||||||
|
// Wait for linked accounts to load for label resolution
|
||||||
|
val accounts = accountRepository.getAccounts()
|
||||||
|
_destinations.value = plan.destinations.map { dest ->
|
||||||
|
val account = accounts.find { it.id == dest.linkedAccountId }
|
||||||
|
val label = if (account != null) "${account.serviceId} - ${account.displayName}" else dest.service
|
||||||
|
DestinationInput(
|
||||||
|
linkedAccountId = dest.linkedAccountId,
|
||||||
|
linkedAccountLabel = label,
|
||||||
|
title = dest.title,
|
||||||
|
description = dest.description,
|
||||||
|
privacyStatus = dest.privacyStatus,
|
||||||
|
gameId = dest.gameId,
|
||||||
|
tags = dest.tags.joinToString(","),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = e.message ?: "Failed to load plan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setPlanName(name: String) {
|
fun setPlanName(name: String) {
|
||||||
_planName.value = name
|
_planName.value = name
|
||||||
}
|
}
|
||||||
@@ -80,7 +181,7 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPlan(onCreated: (String) -> Unit) {
|
fun savePlan(onSaved: (String) -> Unit) {
|
||||||
val name = _planName.value.trim()
|
val name = _planName.value.trim()
|
||||||
val dests = _destinations.value
|
val dests = _destinations.value
|
||||||
|
|
||||||
@@ -114,10 +215,14 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val plan = streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value)
|
val plan = if (isEditMode) {
|
||||||
onCreated(plan.planId)
|
streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value)
|
||||||
|
} else {
|
||||||
|
streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value)
|
||||||
|
}
|
||||||
|
onSaved(plan.planId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_error.value = e.message ?: "Failed to create plan"
|
_error.value = e.message ?: "Failed to ${if (isEditMode) "update" else "create"} plan"
|
||||||
} finally {
|
} finally {
|
||||||
_isCreating.value = false
|
_isCreating.value = false
|
||||||
}
|
}
|
||||||
@@ -127,4 +232,12 @@ class CreatePlanViewModel @Inject constructor(
|
|||||||
fun clearError() {
|
fun clearError() {
|
||||||
_error.value = null
|
_error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
service?.unregisterCallback(callback)
|
||||||
|
try {
|
||||||
|
context.unbindService(serviceConnection)
|
||||||
|
} catch (_: IllegalArgumentException) {}
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -41,12 +42,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.omixlab.lckcontrol.shared.StreamDestination
|
import com.omixlab.lckcontrol.shared.StreamDestination
|
||||||
import com.omixlab.lckcontrol.streaming.StreamingState
|
import com.omixlab.lckcontrol.streaming.StreamingState
|
||||||
|
import com.omixlab.lckcontrol.ui.components.GameInfoRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlanDetailScreen(
|
fun PlanDetailScreen(
|
||||||
planId: String,
|
planId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onNavigateToEditPlan: (String) -> Unit = {},
|
||||||
viewModel: PlanDetailViewModel = hiltViewModel(),
|
viewModel: PlanDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val plan by viewModel.plan.collectAsStateWithLifecycle()
|
val plan by viewModel.plan.collectAsStateWithLifecycle()
|
||||||
@@ -73,6 +76,11 @@ fun PlanDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (plan?.status == "DRAFT") {
|
||||||
|
IconButton(onClick = { onNavigateToEditPlan(planId) }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
if (plan?.status != "LIVE") {
|
if (plan?.status != "LIVE") {
|
||||||
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
|
IconButton(onClick = { viewModel.deletePlan(onBack) }) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||||
@@ -142,14 +150,18 @@ fun PlanDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game ID
|
// Game
|
||||||
if (currentPlan.gameId.isNotBlank()) {
|
if (currentPlan.gameId.isNotBlank()) {
|
||||||
item {
|
item {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text("Game", style = MaterialTheme.typography.labelMedium)
|
Text("Game", style = MaterialTheme.typography.labelMedium)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(currentPlan.gameId, style = MaterialTheme.typography.bodyMedium)
|
GameInfoRow(
|
||||||
|
packageName = currentPlan.gameId,
|
||||||
|
gameInfoProvider = viewModel.gameInfoProvider,
|
||||||
|
showPackageName = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
|
||||||
import com.omixlab.lckcontrol.shared.StreamPlan
|
import com.omixlab.lckcontrol.shared.StreamPlan
|
||||||
import com.omixlab.lckcontrol.streaming.StreamingManager
|
import com.omixlab.lckcontrol.streaming.StreamingManager
|
||||||
|
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||||
import com.omixlab.lckcontrol.streaming.StreamingState
|
import com.omixlab.lckcontrol.streaming.StreamingState
|
||||||
import com.omixlab.lckcontrol.streaming.StreamingStats
|
import com.omixlab.lckcontrol.streaming.StreamingStats
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -22,6 +23,7 @@ class PlanDetailViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val streamPlanRepository: StreamPlanRepository,
|
private val streamPlanRepository: StreamPlanRepository,
|
||||||
private val streamingManager: StreamingManager,
|
private val streamingManager: StreamingManager,
|
||||||
|
val gameInfoProvider: GameInfoProvider,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val planId: String = savedStateHandle["planId"] ?: ""
|
private val planId: String = savedStateHandle["planId"] ?: ""
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.omixlab.lckcontrol.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class GameInfo(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String,
|
||||||
|
val icon: ImageBitmap,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class GameInfoProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val cache = HashMap<String, GameInfo?>()
|
||||||
|
|
||||||
|
fun resolve(packageName: String): GameInfo? {
|
||||||
|
if (packageName.isBlank()) return null
|
||||||
|
return cache.getOrPut(packageName) {
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||||
|
val label = pm.getApplicationLabel(appInfo).toString()
|
||||||
|
val icon = pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
|
||||||
|
GameInfo(packageName, label, icon)
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user