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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 +34,73 @@ 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
|
||||
// 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) {
|
||||
val exoPlayer = ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(videoUrl))
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,68 +32,44 @@ 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")
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
PullToRefreshLayout(
|
||||
isRefreshing = uiState.isLoading,
|
||||
onRefresh = { viewModel.loadPlans() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
onRefresh = { viewModel.loadStreams() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.plans.isEmpty() -> {
|
||||
uiState.isLoading && uiState.streams.isEmpty() -> {
|
||||
LoadingIndicator(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
uiState.error != null && uiState.plans.isEmpty() -> {
|
||||
uiState.error != null && uiState.streams.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = uiState.error!!,
|
||||
onRetry = { viewModel.loadPlans() },
|
||||
onRetry = { viewModel.loadStreams() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.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)")
|
||||
items(uiState.streams) { item ->
|
||||
StreamThumbnailCell(
|
||||
item = item,
|
||||
baseUrl = baseUrl,
|
||||
onClick = {
|
||||
item.plan?.id?.let { onNavigateToPlayer(it) }
|
||||
},
|
||||
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")
|
||||
}
|
||||
}
|
||||
onDelete = {
|
||||
item.plan?.id?.let { viewModel.deleteStream(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -89,6 +77,103 @@ fun StreamsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user