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:
2026-02-28 22:38:54 +01:00
parent 097cd24ea9
commit c1ff5351b7
19 changed files with 453 additions and 36 deletions

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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)

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
} }
} }
} }

View File

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

View File

@@ -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))
},
)
} }
} }
} }

View File

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

View File

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

View File

@@ -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()
}
} }

View File

@@ -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,
)
} }
} }
} }

View File

@@ -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"] ?: ""

View File

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