Dashboard settings, game icons, default execution mode, fix native lib linking
- Add AppPreferences for persisted default streaming mode (IN_GAME/APP_STREAMING) - Add GameInfoProvider to resolve package names to icons via PackageManager - Add GameInfoRow composable used across dashboard, plans, and clients screens - Show backend version in dashboard status card - Default execution mode toggle on dashboard, picked up by new plans - Plan edit mode with full CRUD support - Fix CMake IMPORTED_NO_SONAME to prevent absolute Windows paths in DT_NEEDED - Catch Throwable (not just Exception) for UnsatisfiedLinkError in streaming start
This commit is contained in:
@@ -21,19 +21,24 @@ target_include_directories(lck_streaming PRIVATE
|
||||
)
|
||||
|
||||
# Import pre-built librtmp from jniLibs
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.omixlab.lckcontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppPreferences @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("lck_control_app_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
fun getDefaultExecutionMode(): String =
|
||||
prefs.getString(KEY_DEFAULT_EXECUTION_MODE, "IN_GAME") ?: "IN_GAME"
|
||||
|
||||
fun setDefaultExecutionMode(mode: String) {
|
||||
prefs.edit().putString(KEY_DEFAULT_EXECUTION_MODE, mode).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_DEFAULT_EXECUTION_MODE = "default_execution_mode"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.squareup.moshi.JsonClass
|
||||
data class HealthResponse(
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.omixlab.lckcontrol.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.omixlab.lckcontrol.util.GameInfoProvider
|
||||
|
||||
@Composable
|
||||
fun GameInfoRow(
|
||||
packageName: String,
|
||||
gameInfoProvider: GameInfoProvider,
|
||||
iconSize: Dp = 24.dp,
|
||||
showPackageName: Boolean = false,
|
||||
) {
|
||||
val gameInfo = remember(packageName) { gameInfoProvider.resolve(packageName) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (gameInfo != null) {
|
||||
Image(
|
||||
bitmap = gameInfo.icon,
|
||||
contentDescription = gameInfo.label,
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(gameInfo.label, style = MaterialTheme.typography.bodyMedium)
|
||||
if (showPackageName) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"(${gameInfo.packageName})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.SportsEsports,
|
||||
contentDescription = "Game",
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
packageName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] ?: ""
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.omixlab.lckcontrol.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class GameInfo(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: ImageBitmap,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class GameInfoProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val cache = HashMap<String, GameInfo?>()
|
||||
|
||||
fun resolve(packageName: String): GameInfo? {
|
||||
if (packageName.isBlank()) return null
|
||||
return cache.getOrPut(packageName) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
val label = pm.getApplicationLabel(appInfo).toString()
|
||||
val icon = pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap()
|
||||
GameInfo(packageName, label, icon)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user