mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-05-18 03:45:56 +08:00
feat: tv UI work.
This commit is contained in:
@ -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
|
||||
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
) {
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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"
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user