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)
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
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,68 +32,44 @@ 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 ->
|
||||||
Card(
|
StreamThumbnailCell(
|
||||||
onClick = { onNavigateToDetail(plan.id) },
|
item = item,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
baseUrl = baseUrl,
|
||||||
) {
|
onClick = {
|
||||||
ListItem(
|
item.plan?.id?.let { onNavigateToPlayer(it) }
|
||||||
headlineContent = { Text(plan.name) },
|
|
||||||
supportingContent = {
|
|
||||||
Text("${plan.destinations.size} destination(s)")
|
|
||||||
},
|
},
|
||||||
trailingContent = {
|
onDelete = {
|
||||||
Row {
|
item.plan?.id?.let { viewModel.deleteStream(it) }
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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.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) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user