feat: tv UI work.

This commit is contained in:
oxy-macmini
2025-03-29 19:48:41 +08:00
parent ad06325814
commit 954ae6357d
27 changed files with 234 additions and 406 deletions

View File

@ -14,9 +14,8 @@ import androidx.navigation.navArgument
import com.m3u.data.service.MediaCommand
import com.m3u.tv.screens.Screens
import com.m3u.tv.screens.dashboard.DashboardScreen
import com.m3u.tv.screens.player.PlayerScreen
import com.m3u.tv.screens.playlist.ChannelScreen
import com.m3u.tv.screens.playlist.ChannelDetailScreen
import com.m3u.tv.screens.videoPlayer.VideoPlayerScreen
import com.m3u.tv.utils.LocalHelper
import kotlinx.coroutines.launch
@ -37,23 +36,14 @@ fun App(
route = Screens.Channel(),
arguments = listOf(
navArgument(ChannelScreen.ChannelIdBundleKey) {
type = NavType.StringType
type = NavType.IntType
}
)
) {
ChannelDetailScreen(
goToChannelPlayer = {
ChannelScreen(
navigateToChannelPlayer = {
navController.navigate(Screens.VideoPlayer())
},
refreshScreenWithNewChannel = { channel ->
navController.navigate(
Screens.Channel.withArgs(channel.id)
) {
popUpTo(Screens.Channel()) {
inclusive = true
}
}
},
onBackPressed = {
if (navController.navigateUp()) {
isComingBackFromDifferentScreen = true
@ -82,7 +72,7 @@ fun App(
)
}
composable(route = Screens.VideoPlayer()) {
VideoPlayerScreen(
PlayerScreen(
onBackPressed = {
if (navController.navigateUp()) {
isComingBackFromDifferentScreen = true

View File

@ -1,24 +1,38 @@
package com.m3u.tv.screens
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.graphics.vector.ImageVector
import com.m3u.business.playlist.PlaylistNavigation
import com.m3u.i18n.R
import com.m3u.tv.screens.playlist.ChannelScreen
import com.m3u.tv.screens.videoPlayer.VideoPlayerScreen
import com.m3u.tv.screens.player.VideoPlayerScreen
enum class Screens(
private val args: List<String>? = null,
val isTabItem: Boolean = false,
val tabIcon: ImageVector? = null
val tabIcon: ImageVector? = null,
@StringRes val title: Int? = null
) {
Profile,
Home(isTabItem = true),
Channels(isTabItem = true),
Shows(isTabItem = true),
Search(isTabItem = true, tabIcon = Icons.Default.Search),
Channel(listOf(ChannelScreen.ChannelIdBundleKey)),
Home(
title = R.string.ui_destination_foryou,
isTabItem = true
),
Playlist(
title = R.string.ui_destination_playlist,
isTabItem = true,
args = listOf(PlaylistNavigation.TYPE_URL)
),
Search(
isTabItem = true,
tabIcon = Icons.Default.Search
),
Channel(args = listOf(ChannelScreen.ChannelIdBundleKey)),
Dashboard,
VideoPlayer(listOf(VideoPlayerScreen.ChannelIdBundleKey));
VideoPlayer(args = listOf(VideoPlayerScreen.ChannelIdBundleKey));
operator fun invoke(): String {
val argList = StringBuilder()
@ -30,7 +44,18 @@ enum class Screens(
fun withArgs(vararg args: Any): String {
val destination = StringBuilder()
args.forEach { arg -> destination.append("/$arg") }
args.forEach { arg ->
val path = when (arg) {
is String -> Uri.encode(arg)
else -> arg
}
destination.append("/$path")
}
return name + destination
}
companion object {
val destinations = Screens.entries.map { it() }
val tabDestinations = Screens.entries.filter { it.isTabItem }.map { it() }
}
}

View File

@ -46,7 +46,6 @@ import com.m3u.tv.screens.home.HomeScreen
import com.m3u.tv.screens.playlist.PlaylistScreen
import com.m3u.tv.screens.profile.ProfileScreen
import com.m3u.tv.screens.search.SearchScreen
import com.m3u.tv.screens.shows.ShowsScreen
import com.m3u.tv.utils.Padding
val ParentPadding = PaddingValues(vertical = 16.dp, horizontal = 58.dp)
@ -65,7 +64,7 @@ fun rememberChildPadding(direction: LayoutDirection = LocalLayoutDirection.curre
@Composable
fun DashboardScreen(
openChannelScreen: (channelId: String) -> Unit,
openChannelScreen: (channelId: Int) -> Unit,
openVideoPlayer: (Channel) -> Unit,
isComingBackFromDifferentScreen: Boolean,
resetIsComingBackFromDifferentScreen: () -> Unit,
@ -81,7 +80,8 @@ fun DashboardScreen(
var currentDestination: String? by remember { mutableStateOf(null) }
val currentTopBarSelectedTabIndex by remember(currentDestination) {
derivedStateOf {
currentDestination?.let { TopBarTabs.indexOf(Screens.valueOf(it)) } ?: 0
Screens.tabDestinations.indexOfFirst { it == currentDestination }.takeIf { it != -1 }
?: 0
}
}
@ -98,10 +98,6 @@ fun DashboardScreen(
}
BackPressHandledArea(
// 1. On user's first back press, bring focus to the current selected tab, if TopBar is not
// visible, first make it visible, then focus the selected tab
// 2. On second back press, bring focus back to the first displayed tab
// 3. On third back press, exit the app
onBackPressed = {
if (!isTopBarVisible) {
isTopBarVisible = true
@ -203,7 +199,7 @@ private fun BackPressHandledArea(
@Composable
private fun Body(
openChannelScreen: (channelId: String) -> Unit,
openChannelScreen: (channelId: Int) -> Unit,
openVideoPlayer: (Channel) -> Unit,
updateTopBarVisibility: (Boolean) -> Unit,
modifier: Modifier = Modifier,
@ -220,35 +216,26 @@ private fun Body(
}
composable(Screens.Home()) {
HomeScreen(
onChannelClick = { selectedChannel ->
},
goToChannel = { playlistUrl ->
navigateToPlaylist = { playlistUrl ->
navController.navigate(
Screens.Channels()
Screens.Playlist.withArgs(playlistUrl)
)
},
goToVideoPlayer = openVideoPlayer,
navigateToChannel = openVideoPlayer,
onScroll = updateTopBarVisibility,
isTopBarVisible = isTopBarVisible
)
}
composable(Screens.Channels()) {
composable(Screens.Playlist()) {
PlaylistScreen(
onChannelClick = { channel -> openChannelScreen(channel.id.toString()) },
onScroll = updateTopBarVisibility,
isTopBarVisible = isTopBarVisible
)
}
composable(Screens.Shows()) {
ShowsScreen(
onTVShowClick = { channel -> openChannelScreen(channel.id.toString()) },
onChannelClick = { channel -> openChannelScreen(channel.id) },
onScroll = updateTopBarVisibility,
isTopBarVisible = isTopBarVisible
)
}
composable(Screens.Search()) {
SearchScreen(
onChannelClick = { channel -> openChannelScreen(channel.id.toString()) },
onChannelClick = { channel -> openChannelScreen(channel.id) },
onScroll = updateTopBarVisibility
)
}

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@ -121,12 +122,12 @@ fun DashboardTopBar(
contentDescription = "DashboardSearchButton",
tint = LocalContentColor.current
)
} else {
} else if (screen.title != null) {
Text(
modifier = Modifier
.occupyScreenSize()
.padding(horizontal = 16.dp),
text = screen(),
text = stringResource(screen.title),
style = MaterialTheme.typography.titleSmall.copy(
color = LocalContentColor.current
)

View File

@ -17,9 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -29,7 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.CompactCard
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -39,14 +37,13 @@ import com.m3u.core.foundation.components.CircularProgressIndicator
import com.m3u.core.wrapper.Resource
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.PlaylistWithCount
import com.m3u.tv.common.ChannelsRow
import com.m3u.tv.screens.dashboard.rememberChildPadding
import com.m3u.tv.theme.LexendExa
@Composable
fun HomeScreen(
onChannelClick: (channel: Channel) -> Unit,
goToChannel: (playlistUrl: String) -> Unit,
goToVideoPlayer: (channel: Channel) -> Unit,
navigateToPlaylist: (playlistUrl: String) -> Unit,
navigateToChannel: (channel: Channel) -> Unit,
onScroll: (isTopBarVisible: Boolean) -> Unit,
isTopBarVisible: Boolean,
viewModel: ForyouViewModel = hiltViewModel(),
@ -64,13 +61,9 @@ fun HomeScreen(
Catalog(
playlists = playlists.data,
specs = specs,
trendingChannels = emptyList(),
top10Channels = emptyList(),
nowPlayingChannels = emptyList(),
onChannelClick = onChannelClick,
onScroll = onScroll,
goToChannel = goToChannel,
goToVideoPlayer = goToVideoPlayer,
navigateToPlaylist = navigateToPlaylist,
navigateToChannel = navigateToChannel,
isTopBarVisible = isTopBarVisible
)
}
@ -87,20 +80,15 @@ fun HomeScreen(
private fun Catalog(
playlists: List<PlaylistWithCount>,
specs: List<Recommend.Spec>,
trendingChannels: List<Channel>,
top10Channels: List<Channel>,
nowPlayingChannels: List<Channel>,
onChannelClick: (channel: Channel) -> Unit,
onScroll: (isTopBarVisible: Boolean) -> Unit,
goToChannel: (playlistUrl: String) -> Unit,
goToVideoPlayer: (channel: Channel) -> Unit,
navigateToPlaylist: (playlistUrl: String) -> Unit,
navigateToChannel: (channel: Channel) -> Unit,
modifier: Modifier = Modifier,
isTopBarVisible: Boolean = true,
) {
val lazyListState = rememberLazyListState()
val childPadding = rememberChildPadding()
var immersiveListHasFocus by remember { mutableStateOf(false) }
val shouldShowTopBar by remember {
derivedStateOf {
@ -119,31 +107,33 @@ private fun Catalog(
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = 108.dp),
// Setting overscan margin to bottom to ensure the last row's visibility
modifier = modifier
) {
item(contentType = "FeaturedChannelsCarousel") {
FeaturedSpecsCarousel(
specs = specs,
padding = childPadding,
onClickSpec = { spec ->
when (spec) {
is Recommend.UnseenSpec -> {
goToVideoPlayer(spec.channel)
if (specs.isNotEmpty()) {
item(contentType = "FeaturedChannelsCarousel") {
FeaturedSpecsCarousel(
specs = specs,
padding = childPadding,
onClickSpec = { spec ->
when (spec) {
is Recommend.UnseenSpec -> {
navigateToChannel(spec.channel)
}
is Recommend.DiscoverSpec -> TODO()
is Recommend.NewRelease -> TODO()
}
is Recommend.DiscoverSpec -> TODO()
is Recommend.NewRelease -> TODO()
}
},
modifier = Modifier
.fillMaxWidth()
.height(324.dp)
/*
Setting height for the FeaturedChannelCarousel to keep it rendered with same height,
regardless of the top bar's visibility
*/
)
},
modifier = Modifier
.fillMaxWidth()
.height(324.dp)
/*
Setting height for the FeaturedChannelCarousel to keep it rendered with same height,
regardless of the top bar's visibility
*/
)
}
}
item(contentType = "PlaylistsRow") {
val startPadding: Dp = rememberChildPadding().start
val endPadding: Dp = rememberChildPadding().end
@ -165,15 +155,20 @@ private fun Catalog(
) {
items(playlists) { (playlist, count) ->
CompactCard(
onClick = {
goToChannel(playlist.url)
},
onClick = { navigateToPlaylist(playlist.url) },
title = {
Text(
text = playlist.title,
modifier = Modifier.padding(16.dp)
modifier = Modifier.padding(16.dp),
fontSize = 36.sp,
lineHeight = 36.sp,
fontWeight = FontWeight.Bold,
fontFamily = LexendExa
)
},
colors = CardDefaults.compactCardColors(
containerColor = MaterialTheme.colorScheme.primary
),
image = {},
modifier = Modifier
.width(325.dp)
@ -182,32 +177,5 @@ private fun Catalog(
}
}
}
item(contentType = "ChannelsRow") {
ChannelsRow(
modifier = Modifier
.padding(top = 16.dp),
channels = trendingChannels,
title = "StringConstants.Composable.HomeScreenTrendingTitle",
onChannelSelected = onChannelClick
)
}
// item(contentType = "Top10ChannelsList") {
// Top10ChannelsList(
// channels = top10Channels,
// onChannelClick = onChannelClick,
// modifier = Modifier.onFocusChanged {
// immersiveListHasFocus = it.hasFocus
// },
// )
// }
item(contentType = "ChannelsRow") {
ChannelsRow(
modifier = Modifier
.padding(top = 16.dp),
channels = nowPlayingChannels,
title = "StringConstants.Composable.HomeScreenNowPlayingChannelsTitle",
onChannelSelected = onChannelClick
)
}
}
}

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer
package com.m3u.tv.screens.player
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusable
@ -23,15 +23,15 @@ import com.m3u.business.channel.ChannelViewModel
import com.m3u.business.channel.PlayerState
import com.m3u.core.foundation.ui.thenNoN
import com.m3u.data.database.model.Channel
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerControls
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerOverlay
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerPulse
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerPulse.Type.BACK
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerPulse.Type.FORWARD
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerPulseState
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerState
import com.m3u.tv.screens.videoPlayer.components.rememberVideoPlayerPulseState
import com.m3u.tv.screens.videoPlayer.components.rememberVideoPlayerState
import com.m3u.tv.screens.player.components.VideoPlayerControls
import com.m3u.tv.screens.player.components.VideoPlayerOverlay
import com.m3u.tv.screens.player.components.VideoPlayerPulse
import com.m3u.tv.screens.player.components.VideoPlayerPulse.Type.BACK
import com.m3u.tv.screens.player.components.VideoPlayerPulse.Type.FORWARD
import com.m3u.tv.screens.player.components.VideoPlayerPulseState
import com.m3u.tv.screens.player.components.VideoPlayerState
import com.m3u.tv.screens.player.components.rememberVideoPlayerPulseState
import com.m3u.tv.screens.player.components.rememberVideoPlayerState
import com.m3u.tv.utils.handleDPadKeyEvents
import kotlinx.coroutines.delay
@ -40,7 +40,7 @@ object VideoPlayerScreen {
}
@Composable
fun VideoPlayerScreen(
fun PlayerScreen(
onBackPressed: () -> Unit,
viewModel: ChannelViewModel = hiltViewModel()
) {

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import android.content.Context
import androidx.compose.runtime.Composable

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.size
@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import com.m3u.tv.screens.videoPlayer.components.VideoPlayerPulse.Type.NONE
import com.m3u.tv.screens.player.components.VideoPlayerPulse.Type.NONE
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons

View File

@ -1,4 +1,4 @@
package com.m3u.tv.screens.videoPlayer.components
package com.m3u.tv.screens.player.components
import androidx.annotation.IntRange
import androidx.compose.runtime.Composable

View File

@ -0,0 +1,36 @@
package com.m3u.tv.screens.playlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m3u.data.database.model.Channel
import com.m3u.data.repository.channel.ChannelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChannelDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val channelRepository: ChannelRepository
) : ViewModel() {
val channel: StateFlow<Channel?> = savedStateHandle
.getStateFlow(ChannelScreen.ChannelIdBundleKey, -1)
.flatMapLatest { id -> channelRepository.observe(id) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun updateFavourite() {
val channel = channel.value ?: return
viewModelScope.launch {
channelRepository.favouriteOrUnfavourite(channel.id)
}
}
}

View File

@ -14,9 +14,11 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
@ -32,6 +34,8 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButton
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil.compose.AsyncImage
@ -45,7 +49,8 @@ import kotlinx.coroutines.launch
@Composable
fun ChannelDetail(
channel: Channel,
goToChannelPlayer: () -> Unit
navigateToChannelPlayer: () -> Unit,
updateFavourite: () -> Unit,
) {
val childPadding = rememberChildPadding()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -73,23 +78,16 @@ fun ChannelDetail(
modifier = Modifier.alpha(0.75f)
) {
ChannelDescription(description = channel.category)
DotSeparatedRow(
modifier = Modifier.padding(top = 20.dp),
texts = emptyList()
)
DirectorScreenplayMusicRow(
director = channel.title,
screenplay = channel.title,
music = channel.title
)
}
WatchTrailerButton(
channel = channel,
navigateToChannelPlayer = navigateToChannelPlayer,
updateFavourite = updateFavourite,
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
}
},
goToChannelPlayer = goToChannelPlayer
}
)
}
}
@ -98,25 +96,48 @@ fun ChannelDetail(
@Composable
private fun WatchTrailerButton(
channel: Channel,
modifier: Modifier = Modifier,
goToChannelPlayer: () -> Unit
navigateToChannelPlayer: () -> Unit,
updateFavourite: () -> Unit,
) {
Button(
onClick = goToChannelPlayer,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.padding(top = 24.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
shape = ButtonDefaults.shape(shape = JetStreamButtonShape)
) {
Icon(
imageVector = Icons.Outlined.PlayArrow,
contentDescription = null
)
Spacer(Modifier.size(8.dp))
Text(
text = "Play Now",
style = MaterialTheme.typography.titleSmall
)
Button(
onClick = navigateToChannelPlayer,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
shape = ButtonDefaults.shape(shape = JetStreamButtonShape)
) {
Icon(
imageVector = Icons.Outlined.PlayArrow,
contentDescription = null
)
Spacer(Modifier.size(16.dp))
Text(
text = "Play Now",
style = MaterialTheme.typography.titleSmall
)
}
Spacer(Modifier.size(16.dp))
IconButton(
onClick = updateFavourite,
shape = ButtonDefaults.shape(shape = JetStreamButtonShape),
colors = IconButtonDefaults.colors(
contentColor = if (channel.favourite) Color(0xffffcd3c)
else MaterialTheme.colorScheme.onSurface,
focusedContentColor = if (channel.favourite) Color(0xffffcd3c)
else MaterialTheme.colorScheme.inverseOnSurface
)
) {
Icon(
imageVector = Icons.Outlined.Star,
contentDescription = null
)
}
}
}
@Composable

View File

@ -22,11 +22,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme
import com.m3u.core.foundation.components.CircularProgressIndicator
import com.m3u.data.database.model.Channel
import com.m3u.data.service.MediaCommand
import com.m3u.tv.common.Error
import com.m3u.tv.common.Loading
import com.m3u.tv.common.ChannelsRow
import com.m3u.tv.screens.dashboard.rememberChildPadding
import com.m3u.tv.utils.LocalHelper
import kotlinx.coroutines.launch
@ -36,52 +34,44 @@ object ChannelScreen {
}
@Composable
fun ChannelDetailScreen(
goToChannelPlayer: () -> Unit,
fun ChannelScreen(
navigateToChannelPlayer: () -> Unit,
onBackPressed: () -> Unit,
refreshScreenWithNewChannel: (Channel) -> Unit,
channelScreenViewModel: ChannelScreenViewModel = hiltViewModel()
viewModel: ChannelDetailViewModel = hiltViewModel()
) {
val helper = LocalHelper.current
val coroutineScope = rememberCoroutineScope()
val uiState by channelScreenViewModel.uiState.collectAsStateWithLifecycle()
val channel by viewModel.channel.collectAsStateWithLifecycle()
when (val s = uiState) {
is ChannelScreenUiState.Loading -> {
Loading(modifier = Modifier.fillMaxSize())
when (val channel = channel) {
null -> {
CircularProgressIndicator()
}
is ChannelScreenUiState.Error -> {
Error(modifier = Modifier.fillMaxSize())
}
is ChannelScreenUiState.Done -> {
else -> {
Details(
channel = s.channel,
goToChannelPlayer = {
channel = channel,
navigateToChannelPlayer = {
coroutineScope.launch {
helper.play(MediaCommand.Common(s.channel.id))
helper.play(MediaCommand.Common(channel.id))
}
goToChannelPlayer()
navigateToChannelPlayer()
},
updateFavourite = viewModel::updateFavourite,
onBackPressed = onBackPressed,
refreshScreenWithNewChannel = refreshScreenWithNewChannel,
modifier = Modifier
.fillMaxSize()
.animateContentSize()
)
}
null -> {}
}
}
@Composable
private fun Details(
channel: Channel,
goToChannelPlayer: () -> Unit,
channel: Channel?,
navigateToChannelPlayer: () -> Unit,
updateFavourite: () -> Unit,
onBackPressed: () -> Unit,
refreshScreenWithNewChannel: (Channel) -> Unit,
modifier: Modifier = Modifier,
) {
val childPadding = rememberChildPadding()
@ -91,27 +81,14 @@ private fun Details(
contentPadding = PaddingValues(bottom = 135.dp),
modifier = modifier,
) {
item {
ChannelDetail(
channel = channel,
goToChannelPlayer = goToChannelPlayer
)
}
item {
ChannelsRow(
title = channel.title,
titleStyle = MaterialTheme.typography.titleMedium,
channels = emptyList(),
onChannelSelected = refreshScreenWithNewChannel
)
}
item {
ChannelReviews(
modifier = Modifier.padding(top = childPadding.top),
reviewsAndRatings = emptyList()
)
if (channel != null) {
item {
ChannelDetail(
channel = channel,
navigateToChannelPlayer = navigateToChannelPlayer,
updateFavourite = updateFavourite
)
}
}
item {
@ -140,21 +117,6 @@ private fun Details(
title = "LIVE",
value = "channel.status"
)
// TitleValueText(
// modifier = itemModifier,
// title = "stringResource(R.string.original_language)",
// value = "channel.originalLanguage"
// )
// TitleValueText(
// modifier = itemModifier,
// title = "stringResource(R.string.budget)",
// value = "channel.budget"
// )
// TitleValueText(
// modifier = itemModifier,
// title = "stringResource(R.string.revenue)",
// value = "channel.revenue"
// )
}
}
}

View File

@ -1,39 +0,0 @@
package com.m3u.tv.screens.playlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m3u.data.database.model.Channel
import com.m3u.data.repository.channel.ChannelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class ChannelScreenViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
repository: ChannelRepository,
) : ViewModel() {
val uiState = savedStateHandle
.getStateFlow<String?>(ChannelScreen.ChannelIdBundleKey, null)
.map { id ->
if (id == null) {
ChannelScreenUiState.Error
} else {
val channel = repository.get(id = id.toIntOrNull() ?: 0)
channel?.let { ChannelScreenUiState.Done(channel = it) }
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ChannelScreenUiState.Loading
)
}
sealed class ChannelScreenUiState {
data object Loading : ChannelScreenUiState()
data object Error : ChannelScreenUiState()
data class Done(val channel: Channel) : ChannelScreenUiState()
}

View File

@ -13,27 +13,28 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.m3u.core.util.basic.title
import com.m3u.i18n.R
@Composable
fun AboutSection() {
val context = LocalContext.current
val versionNumber = remember(context) {
context.getVersionNumber()
}
val versionNumber = remember(context) { context.getVersionNumber() }
Column(modifier = Modifier.padding(horizontal = 72.dp)) {
Text(
text = "AboutSectionTitle",
text = stringResource(R.string.feat_about_title).title(),
style = MaterialTheme.typography.headlineSmall
)
Text(
modifier = Modifier
.graphicsLayer { alpha = 0.8f }
.padding(top = 16.dp),
text = "AboutSectionDescription",
text = "FOSS Player, which made of jetpack compose. Android 8.0 and above supported.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -48,7 +49,7 @@ fun AboutSection() {
modifier = Modifier
.graphicsLayer { alpha = 0.6f }
.padding(top = 16.dp),
text = "AboutSectionAppVersionTitle",
text = "App Version",
style = MaterialTheme.typography.labelMedium
)
Text(

View File

@ -1,32 +0,0 @@
package com.m3u.tv.screens.shows
import androidx.lifecycle.ViewModel
import com.m3u.data.database.model.Channel
import com.m3u.data.repository.channel.ChannelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ShowScreenViewModel @Inject constructor(
channelRepository: ChannelRepository
) : ViewModel() {
// val uiState = combine(
// channelRepository.getBingeWatchDramas(),
// channelRepository.getTVShows()
// ) { (bingeWatchDramaList, tvShowList) ->
// ShowScreenUiState.Ready(bingeWatchDramaList = bingeWatchDramaList, tvShowList = tvShowList)
// }.stateIn(
// scope = viewModelScope,
// started = SharingStarted.WhileSubscribed(5_000),
// initialValue = ShowScreenUiState.Loading
// )
}
sealed interface ShowScreenUiState {
data object Loading : ShowScreenUiState
data class Ready(
val bingeWatchDramaList: List<Channel>,
val tvShowList: List<Channel>
) : ShowScreenUiState
}

View File

@ -1,94 +0,0 @@
package com.m3u.tv.screens.shows
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.m3u.data.database.model.Channel
import com.m3u.tv.common.ChannelsRow
import com.m3u.tv.common.Loading
import com.m3u.tv.screens.dashboard.rememberChildPadding
@Composable
fun ShowsScreen(
onTVShowClick: (channel: Channel) -> Unit,
onScroll: (isTopBarVisible: Boolean) -> Unit,
isTopBarVisible: Boolean,
showScreenViewModel: ShowScreenViewModel = hiltViewModel(),
) {
// val uiState = showScreenViewModel.uiState.collectAsStateWithLifecycle()
val uiState by remember { mutableStateOf(ShowScreenUiState.Loading) }
when (val currentState = uiState) {
is ShowScreenUiState.Loading -> {
Loading(modifier = Modifier.fillMaxSize())
}
is ShowScreenUiState.Ready -> {
Catalog(
tvShowList = currentState.tvShowList,
bingeWatchDramaList = currentState.bingeWatchDramaList,
onTVShowClick = onTVShowClick,
onScroll = onScroll,
isTopBarVisible = isTopBarVisible,
modifier = Modifier.fillMaxSize()
)
}
}
}
@Composable
private fun Catalog(
tvShowList: List<Channel>,
bingeWatchDramaList: List<Channel>,
onTVShowClick: (channel: Channel) -> Unit,
onScroll: (isTopBarVisible: Boolean) -> Unit,
isTopBarVisible: Boolean,
modifier: Modifier = Modifier
) {
val childPadding = rememberChildPadding()
val lazyListState = rememberLazyListState()
val shouldShowTopBar by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex == 0 &&
lazyListState.firstVisibleItemScrollOffset == 0
}
}
LaunchedEffect(shouldShowTopBar) {
onScroll(shouldShowTopBar)
}
LaunchedEffect(isTopBarVisible) {
if (isTopBarVisible) lazyListState.animateScrollToItem(0)
}
LazyColumn(
modifier = modifier,
state = lazyListState,
contentPadding = PaddingValues(top = childPadding.top, bottom = 104.dp)
) {
item {
// ChannelsScreenList(
// channels = tvShowList,
// onChannelClick = onTVShowClick
// )
}
item {
ChannelsRow(
modifier = Modifier.padding(top = childPadding.top),
title = "BingeWatchDramasTitle",
channels = bingeWatchDramaList,
onChannelSelected = onTVShowClick
)
}
}
}

View File

@ -10,10 +10,10 @@ object PlaylistNavigation {
const val TYPE_URL = "url"
const val PLAYLIST_ROUTE =
"$PLAYLIST_ROUTE_PATH?$TYPE_URL={$TYPE_URL}"
"$PLAYLIST_ROUTE_PATH/{$TYPE_URL}"
internal fun createPlaylistRoute(url: String): String {
return "$PLAYLIST_ROUTE_PATH?$TYPE_URL=$url"
return "$PLAYLIST_ROUTE_PATH/$url"
}
}

View File

@ -13,6 +13,7 @@ import androidx.media3.common.Tracks
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
import com.m3u.data.parser.xtream.XtreamChannelInfo
import com.m3u.data.service.internal.ChannelPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

View File

@ -7,6 +7,7 @@
<string name="ui_destination_foryou">For You</string>
<string name="ui_destination_favourite">Favourite</string>
<string name="ui_destination_setting">Settings</string>
<string name="ui_destination_playlist">Playlist</string>
<string name="ui_error_unknown">unknown error</string>
<string name="ui_cd_top_bar_on_back_pressed">back</string>