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) get() = prefs.getBoolean(KEY_DARK_THEME, true)
set(value) = prefs.edit().putBoolean(KEY_DARK_THEME, value).apply() 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 { companion object {
private const val PREFS_NAME = "lck_control_app_prefs" private const val PREFS_NAME = "lck_control_app_prefs"
private const val KEY_FEED_FILTER = "feed_filter" private const val KEY_FEED_FILTER = "feed_filter"
private const val KEY_DARK_THEME = "dark_theme" 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 plan: StreamPlanResponse? = null,
val video: VideoFeedItem? = null, val video: VideoFeedItem? = null,
val previewUrl: String?, val previewUrl: String?,
val posterUrl: String? = null,
val thumbnailUrl: String? = null,
val clipUrl: String? = null,
val likeCount: Int, val likeCount: Int,
val commentCount: Int, val commentCount: Int,
val isLiked: Boolean, val isLiked: Boolean,

View File

@@ -2,6 +2,7 @@ package com.omixlab.lckcontrol.app.ui.device
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.DiscoveredDevice
import com.omixlab.lckcontrol.app.p2p.discovery.LanDiscoveryManager import com.omixlab.lckcontrol.app.p2p.discovery.LanDiscoveryManager
import com.omixlab.lckcontrol.app.p2p.pairing.DevicePairingManager import com.omixlab.lckcontrol.app.p2p.pairing.DevicePairingManager
@@ -31,15 +32,27 @@ class DeviceViewModel @Inject constructor(
private val pairingManager: DevicePairingManager, private val pairingManager: DevicePairingManager,
private val webRtcClient: WebRtcClient, private val webRtcClient: WebRtcClient,
private val remoteControlSession: RemoteControlSession, private val remoteControlSession: RemoteControlSession,
private val appPreferences: AppPreferences,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceUiState()) private val _uiState = MutableStateFlow(DeviceUiState())
val uiState: StateFlow<DeviceUiState> = _uiState.asStateFlow() val uiState: StateFlow<DeviceUiState> = _uiState.asStateFlow()
private var autoConnectAttempted = false
init { init {
viewModelScope.launch { viewModelScope.launch {
discoveryManager.devices.collect { devices -> discoveryManager.devices.collect { devices ->
_uiState.value = _uiState.value.copy(discoveredDevices = 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 { 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() { fun startDiscovery() {
@@ -76,6 +94,14 @@ class DeviceViewModel @Inject constructor(
showDiscoverySheet = false, showDiscoverySheet = false,
) )
discoveryManager.stopDiscovery() 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 // Initialize WebRTC connection
connectToDevice(paired) connectToDevice(paired)
} else { } else {
@@ -92,6 +118,7 @@ class DeviceViewModel @Inject constructor(
fun disconnect() { fun disconnect() {
webRtcClient.disconnect() webRtcClient.disconnect()
appPreferences.clearPairedDevice()
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
pairedDevice = null, pairedDevice = null,
connectionState = ConnectionState.DISCONNECTED, connectionState = ConnectionState.DISCONNECTED,

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -16,6 +17,8 @@ import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse import com.omixlab.lckcontrol.app.data.remote.FeedItemResponse
@Composable @Composable
@@ -31,16 +34,73 @@ fun FeedCard(
val baseUrl = "https://lck.omigame.dev" val baseUrl = "https://lck.omigame.dev"
val rawUrl = item.previewUrl ?: item.video?.videoUrl val rawUrl = item.previewUrl ?: item.video?.videoUrl
val videoUrl = rawUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it } val videoUrl = rawUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it }
val posterUrl = item.posterUrl?.let { if (it.startsWith("/")) "$baseUrl$it" else it }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black), .background(Color.Black),
) { ) {
// Video player // 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) { if (videoUrl != null) {
var player by remember { mutableStateOf<ExoPlayer?>(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) { DisposableEffect(videoUrl) {
val exoPlayer = ExoPlayer.Builder(context).build().apply { val exoPlayer = ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videoUrl)) setMediaItem(MediaItem.fromUri(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.ProfileScreen
import com.omixlab.lckcontrol.app.ui.profile.UserProfileScreen import com.omixlab.lckcontrol.app.ui.profile.UserProfileScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamDetailScreen import com.omixlab.lckcontrol.app.ui.streams.StreamDetailScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamPlayerScreen
import com.omixlab.lckcontrol.app.ui.streams.StreamsScreen import com.omixlab.lckcontrol.app.ui.streams.StreamsScreen
@Composable @Composable
@@ -99,8 +100,8 @@ fun AppNavigation(navViewModel: NavViewModel = hiltViewModel()) {
} }
composable(Screen.Streams.route) { composable(Screen.Streams.route) {
StreamsScreen( StreamsScreen(
onNavigateToDetail = { planId -> onNavigateToPlayer = { planId ->
navController.navigate(Screen.StreamDetail.createRoute(planId)) navController.navigate(Screen.StreamPlayer.createRoute(planId))
}, },
) )
} }
@@ -110,6 +111,16 @@ fun AppNavigation(navViewModel: NavViewModel = hiltViewModel()) {
) { ) {
StreamDetailScreen(onNavigateBack = { navController.popBackStack() }) 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) { composable(Screen.Device.route) {
DeviceScreen( DeviceScreen(
onNavigateToCamera = { navController.navigate(Screen.CameraView.route) }, onNavigateToCamera = { navController.navigate(Screen.CameraView.route) },

View File

@@ -7,6 +7,9 @@ sealed class Screen(val route: String) {
data object StreamDetail : Screen("streams/{planId}") { data object StreamDetail : Screen("streams/{planId}") {
fun createRoute(planId: String) = "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 Device : Screen("device")
data object CameraView : Screen("device/camera") data object CameraView : Screen("device/camera")
data object FileTransfer : Screen("device/files") data object FileTransfer : Screen("device/files")

View File

@@ -1,11 +1,11 @@
package com.omixlab.lckcontrol.app.ui.profile package com.omixlab.lckcontrol.app.ui.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -95,20 +95,15 @@ fun ProfileScreen(
ListItem( ListItem(
headlineContent = { Text("Followers & Following") }, headlineContent = { Text("Followers & Following") },
leadingContent = { Icon(Icons.Default.People, null) }, leadingContent = { Icon(Icons.Default.People, null) },
modifier = Modifier.let { mod -> modifier = Modifier.clickable {
mod onNavigateToFollows(profile.id, "followers")
}, },
) )
ListItem( ListItem(
headlineContent = { Text("Linked Accounts") }, headlineContent = { Text("Linked Accounts") },
leadingContent = { Icon(Icons.Default.Link, null) }, leadingContent = { Icon(Icons.Default.Link, null) },
modifier = Modifier, modifier = Modifier.clickable { onNavigateToAccounts() },
)
ListItem(
headlineContent = { Text("Settings") },
leadingContent = { Icon(Icons.Default.Settings, null) },
) )
Spacer(modifier = Modifier.weight(1f)) 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 package com.omixlab.lckcontrol.app.ui.streams
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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 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.ErrorState
import com.omixlab.lckcontrol.app.ui.components.LiveBadge import com.omixlab.lckcontrol.app.ui.components.LiveBadge
import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator import com.omixlab.lckcontrol.app.ui.components.LoadingIndicator
@@ -20,75 +32,148 @@ import com.omixlab.lckcontrol.app.ui.components.PullToRefreshLayout
@Composable @Composable
fun StreamsScreen( fun StreamsScreen(
onNavigateToDetail: (String) -> Unit, onNavigateToPlayer: (String) -> Unit,
viewModel: StreamsViewModel = hiltViewModel(), viewModel: StreamsViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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")
}
},
) { padding ->
PullToRefreshLayout( PullToRefreshLayout(
isRefreshing = uiState.isLoading, isRefreshing = uiState.isLoading,
onRefresh = { viewModel.loadPlans() }, onRefresh = { viewModel.loadStreams() },
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(padding),
) { ) {
when { when {
uiState.isLoading && uiState.plans.isEmpty() -> { uiState.isLoading && uiState.streams.isEmpty() -> {
LoadingIndicator(modifier = Modifier.fillMaxSize()) LoadingIndicator(modifier = Modifier.fillMaxSize())
} }
uiState.error != null && uiState.plans.isEmpty() -> { uiState.error != null && uiState.streams.isEmpty() -> {
ErrorState( ErrorState(
message = uiState.error!!, message = uiState.error!!,
onRetry = { viewModel.loadPlans() }, onRetry = { viewModel.loadStreams() },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
else -> { else -> {
LazyColumn( LazyVerticalGrid(
contentPadding = PaddingValues(16.dp), columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(uiState.plans) { plan -> 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( Card(
onClick = { onNavigateToDetail(plan.id) }, onClick = onClick,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
) { ) {
ListItem( Box(
headlineContent = { Text(plan.name) }, modifier = Modifier.aspectRatio(1f),
supportingContent = { ) {
Text("${plan.destinations.size} destination(s)") // Thumbnail image
}, SubcomposeAsyncImage(
trailingContent = { model = ImageRequest.Builder(LocalContext.current)
Row { .data(thumbUrl)
if (plan.status == "LIVE") { .build(),
LiveBadge() contentDescription = item.plan?.name,
} else { contentScale = ContentScale.Crop,
Text( loading = { ThumbnailPlaceholder() },
text = plan.status, error = { ThumbnailPlaceholder() },
style = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
// Delete button — top-right
IconButton( IconButton(
onClick = { viewModel.deletePlan(plan.id) }, 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.Delete, contentDescription = "Delete") 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.ViewModel
import androidx.lifecycle.viewModelScope 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 com.omixlab.lckcontrol.app.data.repository.StreamRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,13 +13,14 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
data class StreamsUiState( data class StreamsUiState(
val plans: List<StreamPlanResponse> = emptyList(), val streams: List<FeedItemResponse> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
) )
@HiltViewModel @HiltViewModel
class StreamsViewModel @Inject constructor( class StreamsViewModel @Inject constructor(
private val feedRepository: FeedRepository,
private val streamRepository: StreamRepository, private val streamRepository: StreamRepository,
) : ViewModel() { ) : ViewModel() {
@@ -26,29 +28,29 @@ class StreamsViewModel @Inject constructor(
val uiState: StateFlow<StreamsUiState> = _uiState.asStateFlow() val uiState: StateFlow<StreamsUiState> = _uiState.asStateFlow()
init { init {
loadPlans() loadStreams()
} }
fun loadPlans() { fun loadStreams() {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null) _uiState.value = _uiState.value.copy(isLoading = true, error = null)
try { try {
val plans = streamRepository.getStreamPlans() val (items, _) = feedRepository.getFeed(filter = "mine")
_uiState.value = _uiState.value.copy(plans = plans, isLoading = false) _uiState.value = _uiState.value.copy(streams = items, isLoading = false)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, 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 { viewModelScope.launch {
try { try {
streamRepository.deleteStreamPlan(id) streamRepository.deleteStreamPlan(planId)
loadPlans() loadStreams()
} catch (_: Exception) {} } catch (_: Exception) {}
} }
} }