From c3c4a67e707ddb5df8c58982c43cda9c84ce4f09 Mon Sep 17 00:00:00 2001 From: Lsong Date: Wed, 28 Aug 2024 19:03:57 +0800 Subject: [PATCH] update --- .../main/java/me/lsong/mytv/MainActivity.kt | 4 +- .../java/me/lsong/mytv/ui/LoadingScreen.kt | 237 --------- .../main/java/me/lsong/mytv/ui/MainContent.kt | 165 ------- .../main/java/me/lsong/mytv/ui/MainScreen.kt | 450 ++++++++++++++++++ .../ui/{MainContentState.kt => MainState.kt} | 0 .../java/me/lsong/mytv/ui/MainViewModel.kt | 136 ------ .../java/me/lsong/mytv/ui/widgets/Menu.kt | 58 --- 7 files changed, 452 insertions(+), 598 deletions(-) delete mode 100644 app/src/main/java/me/lsong/mytv/ui/LoadingScreen.kt delete mode 100644 app/src/main/java/me/lsong/mytv/ui/MainContent.kt create mode 100644 app/src/main/java/me/lsong/mytv/ui/MainScreen.kt rename app/src/main/java/me/lsong/mytv/ui/{MainContentState.kt => MainState.kt} (100%) delete mode 100644 app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt diff --git a/app/src/main/java/me/lsong/mytv/MainActivity.kt b/app/src/main/java/me/lsong/mytv/MainActivity.kt index 176f39a..47e207b 100644 --- a/app/src/main/java/me/lsong/mytv/MainActivity.kt +++ b/app/src/main/java/me/lsong/mytv/MainActivity.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.debounce -import me.lsong.mytv.ui.LoadingScreen +import me.lsong.mytv.ui.MainScreen import me.lsong.mytv.ui.components.LeanbackPadding import me.lsong.mytv.ui.theme.LeanbackTheme import me.lsong.mytv.ui.toast.LeanbackToastScreen @@ -117,7 +117,7 @@ fun LeanbackApp( ) { val doubleBackPressedExitState = rememberLeanbackDoubleBackPressedExitState() LeanbackToastScreen() - LoadingScreen( + MainScreen( modifier = modifier, onBackPressed = { if (doubleBackPressedExitState.allowExit) { diff --git a/app/src/main/java/me/lsong/mytv/ui/LoadingScreen.kt b/app/src/main/java/me/lsong/mytv/ui/LoadingScreen.kt deleted file mode 100644 index f948c60..0000000 --- a/app/src/main/java/me/lsong/mytv/ui/LoadingScreen.kt +++ /dev/null @@ -1,237 +0,0 @@ -package me.lsong.mytv.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import io.github.alexzhirkevich.qrose.options.QrBallShape -import io.github.alexzhirkevich.qrose.options.QrFrameShape -import io.github.alexzhirkevich.qrose.options.QrPixelShape -import io.github.alexzhirkevich.qrose.options.QrShapes -import io.github.alexzhirkevich.qrose.options.circle -import io.github.alexzhirkevich.qrose.options.roundCorners -import io.github.alexzhirkevich.qrose.rememberQrCodePainter -import me.lsong.mytv.R -import me.lsong.mytv.rememberLeanbackChildPadding -import me.lsong.mytv.ui.components.LeanbackVisible -import me.lsong.mytv.ui.settings.MyTvSettingsScreen -import me.lsong.mytv.ui.theme.LeanbackTheme -import me.lsong.mytv.utils.HttpServer -import me.lsong.mytv.utils.Constants - -@Composable -fun LoadingScreen( - modifier: Modifier = Modifier, - onBackPressed: () -> Unit = {}, - mainViewModel: MainViewModel = viewModel(), -) { - val uiState by mainViewModel.uiState.collectAsState() - - when (val s = uiState) { - is LeanbackMainUiState.Ready -> LeanbackMainContent( - modifier = modifier, - groupList = s.tvGroupList, - epgList = s.epgList, - onBackPressed = onBackPressed, - ) - - is LeanbackMainUiState.Loading -> LeanbackMainSettingsHandle(onBackPressed = onBackPressed) { - LeanbackMainScreenLoading { s.message } - } - - is LeanbackMainUiState.Error -> LeanbackMainSettingsHandle(onBackPressed = onBackPressed) { - LeanbackMainScreenError({ s.message }) - } - } -} - -@Composable -private fun LeanbackMainScreenLoading(messageProvider: () -> String?) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Image( - painter = painterResource(id = R.mipmap.ic_launcher), - contentDescription = "DuckTV", - modifier = Modifier.size(96.dp) - ) - Text( - text = Constants.APP_NAME, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - - LinearProgressIndicator( - modifier = Modifier - .widthIn(300.dp, 800.dp) - .height(8.dp) - ) - - val message = messageProvider() - if (message != null) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), - modifier = Modifier.sizeIn(maxWidth = 500.dp), - ) - } - } - } -} - -@Preview(device = "id:Android TV (720p)") -@Composable -private fun LeanbackMainScreenLoadingPreview() { - LeanbackTheme { - LeanbackMainScreenLoading { "获取远程直播源(2/10)..." } - } -} - -@Composable -private fun LeanbackMainScreenError( - messageProvider: () -> String?, - serverUrl: String = HttpServer.serverUrl, -) { - val childPadding = rememberLeanbackChildPadding() - - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = childPadding.start, bottom = childPadding.bottom), - ) { - Text( - text = "加载失败", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.error, - ) - - val message = messageProvider() - if (message != null) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), - modifier = Modifier.sizeIn(maxWidth = 500.dp), - ) - } - } - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = childPadding.end, bottom = childPadding.bottom) - .width(100.dp) - .height(100.dp) - .background( - color = MaterialTheme.colorScheme.onBackground, - shape = MaterialTheme.shapes.medium, - ), - ) { - Image( - modifier = Modifier - .fillMaxSize() - .padding(6.dp), - painter = rememberQrCodePainter( - data = serverUrl, - shapes = QrShapes( - ball = QrBallShape.circle(), - darkPixel = QrPixelShape.roundCorners(), - frame = QrFrameShape.roundCorners(.25f), - ), - ), - contentDescription = serverUrl, - ) - } - } -} - -@Preview(device = "id:Android TV (720p)") -@Composable -private fun LeanbackMainScreenErrorPreview() { - LeanbackTheme { - LeanbackMainScreenError( - { "获取远程直播源失败,请检查网络连接" }, - "http://244.178.44.111:8080", - ) - } -} - -@Preview(device = "id:Android TV (720p)") -@Composable -private fun LeanbackMainScreenErrorLongPreview() { - LeanbackTheme { - LeanbackMainScreenError( - { "Caused by: androidx.media3.datasource.HttpDataSource\$HttpDataSourceException:" + " java.io.IOException: unexpected end of stream on com.android.okhttp.Address@2f10c24d" }, - "http://244.178.44.111:8080", - ) - } -} - -@Composable -private fun LeanbackMainSettingsHandle( - modifier: Modifier = Modifier, - onBackPressed: () -> Unit = {}, - content: @Composable () -> Unit, -) { - val focusRequester = remember { FocusRequester() } - var showSettings by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - LeanbackBackPressHandledArea(onBackPressed = { - if (showSettings) { - showSettings = false - onBackPressed() - onBackPressed() - } else { - showSettings = true - } - }) { - Box( - modifier = modifier - .focusRequester(focusRequester) - .focusable() - ) { - content() - LeanbackVisible({ showSettings }) { - MyTvSettingsScreen() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/ui/MainContent.kt b/app/src/main/java/me/lsong/mytv/ui/MainContent.kt deleted file mode 100644 index 5cd97c4..0000000 --- a/app/src/main/java/me/lsong/mytv/ui/MainContent.kt +++ /dev/null @@ -1,165 +0,0 @@ -package me.lsong.mytv.ui - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.delay -import me.lsong.mytv.epg.EpgList -import me.lsong.mytv.iptv.TVGroupList -import me.lsong.mytv.ui.components.LeanbackVisible -import me.lsong.mytv.ui.components.LeanbackMonitorScreen -import me.lsong.mytv.ui.player.MyTvVideoScreen -import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState -import me.lsong.mytv.ui.settings.MyTvSettingsViewModel -import me.lsong.mytv.ui.widgets.MyTvMenu -import me.lsong.mytv.ui.widgets.MyTvMenuWidget -import me.lsong.mytv.ui.widgets.MyTvNowPlaying -import me.lsong.mytv.utils.handleLeanbackDragGestures -import me.lsong.mytv.utils.handleLeanbackKeyEvents - -@Composable -fun LeanbackMainContent( - modifier: Modifier = Modifier, - onBackPressed: () -> Unit = {}, - epgList: EpgList = EpgList(), - groupList: TVGroupList = TVGroupList(), - settingsViewModel: MyTvSettingsViewModel = viewModel(), -) { - val videoPlayerState = rememberLeanbackVideoPlayerState() - val mainContentState = rememberMainContentState( - videoPlayerState = videoPlayerState, - tvGroupList = groupList, - ) - - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - // 防止切换到其他界面时焦点丢失 - // TODO 换一个更好的解决方案 - while (true) { - if (!mainContentState.isChannelInfoVisible && !mainContentState.isMenuVisible - ) { - focusRequester.requestFocus() - } - delay(100) - } - } - - LeanbackBackPressHandledArea( - modifier = modifier, - onBackPressed = { - if (mainContentState.isChannelInfoVisible) { - mainContentState.isMenuVisible = false - mainContentState.isChannelInfoVisible = false - } - else if (mainContentState.isMenuVisible) { - mainContentState.isMenuVisible = false - mainContentState.isChannelInfoVisible = false - } else onBackPressed() - }, - ) { - MyTvVideoScreen( - state = videoPlayerState, - aspectRatioProvider = { settingsViewModel.videoPlayerAspectRatio }, - showMetadataProvider = { settingsViewModel.debugShowVideoPlayerMetadata }, - modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester) - .focusable() - .handleLeanbackKeyEvents( - onUp = { - if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext() - else mainContentState.changeCurrentChannelToPrev() - }, - onDown = { - if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev() - else mainContentState.changeCurrentChannelToNext() - }, - onLeft = { mainContentState.changeToPrevSource() }, - onRight = { mainContentState.changeToNextSource() }, - onSelect = { mainContentState.showChannelInfo() }, - onLongDown = { mainContentState.showMenu() }, - onLongSelect = { mainContentState.showMenu() }, - onSettings = { mainContentState.showMenu() }, - onNumber = { - // if (settingsViewModel.iptvChannelNoSelectEnable) { - // panelChannelNoSelectState.input(it) - // } - }, - ) - .handleLeanbackDragGestures( - onSwipeDown = { - if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext() - else mainContentState.changeCurrentChannelToPrev() - }, - onSwipeUp = { - if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev() - else mainContentState.changeCurrentChannelToNext() - }, - onSwipeLeft = { - mainContentState.changeToPrevSource() - }, - onSwipeRight = { - mainContentState.changeToNextSource() - }, - ), - ) - - LeanbackVisible({ mainContentState.isMenuVisible && !mainContentState.isChannelInfoVisible }) { - MyTvMenuWidget( - groupListProvider = { groupList }, - epgListProvider = { epgList }, - channelProvider = { mainContentState.currentChannel }, - onSelected = { channel -> mainContentState.changeCurrentChannel(channel) } - ) - } - - LeanbackVisible({ mainContentState.isChannelInfoVisible }) { - MyTvNowPlaying( - modifier = modifier, - epgListProvider = { epgList }, - channelProvider = { mainContentState.currentChannel }, - channelIndexProvider = { mainContentState.currentChannelIndex }, - sourceIndexProvider = { mainContentState.currentSourceIndex }, - videoPlayerMetadataProvider = { videoPlayerState.metadata }, - onClose = { mainContentState.isChannelInfoVisible = false }, - ) - } - - LeanbackVisible({ settingsViewModel.debugShowFps }) { - LeanbackMonitorScreen() - } - - } -} - -@Composable -fun LeanbackBackPressHandledArea( - onBackPressed: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, -) = Box( - modifier = Modifier - .onPreviewKeyEvent { - if (it.key == Key.Back && it.type == KeyEventType.KeyUp) { - onBackPressed() - true - } else { - false - } - } - .then(modifier), - content = content, -) \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt b/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt new file mode 100644 index 0000000..9967a7c --- /dev/null +++ b/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt @@ -0,0 +1,450 @@ +package me.lsong.mytv.ui + +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import me.lsong.mytv.R +import me.lsong.mytv.epg.EpgChannel +import me.lsong.mytv.epg.EpgList +import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes +import me.lsong.mytv.epg.EpgRepository +import me.lsong.mytv.iptv.IptvRepository +import me.lsong.mytv.iptv.TVChannel +import me.lsong.mytv.iptv.TVChannelList +import me.lsong.mytv.iptv.TVGroup +import me.lsong.mytv.iptv.TVGroupList +import me.lsong.mytv.iptv.TVGroupList.Companion.channels +import me.lsong.mytv.iptv.TVGroupList.Companion.findGroupIndex +import me.lsong.mytv.iptv.TVSource +import me.lsong.mytv.ui.components.LeanbackMonitorScreen +import me.lsong.mytv.ui.components.LeanbackVisible +import me.lsong.mytv.ui.player.MyTvVideoScreen +import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState +import me.lsong.mytv.ui.settings.MyTvSettingsViewModel +import me.lsong.mytv.ui.theme.LeanbackTheme +import me.lsong.mytv.ui.widgets.MyTvMenu +import me.lsong.mytv.ui.widgets.MyTvMenuItem +import me.lsong.mytv.ui.widgets.MyTvNowPlaying +import me.lsong.mytv.utils.Constants +import me.lsong.mytv.utils.Settings +import me.lsong.mytv.utils.handleLeanbackDragGestures +import me.lsong.mytv.utils.handleLeanbackKeyEvents + +@Composable +fun MyTvMenuWidget( + modifier: Modifier = Modifier, + groupListProvider: () -> TVGroupList = { TVGroupList() }, + epgListProvider: () -> EpgList = { EpgList() }, + channelProvider: () -> TVChannel = { TVChannel() }, + onSelected: (TVChannel) -> Unit = {}, + onUserAction: () -> Unit = {} +) { + val groupList = groupListProvider() + val currentChannel = channelProvider() + val epgList = epgListProvider() + + val groups = remember(groupList) { + groupList.map { group -> + MyTvMenuItem(title = group.title) + } + } + + val currentGroup = remember(groupList, currentChannel) { + groups.firstOrNull { it.title == groupList[groupList.findGroupIndex(currentChannel)].title } + ?: MyTvMenuItem() + } + + val currentMenuItem = remember(currentChannel) { + MyTvMenuItem( + icon = currentChannel.logo, + title = currentChannel.title, + description = epgList.currentProgrammes(currentChannel)?.now?.title ?: currentChannel.name + ) + } + + val itemsProvider: (String) -> List = { groupTitle -> + groupList.find { it.title == groupTitle }?.channels?.map { channel -> + MyTvMenuItem( + icon = channel.logo ?: "", + title = channel.title, + description = epgList.currentProgrammes(channel)?.now?.title ?: channel.name + ) + } ?: emptyList() + } + + MyTvMenu( + groups = groups, + itemsProvider = itemsProvider, + currentGroupProvider = { currentGroup }, + currentItemProvider = { currentMenuItem }, + onGroupSelected = { /* 可以在这里添加组被选中时的逻辑 */ }, + onItemSelected = { selectedItem -> + val selectedChannel = groupList.channels.first { it.title == selectedItem.title } + onSelected(selectedChannel) + }, + modifier = modifier, + onUserAction = onUserAction + ) +} + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + mainViewModel: MainViewModel = viewModel(), + settingsViewModel: MyTvSettingsViewModel = viewModel() +) { + val uiState by mainViewModel.uiState.collectAsState() + + LeanbackBackPressHandledArea( + modifier = modifier, + onBackPressed = onBackPressed + ) { + when (val state = uiState) { + is LeanbackMainUiState.Loading, + is LeanbackMainUiState.Error -> StartScreen(state) + is LeanbackMainUiState.Ready -> MainContent( + modifier = modifier, + groupList = state.tvGroupList, + epgList = state.epgList, + onBackPressed = onBackPressed, + settingsViewModel = settingsViewModel + ) + } + } +} + +@Composable +private fun StartScreen(state: LeanbackMainUiState) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(id = R.mipmap.ic_launcher), + contentDescription = "DuckTV", + modifier = Modifier.size(96.dp) + ) + Text( + text = Constants.APP_NAME, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + + when (state) { + is LeanbackMainUiState.Loading -> { + LinearProgressIndicator( + modifier = Modifier + .widthIn(300.dp, 800.dp) + .height(8.dp) + ) + state.message?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + modifier = Modifier.sizeIn(maxWidth = 500.dp), + ) + } + } + is LeanbackMainUiState.Error -> { + state.message?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + modifier = Modifier.sizeIn(maxWidth = 500.dp), + ) + } + } + else -> {} // This case should never happen + } + } + } +} + +@Composable +fun MainContent( + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + epgList: EpgList = EpgList(), + groupList: TVGroupList = TVGroupList(), + settingsViewModel: MyTvSettingsViewModel = viewModel(), +) { + val videoPlayerState = rememberLeanbackVideoPlayerState() + val mainContentState = rememberMainContentState( + videoPlayerState = videoPlayerState, + tvGroupList = groupList, + ) + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + // 防止切换到其他界面时焦点丢失 + // TODO 换一个更好的解决方案 + while (true) { + if (!mainContentState.isChannelInfoVisible && !mainContentState.isMenuVisible + ) { + focusRequester.requestFocus() + } + delay(100) + } + } + + LeanbackBackPressHandledArea( + modifier = modifier, + onBackPressed = { + if (mainContentState.isChannelInfoVisible) { + mainContentState.isMenuVisible = false + mainContentState.isChannelInfoVisible = false + } + else if (mainContentState.isMenuVisible) { + mainContentState.isMenuVisible = false + mainContentState.isChannelInfoVisible = false + } else onBackPressed() + }, + ) { + MyTvVideoScreen( + state = videoPlayerState, + aspectRatioProvider = { settingsViewModel.videoPlayerAspectRatio }, + showMetadataProvider = { settingsViewModel.debugShowVideoPlayerMetadata }, + modifier = Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .handleLeanbackKeyEvents( + onUp = { + if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext() + else mainContentState.changeCurrentChannelToPrev() + }, + onDown = { + if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev() + else mainContentState.changeCurrentChannelToNext() + }, + onLeft = { mainContentState.changeToPrevSource() }, + onRight = { mainContentState.changeToNextSource() }, + onSelect = { mainContentState.showChannelInfo() }, + onLongDown = { mainContentState.showMenu() }, + onLongSelect = { mainContentState.showMenu() }, + onSettings = { mainContentState.showMenu() }, + onNumber = {}, + ) + .handleLeanbackDragGestures( + onSwipeDown = { + if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext() + else mainContentState.changeCurrentChannelToPrev() + }, + onSwipeUp = { + if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev() + else mainContentState.changeCurrentChannelToNext() + }, + onSwipeLeft = { + mainContentState.changeToPrevSource() + }, + onSwipeRight = { + mainContentState.changeToNextSource() + }, + ), + ) + + LeanbackVisible({ mainContentState.isMenuVisible && !mainContentState.isChannelInfoVisible }) { + MyTvMenuWidget( + epgListProvider = { epgList }, + groupListProvider = { groupList }, + channelProvider = { mainContentState.currentChannel }, + onSelected = { channel -> mainContentState.changeCurrentChannel(channel) } + ) + } + + LeanbackVisible({ mainContentState.isChannelInfoVisible }) { + MyTvNowPlaying( + modifier = modifier, + epgListProvider = { epgList }, + channelProvider = { mainContentState.currentChannel }, + channelIndexProvider = { mainContentState.currentChannelIndex }, + sourceIndexProvider = { mainContentState.currentSourceIndex }, + videoPlayerMetadataProvider = { videoPlayerState.metadata }, + onClose = { mainContentState.isChannelInfoVisible = false }, + ) + } + + LeanbackVisible({ settingsViewModel.debugShowFps }) { + LeanbackMonitorScreen() + } + + } +} + +@Composable +fun LeanbackBackPressHandledArea( + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) = Box( + modifier = Modifier + .onPreviewKeyEvent { + if (it.key == Key.Back && it.type == KeyEventType.KeyUp) { + onBackPressed() + true + } else { + false + } + } + .then(modifier), + content = content, +) + +class MainViewModel : ViewModel() { + private val iptvRepository = IptvRepository() + private val epgRepository = EpgRepository() + + private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + refreshData() + } + } + + private suspend fun refreshData() { + var epgUrls = emptyArray() + var iptvUrls = emptyArray() + if (Settings.iptvSourceUrls.isNotEmpty()) { + iptvUrls += Settings.iptvSourceUrls + } + if (iptvUrls.isEmpty()) { + iptvUrls += Constants.IPTV_SOURCE_URL + } + flow { + val allSources = mutableListOf() + iptvUrls.forEachIndexed { index, url -> + emit(LoadingState(index + 1, iptvUrls.size, url, "IPTV")) + val m3u = fetchDataWithRetry { iptvRepository.getChannelSourceList(sourceUrl = url) } + allSources.addAll(m3u.sources) + if (m3u.epgUrl != null) + epgUrls += (m3u.epgUrl).toString() + } + if (epgUrls.isEmpty()) { + epgUrls += Constants.EPG_XML_URL + } + val epgChannels = mutableListOf() + epgUrls.distinct().toTypedArray().forEachIndexed { index, url -> + emit(LoadingState(index + 1, epgUrls.size, url, "EPG")) + val epg = fetchDataWithRetry { epgRepository.getEpgList(url) } + epgChannels.addAll(epg.value) + } + val groupList = processChannelSources(allSources) + emit(DataResult(groupList, EpgList(epgChannels.distinctBy{ it.id }))) + } + .catch { error -> + _uiState.value = LeanbackMainUiState.Error(error.message) + Settings.iptvSourceUrlHistoryList -= iptvUrls.toList() + } + .collect { result -> + when (result) { + is LoadingState -> { + _uiState.value = + LeanbackMainUiState.Loading("获取${result.type}数据(${result.currentSource}/${result.totalSources})...") + } + is DataResult -> { + Log.d("epg","合并节目单完成:${result.epgList.size}") + _uiState.value = LeanbackMainUiState.Ready( + tvGroupList = result.groupList, + epgList = result.epgList + ) + Settings.iptvSourceUrlHistoryList += iptvUrls.toList() + } + } + } + } + + private suspend fun fetchDataWithRetry(fetch: suspend () -> T): T { + var attempt = 0 + while (attempt < Constants.HTTP_RETRY_COUNT) { + try { + return fetch() + } catch (e: Exception) { + attempt++ + if (attempt >= Constants.HTTP_RETRY_COUNT) throw e + delay(Constants.HTTP_RETRY_INTERVAL) + } + } + throw IllegalStateException("Failed to fetch data after $attempt attempts") + } + + private fun processChannelSources(sources: List): TVGroupList { + val sourceList = TVChannelList(sources.groupBy { it.name }.map { channelEntry -> + TVChannel( + name = channelEntry.key, + title = channelEntry.value.first().title, + sources = channelEntry.value) + }) + val groupList = TVGroupList(sourceList.groupBy { it.groupTitle ?: "其他" }.map { groupEntry -> + TVGroup(title = groupEntry.key, channels = TVChannelList(groupEntry.value)) + }) + return groupList + } + private data class LoadingState(val currentSource: Int, val totalSources: Int, val currentUrl: String, val type: String) + private data class DataResult(val groupList: TVGroupList, val epgList: EpgList) +} + +sealed interface LeanbackMainUiState { + data class Loading(val message: String? = null) : LeanbackMainUiState + data class Error(val message: String? = null) : LeanbackMainUiState + data class Ready( + val tvGroupList: TVGroupList = TVGroupList(), + val epgList: EpgList = EpgList(), + ) : LeanbackMainUiState +} + +@Preview(device = "id:pixel_5") +@Composable +private fun MyTvMainScreenPreview() { + LeanbackTheme { + MainScreen() + } +} + diff --git a/app/src/main/java/me/lsong/mytv/ui/MainContentState.kt b/app/src/main/java/me/lsong/mytv/ui/MainState.kt similarity index 100% rename from app/src/main/java/me/lsong/mytv/ui/MainContentState.kt rename to app/src/main/java/me/lsong/mytv/ui/MainState.kt diff --git a/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt b/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt deleted file mode 100644 index 9f27170..0000000 --- a/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt +++ /dev/null @@ -1,136 +0,0 @@ -package me.lsong.mytv.ui - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import me.lsong.mytv.epg.EpgChannel -import me.lsong.mytv.epg.EpgList -import me.lsong.mytv.iptv.TVChannel -import me.lsong.mytv.iptv.TVChannelList -import me.lsong.mytv.iptv.TVGroup -import me.lsong.mytv.iptv.TVGroupList -import me.lsong.mytv.iptv.TVSource -import me.lsong.mytv.epg.EpgRepository -import me.lsong.mytv.iptv.IptvRepository -import me.lsong.mytv.utils.Constants -import me.lsong.mytv.utils.Settings - -class MainViewModel : ViewModel() { - private val iptvRepository = IptvRepository() - private val epgRepository = EpgRepository() - - private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - viewModelScope.launch { - refreshData() - } - } - - private suspend fun refreshData() { - var epgUrls = emptyArray() - var iptvUrls = emptyArray() - - // SP.iptvSourceUrls = setOf( - // "https://raw.githubusercontent.com/YueChan/Live/main/IPTV.m3u", - // "https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.m3u", - // "https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/itv.m3u", - // "https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/index.m3u", - // ) - - if (Settings.iptvSourceUrls.isNotEmpty()) { - iptvUrls += Settings.iptvSourceUrls - } - if (iptvUrls.isEmpty()) { - iptvUrls += Constants.IPTV_SOURCE_URL - } - flow { - val allSources = mutableListOf() - iptvUrls.forEachIndexed { index, url -> - emit(LoadingState(index + 1, iptvUrls.size, url, "IPTV")) - val m3u = fetchDataWithRetry { iptvRepository.getChannelSourceList(sourceUrl = url) } - allSources.addAll(m3u.sources) - if (m3u.epgUrl != null) - epgUrls += (m3u.epgUrl).toString() - } - if (epgUrls.isEmpty()) { - epgUrls += Constants.EPG_XML_URL - } - val epgChannels = mutableListOf() - epgUrls.distinct().toTypedArray().forEachIndexed { index, url -> - emit(LoadingState(index + 1, epgUrls.size, url, "EPG")) - val epg = fetchDataWithRetry { epgRepository.getEpgList(url) } - epgChannels.addAll(epg.value) - } - val groupList = processChannelSources(allSources) - emit(DataResult(groupList, EpgList(epgChannels.distinctBy{ it.id }))) - } - .catch { error -> - _uiState.value = LeanbackMainUiState.Error(error.message) - Settings.iptvSourceUrlHistoryList -= iptvUrls.toList() - } - .collect { result -> - when (result) { - is LoadingState -> { - _uiState.value = - LeanbackMainUiState.Loading("获取${result.type}数据(${result.currentSource}/${result.totalSources})...") - } - is DataResult -> { - Log.d("epg","合并节目单完成:${result.epgList.size}") - _uiState.value = LeanbackMainUiState.Ready( - tvGroupList = result.groupList, - epgList = result.epgList - ) - Settings.iptvSourceUrlHistoryList += iptvUrls.toList() - } - } - } - } - - private suspend fun fetchDataWithRetry(fetch: suspend () -> T): T { - var attempt = 0 - while (attempt < Constants.HTTP_RETRY_COUNT) { - try { - return fetch() - } catch (e: Exception) { - attempt++ - if (attempt >= Constants.HTTP_RETRY_COUNT) throw e - delay(Constants.HTTP_RETRY_INTERVAL) - } - } - throw IllegalStateException("Failed to fetch data after $attempt attempts") - } - - private fun processChannelSources(sources: List): TVGroupList { - val sourceList = TVChannelList(sources.groupBy { it.name }.map { channelEntry -> - TVChannel( - name = channelEntry.key, - title = channelEntry.value.first().title, - sources = channelEntry.value) - }) - val groupList = TVGroupList(sourceList.groupBy { it.groupTitle ?: "其他" }.map { groupEntry -> - TVGroup(title = groupEntry.key, channels = TVChannelList(groupEntry.value)) - }) - return groupList - } - - private data class LoadingState(val currentSource: Int, val totalSources: Int, val currentUrl: String, val type: String) - private data class DataResult(val groupList: TVGroupList, val epgList: EpgList) -} - -sealed interface LeanbackMainUiState { - data class Loading(val message: String? = null) : LeanbackMainUiState - data class Error(val message: String? = null) : LeanbackMainUiState - data class Ready( - val tvGroupList: TVGroupList = TVGroupList(), - val epgList: EpgList = EpgList(), - ) : LeanbackMainUiState -} \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/ui/widgets/Menu.kt b/app/src/main/java/me/lsong/mytv/ui/widgets/Menu.kt index c329447..c729b1f 100644 --- a/app/src/main/java/me/lsong/mytv/ui/widgets/Menu.kt +++ b/app/src/main/java/me/lsong/mytv/ui/widgets/Menu.kt @@ -269,64 +269,6 @@ fun MyTvMenuItemList( } } -@Composable -fun MyTvMenuWidget( - modifier: Modifier = Modifier, - groupListProvider: () -> TVGroupList = { TVGroupList() }, - epgListProvider: () -> EpgList = { EpgList() }, - channelProvider: () -> TVChannel = { TVChannel() }, - onSelected: (TVChannel) -> Unit = {}, - onUserAction: () -> Unit = {} -) { - val groupList = groupListProvider() - val currentChannel = channelProvider() - val epgList = epgListProvider() - - val groups = remember(groupList) { - groupList.map { group -> - MyTvMenuItem(title = group.title) - } - } - - val currentGroup = remember(groupList, currentChannel) { - groups.firstOrNull { it.title == groupList[groupList.findGroupIndex(currentChannel)].title } - ?: MyTvMenuItem() - } - - val currentMenuItem = remember(currentChannel) { - MyTvMenuItem( - icon = currentChannel.logo, - title = currentChannel.title, - description = epgList.currentProgrammes(currentChannel)?.now?.title ?: currentChannel.name - ) - } - - val itemsProvider: (String) -> List = { groupTitle -> - groupList.find { it.title == groupTitle }?.channels?.map { channel -> - MyTvMenuItem( - icon = channel.logo ?: "", - title = channel.title, - description = epgList.currentProgrammes(channel)?.now?.title ?: channel.name - ) - } ?: emptyList() - } - - MyTvMenu( - groups = groups, - itemsProvider = itemsProvider, - currentGroupProvider = { currentGroup }, - currentItemProvider = { currentMenuItem }, - onGroupSelected = { /* 可以在这里添加组被选中时的逻辑 */ }, - onItemSelected = { selectedItem -> - val selectedChannel = groupList.channels.first { it.title == selectedItem.title } - onSelected(selectedChannel) - }, - modifier = modifier, - onUserAction = onUserAction - ) -} - - @Preview @Composable private fun MyTvMenuItemComponentPreview() {