diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 3e4c2f6..9bcdbbd 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -21,19 +21,24 @@ target_include_directories(lck_streaming PRIVATE ) # 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) set_target_properties(rtmp PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/librtmp.so + IMPORTED_NO_SONAME TRUE ) add_library(ssl SHARED IMPORTED) set_target_properties(ssl PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libssl.so + IMPORTED_NO_SONAME TRUE ) add_library(crypto SHARED IMPORTED) set_target_properties(crypto PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libcrypto.so + IMPORTED_NO_SONAME TRUE ) target_link_libraries(lck_streaming diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/local/AppPreferences.kt b/app/src/main/java/com/omixlab/lckcontrol/data/local/AppPreferences.kt new file mode 100644 index 0000000..9ea1a45 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/data/local/AppPreferences.kt @@ -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" + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt index 9dbf780..34bedaf 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.JsonClass data class HealthResponse( val status: String, val timestamp: String, + val version: String? = null, ) // ── Auth ───────────────────────────────────────────────── @@ -72,6 +73,14 @@ data class CreateStreamPlanRequest( val destinations: List, ) +@JsonClass(generateAdapter = true) +data class UpdateStreamPlanRequest( + val name: String? = null, + val executionMode: String? = null, + val gameId: String? = null, + val destinations: List? = null, +) + @JsonClass(generateAdapter = true) data class CreateDestinationRequest( val linkedAccountId: String, diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt index 107f793..477da24 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt @@ -54,6 +54,9 @@ interface LckApiService { @GET("streams/plans/{id}") 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}") suspend fun deleteStreamPlan(@Path("id") id: String): SuccessResponse diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt index d5eaf04..00b454b 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt @@ -9,6 +9,7 @@ import com.omixlab.lckcontrol.data.remote.CreateStreamPlanRequest import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.remote.PrepareResponse 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.StreamPlan import kotlinx.coroutines.flow.Flow @@ -68,6 +69,34 @@ class StreamPlanRepository @Inject constructor( 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, + 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 */ suspend fun preparePlan(planId: String): PrepareResponse { val response = apiService.prepareStreamPlan(planId) diff --git a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt index 636490e..2db9929 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/service/LckControlService.kt @@ -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.Users import com.omixlab.lckcontrol.R +import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.local.TokenStore import com.omixlab.lckcontrol.data.remote.LckApiService 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" } + @Inject lateinit var appPreferences: AppPreferences @Inject lateinit var accountRepository: AccountRepository @Inject lateinit var streamPlanRepository: StreamPlanRepository @Inject lateinit var tokenStore: TokenStore @@ -115,6 +117,8 @@ class LckControlService : Service() { val accounts = accountRepository.getAccounts().filter { it.isEnabled } val gameId = clientTracker.getAll() .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 -> StreamDestination( service = account.serviceId, @@ -126,8 +130,10 @@ class LckControlService : Service() { val plan = streamPlanRepository.createPlan( name = "$clientName Stream", destinations = destinations, + executionMode = execMode, gameId = gameId, ) + Log.d(TAG, "createDefaultPlan: created plan ${plan.planId} with executionMode=${plan.executionMode}") broadcastPlansChanged() plan } @@ -167,7 +173,10 @@ class LckControlService : Service() { if (updated != null) broadcastPlanUpdated(updated) true - } catch (_: Exception) { false } + } catch (e: Throwable) { + Log.e(TAG, "startStreamPlan failed", e) + false + } } override fun endStreamPlan(planId: String): Boolean = runBlocking { diff --git a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt index 048c881..b285713 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/streaming/StreamingManager.kt @@ -99,7 +99,7 @@ class StreamingManager @Inject constructor() { _error.value = "Failed to start streaming engine" _state.value = StreamingState.ERROR } - } catch (e: Exception) { + } catch (e: Throwable) { Log.e(TAG, "Failed to start streaming", e) _error.value = e.message ?: "Unknown error" _state.value = StreamingState.ERROR diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt index ff067ac..684402b 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsScreen.kt @@ -29,10 +29,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.omixlab.lckcontrol.ui.components.GameInfoRow @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActiveClientsScreen( + onNavigateToPlan: (String) -> Unit = {}, viewModel: ActiveClientsViewModel = hiltViewModel(), ) { val clients by viewModel.clients.collectAsStateWithLifecycle() @@ -116,10 +118,11 @@ fun ActiveClientsScreen( ) { Column(modifier = Modifier.weight(1f)) { Text(client.clientName, style = MaterialTheme.typography.titleSmall) - Text( - client.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Spacer(Modifier.height(4.dp)) + GameInfoRow( + packageName = client.packageName, + gameInfoProvider = viewModel.gameInfoProvider, + iconSize = 24.dp, ) } if (client.activePlanId != null) { @@ -133,7 +136,12 @@ fun ActiveClientsScreen( if (client.activePlanId == null) { Spacer(Modifier.height(8.dp)) OutlinedButton( - onClick = { viewModel.createDefaultPlan(client.clientName) }, + onClick = { + val plan = viewModel.createDefaultPlan(client.clientName) + if (plan != null) { + onNavigateToPlan(plan.planId) + } + }, ) { Text("Create Default Plan") } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt index cc637b1..1e2da17 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/clients/ActiveClientsViewModel.kt @@ -10,6 +10,7 @@ import com.omixlab.lckcontrol.shared.ConnectedClientInfo import com.omixlab.lckcontrol.shared.ILckControlCallback import com.omixlab.lckcontrol.shared.ILckControlService import com.omixlab.lckcontrol.shared.StreamPlan +import com.omixlab.lckcontrol.util.GameInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,7 @@ import javax.inject.Inject @HiltViewModel class ActiveClientsViewModel @Inject constructor( @ApplicationContext private val context: Context, + val gameInfoProvider: GameInfoProvider, ) : ViewModel() { private var service: ILckControlService? = null diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/components/GameInfoRow.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/components/GameInfoRow.kt new file mode 100644 index 0000000..f24275d --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/components/GameInfoRow.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt index 8a803be..6bc1f84 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -34,6 +35,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.omixlab.lckcontrol.shared.StreamPlan +import com.omixlab.lckcontrol.ui.components.GameInfoRow +import com.omixlab.lckcontrol.util.GameInfoProvider @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,6 +47,8 @@ fun DashboardScreen( ) { val plans by viewModel.plans.collectAsStateWithLifecycle() val backendHealthy by viewModel.backendHealthy.collectAsStateWithLifecycle() + val backendVersion by viewModel.backendVersion.collectAsStateWithLifecycle() + val defaultExecutionMode by viewModel.defaultExecutionMode.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -86,11 +91,39 @@ fun DashboardScreen( Column { Text("Backend", style = MaterialTheme.typography.titleSmall) 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 { Spacer(Modifier.height(8.dp)) Text("Stream Plans", style = MaterialTheme.typography.titleMedium) @@ -114,7 +147,11 @@ fun DashboardScreen( } } else { 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 -private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) { +private fun PlanCard( + plan: StreamPlan, + gameInfoProvider: GameInfoProvider, + onClick: () -> Unit, +) { ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -145,6 +186,14 @@ private fun PlanCard(plan: StreamPlan, onClick: () -> Unit) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (plan.gameId.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + GameInfoRow( + packageName = plan.gameId, + gameInfoProvider = gameInfoProvider, + iconSize = 24.dp, + ) + } } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt index 3302530..f49fff1 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt @@ -2,9 +2,11 @@ package com.omixlab.lckcontrol.ui.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.omixlab.lckcontrol.data.local.AppPreferences import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.StreamPlan +import com.omixlab.lckcontrol.util.GameInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +21,8 @@ import javax.inject.Inject class DashboardViewModel @Inject constructor( private val streamPlanRepository: StreamPlanRepository, private val apiService: LckApiService, + private val appPreferences: AppPreferences, + val gameInfoProvider: GameInfoProvider, ) : ViewModel() { val plans: StateFlow> = streamPlanRepository.observePlans() @@ -27,20 +31,32 @@ class DashboardViewModel @Inject constructor( private val _backendHealthy = MutableStateFlow(null) val backendHealthy: StateFlow = _backendHealthy.asStateFlow() + private val _backendVersion = MutableStateFlow(null) + val backendVersion: StateFlow = _backendVersion.asStateFlow() + + private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode()) + val defaultExecutionMode: StateFlow = _defaultExecutionMode.asStateFlow() + init { viewModelScope.launch { try { streamPlanRepository.syncPlans() } catch (_: Exception) {} } viewModelScope.launch { while (true) { - _backendHealthy.value = try { - apiService.healthCheck() - true + try { + val response = apiService.healthCheck() + _backendHealthy.value = true + _backendVersion.value = response.version } catch (_: Exception) { - false + _backendHealthy.value = false } delay(5_000) } } } + + fun setDefaultExecutionMode(mode: String) { + _defaultExecutionMode.value = mode + appPreferences.setDefaultExecutionMode(mode) + } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt index 97100e3..6fd0939 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/AppNavigation.kt @@ -110,7 +110,7 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { } composable(Screen.Dashboard.route) { DashboardScreen( - onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.route) }, + onNavigateToCreatePlan = { navController.navigate(Screen.CreatePlan.createRoute()) }, onNavigateToPlan = { planId -> navController.navigate(Screen.PlanDetail.createRoute(planId)) }, @@ -119,7 +119,14 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { composable(Screen.Accounts.route) { AccountsScreen() } - composable(Screen.CreatePlan.route) { + composable( + route = Screen.CreatePlan.route, + arguments = listOf(navArgument("planId") { + type = NavType.StringType + nullable = true + defaultValue = null + }), + ) { CreatePlanScreen( onPlanCreated = { planId -> navController.navigate(Screen.PlanDetail.createRoute(planId)) { @@ -137,10 +144,17 @@ fun AppNavigation(tokenStore: TokenStore, apiService: LckApiService) { PlanDetailScreen( planId = planId, onBack = { navController.popBackStack() }, + onNavigateToEditPlan = { id -> + navController.navigate(Screen.CreatePlan.createRoute(id)) + }, ) } composable(Screen.ActiveClients.route) { - ActiveClientsScreen() + ActiveClientsScreen( + onNavigateToPlan = { planId -> + navController.navigate(Screen.PlanDetail.createRoute(planId)) + }, + ) } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt index 350b899..b24e6f6 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/navigation/Screen.kt @@ -4,7 +4,10 @@ sealed class Screen(val route: String) { data object Login : Screen("login") data object Dashboard : Screen("dashboard") 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}") { fun createRoute(planId: String) = "plan_detail/$planId" } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt index e0dc308..40812d0 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.omixlab.lckcontrol.shared.LinkedAccount +import com.omixlab.lckcontrol.ui.components.GameInfoRow @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,9 +52,11 @@ fun CreatePlanScreen( onBack: () -> Unit, viewModel: CreatePlanViewModel = hiltViewModel(), ) { + val isEditMode = viewModel.isEditMode val planName by viewModel.planName.collectAsStateWithLifecycle() val executionMode by viewModel.executionMode.collectAsStateWithLifecycle() val gameId by viewModel.gameId.collectAsStateWithLifecycle() + val connectedClients by viewModel.connectedClients.collectAsStateWithLifecycle() val destinations by viewModel.destinations.collectAsStateWithLifecycle() val linkedAccounts by viewModel.linkedAccounts.collectAsStateWithLifecycle() val isCreating by viewModel.isCreating.collectAsStateWithLifecycle() @@ -70,7 +73,7 @@ fun CreatePlanScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Create Stream Plan") }, + title = { Text(if (isEditMode) "Edit Stream Plan" else "Create Stream Plan") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -128,14 +131,21 @@ fun CreatePlanScreen( } item { - OutlinedTextField( - value = gameId, - onValueChange = viewModel::setGameId, - label = { Text("Game Package ID") }, - placeholder = { Text("com.example.game") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) + Text("Game", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + if (gameId.isNotBlank()) { + GameInfoRow( + packageName = gameId, + gameInfoProvider = viewModel.gameInfoProvider, + showPackageName = true, + ) + } else if (connectedClients.isEmpty()) { + Text( + "No game connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } item { @@ -165,11 +175,18 @@ fun CreatePlanScreen( item { Spacer(Modifier.height(16.dp)) Button( - onClick = { viewModel.createPlan(onPlanCreated) }, + onClick = { viewModel.savePlan(onPlanCreated) }, modifier = Modifier.fillMaxWidth(), 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)) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt index de7a0bd..764815e 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/CreatePlanViewModel.kt @@ -1,12 +1,25 @@ 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.viewModelScope import com.omixlab.lckcontrol.data.repository.AccountRepository 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.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.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -27,22 +40,32 @@ data class DestinationInput( @HiltViewModel class CreatePlanViewModel @Inject constructor( - accountRepository: AccountRepository, + @ApplicationContext private val context: Context, + savedStateHandle: SavedStateHandle, + private val accountRepository: AccountRepository, private val streamPlanRepository: StreamPlanRepository, + private val appPreferences: AppPreferences, + val gameInfoProvider: GameInfoProvider, ) : ViewModel() { + val editingPlanId: String? = savedStateHandle.get("planId") + val isEditMode: Boolean = editingPlanId != null + val linkedAccounts: StateFlow> = accountRepository.observeAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _planName = MutableStateFlow("") val planName: StateFlow = _planName.asStateFlow() - private val _executionMode = MutableStateFlow("IN_GAME") + private val _executionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode()) val executionMode: StateFlow = _executionMode.asStateFlow() private val _gameId = MutableStateFlow("") val gameId: StateFlow = _gameId.asStateFlow() + private val _connectedClients = MutableStateFlow>(emptyList()) + val connectedClients: StateFlow> = _connectedClients.asStateFlow() + private val _destinations = MutableStateFlow>(emptyList()) val destinations: StateFlow> = _destinations.asStateFlow() @@ -52,6 +75,84 @@ class CreatePlanViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + private var service: ILckControlService? = null + + private val callback = object : ILckControlCallback.Stub() { + override fun onStreamPlansChanged(plans: List) {} + 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) { _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 dests = _destinations.value @@ -114,10 +215,14 @@ class CreatePlanViewModel @Inject constructor( tags = input.tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, ) } - val plan = streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value) - onCreated(plan.planId) + val plan = if (isEditMode) { + streamPlanRepository.updatePlan(editingPlanId!!, name, streamDests, _executionMode.value, _gameId.value) + } else { + streamPlanRepository.createPlan(name, streamDests, _executionMode.value, _gameId.value) + } + onSaved(plan.planId) } catch (e: Exception) { - _error.value = e.message ?: "Failed to create plan" + _error.value = e.message ?: "Failed to ${if (isEditMode) "update" else "create"} plan" } finally { _isCreating.value = false } @@ -127,4 +232,12 @@ class CreatePlanViewModel @Inject constructor( fun clearError() { _error.value = null } + + override fun onCleared() { + service?.unregisterCallback(callback) + try { + context.unbindService(serviceConnection) + } catch (_: IllegalArgumentException) {} + super.onCleared() + } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt index 7c1a6eb..991851e 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -41,12 +42,14 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.omixlab.lckcontrol.shared.StreamDestination import com.omixlab.lckcontrol.streaming.StreamingState +import com.omixlab.lckcontrol.ui.components.GameInfoRow @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlanDetailScreen( planId: String, onBack: () -> Unit, + onNavigateToEditPlan: (String) -> Unit = {}, viewModel: PlanDetailViewModel = hiltViewModel(), ) { val plan by viewModel.plan.collectAsStateWithLifecycle() @@ -73,6 +76,11 @@ fun PlanDetailScreen( } }, actions = { + if (plan?.status == "DRAFT") { + IconButton(onClick = { onNavigateToEditPlan(planId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } if (plan?.status != "LIVE") { IconButton(onClick = { viewModel.deletePlan(onBack) }) { Icon(Icons.Default.Delete, contentDescription = "Delete") @@ -142,14 +150,18 @@ fun PlanDetailScreen( } } - // Game ID + // Game if (currentPlan.gameId.isNotBlank()) { item { ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text("Game", style = MaterialTheme.typography.labelMedium) Spacer(Modifier.height(4.dp)) - Text(currentPlan.gameId, style = MaterialTheme.typography.bodyMedium) + GameInfoRow( + packageName = currentPlan.gameId, + gameInfoProvider = viewModel.gameInfoProvider, + showPackageName = true, + ) } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt index a439d10..10f509e 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/plans/PlanDetailViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.streaming.StreamingManager +import com.omixlab.lckcontrol.util.GameInfoProvider import com.omixlab.lckcontrol.streaming.StreamingState import com.omixlab.lckcontrol.streaming.StreamingStats import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +23,7 @@ class PlanDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val streamPlanRepository: StreamPlanRepository, private val streamingManager: StreamingManager, + val gameInfoProvider: GameInfoProvider, ) : ViewModel() { private val planId: String = savedStateHandle["planId"] ?: "" diff --git a/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt b/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt new file mode 100644 index 0000000..5377f70 --- /dev/null +++ b/app/src/main/java/com/omixlab/lckcontrol/util/GameInfoProvider.kt @@ -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() + + 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 + } + } + } +}