Rewrite Streams tab as thumbnail grid with fullscreen player

- StreamsScreen now shows 2-column grid of square thumbnails via LazyVerticalGrid
- Each cell shows like count overlay, LIVE badge, and delete button
- StreamsViewModel uses FeedRepository with filter=mine instead of StreamRepository
- Add StreamPlayerScreen for fullscreen playback reusing FeedCard
- Add StreamPlayer navigation route and wire it in AppNavigation
- Update FeedCard with poster image support
- Add device status and profile enhancements
This commit is contained in:
2026-03-04 21:07:21 +01:00
parent 76dc176fd3
commit 090f4f2a20
10 changed files with 366 additions and 88 deletions

View File

@@ -21,9 +21,47 @@ class AppPreferences @Inject constructor(
get() = prefs.getBoolean(KEY_DARK_THEME, true)
set(value) = prefs.edit().putBoolean(KEY_DARK_THEME, value).apply()
// Paired Quest device info
var pairedDeviceId: String?
get() = prefs.getString(KEY_PAIRED_DEVICE_ID, null)
set(value) = prefs.edit().putString(KEY_PAIRED_DEVICE_ID, value).apply()
var pairedDeviceName: String?
get() = prefs.getString(KEY_PAIRED_DEVICE_NAME, null)
set(value) = prefs.edit().putString(KEY_PAIRED_DEVICE_NAME, value).apply()
var pairedDeviceIp: String?
get() = prefs.getString(KEY_PAIRED_DEVICE_IP, null)
set(value) = prefs.edit().putString(KEY_PAIRED_DEVICE_IP, value).apply()
var pairedDevicePort: Int
get() = prefs.getInt(KEY_PAIRED_DEVICE_PORT, 0)
set(value) = prefs.edit().putInt(KEY_PAIRED_DEVICE_PORT, value).apply()
var pairedDeviceNonce: String?
get() = prefs.getString(KEY_PAIRED_DEVICE_NONCE, null)
set(value) = prefs.edit().putString(KEY_PAIRED_DEVICE_NONCE, value).apply()
fun hasSavedDevice(): Boolean = pairedDeviceId != null && pairedDeviceIp != null
fun clearPairedDevice() {
prefs.edit()
.remove(KEY_PAIRED_DEVICE_ID)
.remove(KEY_PAIRED_DEVICE_NAME)
.remove(KEY_PAIRED_DEVICE_IP)
.remove(KEY_PAIRED_DEVICE_PORT)
.remove(KEY_PAIRED_DEVICE_NONCE)
.apply()
}
companion object {
private const val PREFS_NAME = "lck_control_app_prefs"
private const val KEY_FEED_FILTER = "feed_filter"
private const val KEY_DARK_THEME = "dark_theme"
private const val KEY_PAIRED_DEVICE_ID = "paired_device_id"
private const val KEY_PAIRED_DEVICE_NAME = "paired_device_name"
private const val KEY_PAIRED_DEVICE_IP = "paired_device_ip"
private const val KEY_PAIRED_DEVICE_PORT = "paired_device_port"
private const val KEY_PAIRED_DEVICE_NONCE = "paired_device_nonce"
}
}

View File

@@ -199,6 +199,9 @@ data class FeedItemResponse(
val plan: StreamPlanResponse? = null,
val video: VideoFeedItem? = null,
val previewUrl: String?,
val posterUrl: String? = null,
val thumbnailUrl: String? = null,
val clipUrl: String? = null,
val likeCount: Int,
val commentCount: Int,
val isLiked: Boolean,

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.app.ui.device
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.app.data.local.AppPreferences
import com.omixlab.lckcontrol.app.p2p.discovery.DiscoveredDevice
import com.omixlab.lckcontrol.app.p2p.discovery.LanDiscoveryManager
import com.omixlab.lckcontrol.app.p2p.pairing.DevicePairingManager
@@ -31,15 +32,27 @@ class DeviceViewModel @Inject constructor(
private val pairingManager: DevicePairingManager,
private val webRtcClient: WebRtcClient,
private val remoteControlSession: RemoteControlSession,
private val appPreferences: AppPreferences,
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceUiState())
val uiState: StateFlow<DeviceUiState> = _uiState.asStateFlow()
private var autoConnectAttempted = false
init {
viewModelScope.launch {
discoveryManager.devices.collect { devices ->
_uiState.value = _uiState.value.copy(discoveredDevices = devices)
// Auto-connect: when the saved device appears in discovered list
if (!autoConnectAttempted && appPreferences.hasSavedDevice()) {
val savedId = appPreferences.pairedDeviceId
val match = devices.find { it.name == savedId || it.ip == appPreferences.pairedDeviceIp }
if (match != null) {
autoConnectAttempted = true
pairWithDevice(match)
}
}
}
}
viewModelScope.launch {
@@ -55,6 +68,11 @@ class DeviceViewModel @Inject constructor(
}
}
}
// Auto-start discovery if there's a saved device
if (appPreferences.hasSavedDevice()) {
discoveryManager.startDiscovery()
}
}
fun startDiscovery() {
@@ -76,6 +94,14 @@ class DeviceViewModel @Inject constructor(
showDiscoverySheet = false,
)
discoveryManager.stopDiscovery()
// Save paired device info for auto-connect
appPreferences.pairedDeviceId = paired.deviceId
appPreferences.pairedDeviceName = paired.deviceName
appPreferences.pairedDeviceIp = paired.ip
appPreferences.pairedDevicePort = paired.port
appPreferences.pairedDeviceNonce = paired.nonce
// Initialize WebRTC connection
connectToDevice(paired)
} else {
@@ -92,6 +118,7 @@ class DeviceViewModel @Inject constructor(
fun disconnect() {
webRtcClient.disconnect()
appPreferences.clearPairedDevice()
_uiState.value = _uiState.value.copy(
pairedDevice = null,
connectionState = ConnectionState.DISCONNECTED,

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
@@ -16,6 +17,8 @@ import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
@Composable
@@ -31,14 +34,71 @@ fun FeedCard(
val baseUrl = "https://lck.omigame.dev"
val rawUrl = item.previewUrl ?: item.video?.videoUrl
val videoUrl = rawUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it }
val posterUrl = item.posterUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
// Video player
if (videoUrl != null) {
// Poster image — shows instantly while video loads
if (posterUrl != null) {
var showPoster by remember { mutableStateOf(true) }
if (showPoster) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(posterUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
// Video player on top — hides poster when first frame renders
if (videoUrl != null) {
var player by remember { mutableStateOf<ExoPlayer?>(null) }
DisposableEffect(videoUrl) {
val exoPlayer = ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videoUrl))
repeatMode = Player.REPEAT_MODE_ONE
addListener(object : Player.Listener {
override fun onRenderedFirstFrame() {
showPoster = false
}
})
prepare()
playWhenReady = true
}
player = exoPlayer
onDispose {
exoPlayer.release()
player = null
}
}
player?.let { exo ->
AndroidView(
factory = {
PlayerView(it).apply {
this.player = exo
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
} else if (videoUrl != null) {
// No poster — video only (original behavior)
var player by remember { mutableStateOf<ExoPlayer?>(null) }
DisposableEffect(videoUrl) {

View File

@@ -27,6 +27,7 @@ import com.omixlab.lckcontrol.app.ui.profile.FollowListScreen
import com.omixlab.lckcontrol.app.ui.profile.ProfileScreen
import com.omixlab.lckcontrol.app.ui.profile.UserProfileScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamDetailScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamPlayerScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamsScreen
@Composable
@@ -99,8 +100,8 @@ fun AppNavigation(navViewModel: NavViewModel = hiltViewModel()) {
}
composable(Screen.Streams.route) {
StreamsScreen(
onNavigateToDetail = { planId ->
navController.navigate(Screen.StreamDetail.createRoute(planId))
onNavigateToPlayer = { planId ->
navController.navigate(Screen.StreamPlayer.createRoute(planId))
},
)
}
@@ -110,6 +111,16 @@ fun AppNavigation(navViewModel: NavViewModel = hiltViewModel()) {
) {
StreamDetailScreen(onNavigateBack = { navController.popBackStack() })
}
composable(
Screen.StreamPlayer.route,
arguments = listOf(navArgument("planId") { type = NavType.StringType }),
) { backStackEntry ->
val planId = backStackEntry.arguments?.getString("planId") ?: return@composable
StreamPlayerScreen(
planId = planId,
onNavigateBack = { navController.popBackStack() },
)
}
composable(Screen.Device.route) {
DeviceScreen(
onNavigateToCamera = { navController.navigate(Screen.CameraView.route) },

View File

@@ -7,6 +7,9 @@ sealed class Screen(val route: String) {
data object StreamDetail : Screen("streams/{planId}") {
fun createRoute(planId: String) = "streams/$planId"
}
data object StreamPlayer : Screen("streams/play/{planId}") {
fun createRoute(planId: String) = "streams/play/$planId"
}
data object Device : Screen("device")
data object CameraView : Screen("device/camera")
data object FileTransfer : Screen("device/files")

View File

@@ -1,11 +1,11 @@
package com.omixlab.lckcontrol.app.ui.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -95,20 +95,15 @@ fun ProfileScreen(
ListItem(
headlineContent = { Text("Followers & Following") },
leadingContent = { Icon(Icons.Default.People, null) },
modifier = Modifier.let { mod ->
mod
modifier = Modifier.clickable {
onNavigateToFollows(profile.id, "followers")
},
)
ListItem(
headlineContent = { Text("Linked Accounts") },
leadingContent = { Icon(Icons.Default.Link, null) },
modifier = Modifier,
)
ListItem(
headlineContent = { Text("Settings") },
leadingContent = { Icon(Icons.Default.Settings, null) },
modifier = Modifier.clickable { onNavigateToAccounts() },
)
Spacer(modifier = Modifier.weight(1f))

View File

@@ -0,0 +1,54 @@
package com.omixlab.lckcontrol.app.ui.streams
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.app.ui.feed.FeedCard
@Composable
fun StreamPlayerScreen(
planId: String,
onNavigateBack: () -> Unit,
viewModel: StreamsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val item = uiState.streams.find { it.plan?.id == planId }
Box(modifier = Modifier.fillMaxSize()) {
if (item != null) {
FeedCard(
item = item,
pageIndex = 0,
onLikeClick = {},
onFollowClick = {},
onUserClick = {},
onCommentClick = {},
)
}
IconButton(
onClick = onNavigateBack,
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f),
contentColor = Color.White,
),
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(12.dp),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
}

View File

@@ -1,18 +1,30 @@
package com.omixlab.lckcontrol.app.ui.streams
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
import com.omixlab.lckcontrol.app.ui.components.ErrorState
import com.omixlab.lckcontrol.app.ui.components.LiveBadge
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
@@ -20,75 +32,148 @@ import com.omixlab.lckcontrol.app.ui.components.PullToRefreshLayout
@Composable
fun StreamsScreen(
onNavigateToDetail: (String) -> Unit,
onNavigateToPlayer: (String) -> Unit,
viewModel: StreamsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val baseUrl = "https://lck.omigame.dev"
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = { /* TODO: create stream plan */ }) {
Icon(Icons.Default.Add, contentDescription = "New Stream Plan")
PullToRefreshLayout(
isRefreshing = uiState.isLoading,
onRefresh = { viewModel.loadStreams() },
modifier = Modifier.fillMaxSize(),
) {
when {
uiState.isLoading && uiState.streams.isEmpty() -> {
LoadingIndicator(modifier = Modifier.fillMaxSize())
}
},
) { padding ->
PullToRefreshLayout(
isRefreshing = uiState.isLoading,
onRefresh = { viewModel.loadPlans() },
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
uiState.isLoading && uiState.plans.isEmpty() -> {
LoadingIndicator(modifier = Modifier.fillMaxSize())
}
uiState.error != null && uiState.plans.isEmpty() -> {
ErrorState(
message = uiState.error!!,
onRetry = { viewModel.loadPlans() },
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(uiState.plans) { plan ->
Card(
onClick = { onNavigateToDetail(plan.id) },
modifier = Modifier.fillMaxWidth(),
) {
ListItem(
headlineContent = { Text(plan.name) },
supportingContent = {
Text("${plan.destinations.size} destination(s)")
},
trailingContent = {
Row {
if (plan.status == "LIVE") {
LiveBadge()
} else {
Text(
text = plan.status,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = { viewModel.deletePlan(plan.id) },
) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
},
)
}
}
uiState.error != null && uiState.streams.isEmpty() -> {
ErrorState(
message = uiState.error!!,
onRetry = { viewModel.loadStreams() },
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(uiState.streams) { item ->
StreamThumbnailCell(
item = item,
baseUrl = baseUrl,
onClick = {
item.plan?.id?.let { onNavigateToPlayer(it) }
},
onDelete = {
item.plan?.id?.let { viewModel.deleteStream(it) }
},
)
}
}
}
}
}
}
@Composable
private fun StreamThumbnailCell(
item: FeedItemResponse,
baseUrl: String,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
val thumbUrl = item.thumbnailUrl?.let {
if (it.startsWith("/")) "$baseUrl$it" else it
} ?: item.plan?.id?.let { "$baseUrl/media/thumbnails/${it}_thumb.jpg" }
Card(
onClick = onClick,
shape = RoundedCornerShape(8.dp),
) {
Box(
modifier = Modifier.aspectRatio(1f),
) {
// Thumbnail image
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(thumbUrl)
.build(),
contentDescription = item.plan?.name,
contentScale = ContentScale.Crop,
loading = { ThumbnailPlaceholder() },
error = { ThumbnailPlaceholder() },
modifier = Modifier.fillMaxSize(),
)
// Delete button — top-right
IconButton(
onClick = onDelete,
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f),
contentColor = Color.White,
),
modifier = Modifier
.align(Alignment.TopEnd)
.size(32.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
)
}
// Bottom overlay bar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
// Like count
Icon(
Icons.Default.Favorite,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${item.likeCount}",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.weight(1f))
// LIVE badge
if (item.plan?.status == "LIVE") {
LiveBadge()
}
}
}
}
}
@Composable
private fun ThumbnailPlaceholder() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Videocam,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp),
)
}
}

View File

@@ -2,7 +2,8 @@ package com.omixlab.lckcontrol.app.ui.streams
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.app.data.remote.StreamPlanResponse
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
import com.omixlab.lckcontrol.app.data.repository.FeedRepository
import com.omixlab.lckcontrol.app.data.repository.StreamRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,13 +13,14 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
data class StreamsUiState(
val plans: List<StreamPlanResponse> = emptyList(),
val streams: List<FeedItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class StreamsViewModel @Inject constructor(
private val feedRepository: FeedRepository,
private val streamRepository: StreamRepository,
) : ViewModel() {
@@ -26,29 +28,29 @@ class StreamsViewModel @Inject constructor(
val uiState: StateFlow<StreamsUiState> = _uiState.asStateFlow()
init {
loadPlans()
loadStreams()
}
fun loadPlans() {
fun loadStreams() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val plans = streamRepository.getStreamPlans()
_uiState.value = _uiState.value.copy(plans = plans, isLoading = false)
val (items, _) = feedRepository.getFeed(filter = "mine")
_uiState.value = _uiState.value.copy(streams = items, isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load stream plans",
error = e.message ?: "Failed to load streams",
)
}
}
}
fun deletePlan(id: String) {
fun deleteStream(planId: String) {
viewModelScope.launch {
try {
streamRepository.deleteStreamPlan(id)
loadPlans()
streamRepository.deleteStreamPlan(planId)
loadStreams()
} catch (_: Exception) {}
}
}