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

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(
val status: String,
val timestamp: String,
val version: String? = null,
)
// ── Auth ─────────────────────────────────────────────────
@@ -72,6 +73,14 @@ data class CreateStreamPlanRequest(
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)
data class CreateDestinationRequest(
val linkedAccountId: String,

View File

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

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.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<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 */
suspend fun preparePlan(planId: String): PrepareResponse {
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.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 {

View File

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

View File

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

View File

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

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

View File

@@ -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<List<StreamPlan>> = streamPlanRepository.observePlans()
@@ -27,20 +31,32 @@ class DashboardViewModel @Inject constructor(
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
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 {
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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<String>("planId")
val isEditMode: Boolean = editingPlanId != null
val linkedAccounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _planName = MutableStateFlow("")
val planName: StateFlow<String> = _planName.asStateFlow()
private val _executionMode = MutableStateFlow("IN_GAME")
private val _executionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
val executionMode: StateFlow<String> = _executionMode.asStateFlow()
private val _gameId = MutableStateFlow("")
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())
val destinations: StateFlow<List<DestinationInput>> = _destinations.asStateFlow()
@@ -52,6 +75,84 @@ class CreatePlanViewModel @Inject constructor(
private val _error = MutableStateFlow<String?>(null)
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) {
_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()
}
}

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

View File

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

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