diff --git a/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt b/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt index 5fc646e..c95ad63 100644 --- a/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt +++ b/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt @@ -13,12 +13,6 @@ import me.lsong.mytv.utils.Settings import okhttp3.OkHttpClient import okhttp3.Request -// 接口定义 -interface TVProvider { - suspend fun load() - fun groups(): TVGroupList - suspend fun epg(): EpgList -} // 数据类定义 @Immutable @@ -149,10 +143,13 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { groupList = process(sources) epgList = fetchEPGData(epgUrls) } + override fun groups(): TVGroupList { + return groupList + } - override suspend fun epg(): EpgList = epgList - override fun groups(): TVGroupList = groupList - + override fun channels(groupTitle: String): TVChannelList { + return groupList.find { it.title == groupTitle }?.channels ?: TVChannelList() + } private suspend fun fetchIPTVSources(): Pair, List> { val allSources = mutableListOf() @@ -227,4 +224,23 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl") } } +} + + +// Interface definition +interface TVProvider { + suspend fun load() + fun groups(): TVGroupList + fun channels(groupTitle: String): TVChannelList +} + +class MyTvProviderManager : TVProvider { + private val providers: List = listOf( + IPTVProvider(EpgRepository()) + ) + override suspend fun load() { + providers.forEach { it.load() } + } + override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() }) + override fun channels(groupTitle: String): TVChannelList = TVChannelList(providers.flatMap { it.channels(groupTitle) }) } \ 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 index 7544b77..b587de2 100644 --- a/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt +++ b/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt @@ -1,5 +1,6 @@ package me.lsong.mytv.ui +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -11,9 +12,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -51,15 +50,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import me.lsong.mytv.R -import me.lsong.mytv.epg.EpgList -import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes -import me.lsong.mytv.epg.EpgRepository -import me.lsong.mytv.providers.IPTVProvider +import me.lsong.mytv.providers.MyTvProviderManager import me.lsong.mytv.providers.TVChannel -import me.lsong.mytv.providers.TVGroupList import me.lsong.mytv.providers.TVGroupList.Companion.channels import me.lsong.mytv.providers.TVGroupList.Companion.findGroupIndex -import me.lsong.mytv.providers.TVProvider import me.lsong.mytv.ui.components.LeanbackVisible import me.lsong.mytv.ui.components.MonitorScreen import me.lsong.mytv.ui.components.MyTvMenuItem @@ -75,88 +69,35 @@ import me.lsong.mytv.utils.handleLeanbackDragGestures import me.lsong.mytv.utils.handleLeanbackKeyEvents @Composable -private fun StartScreen(state: LeanbackMainUiState) { - var isSettingsVisible by remember { mutableStateOf(false) } - BackHandler(enabled = !isSettingsVisible) { - isSettingsVisible = true - } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .onPreviewKeyEvent { event -> - if (event.key == Key.Menu && event.type == KeyEventType.KeyUp) { - isSettingsVisible = !isSettingsVisible - true - } else { - false - } - }, - ) { - 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 - } - } - } - LeanbackVisible({ isSettingsVisible }) { - SettingsScreen() +fun MainScreen( + modifier: Modifier = Modifier, + mainViewModel: MainViewModel = viewModel(), +) { + val uiState by mainViewModel.uiState.collectAsState() + when (val state = uiState) { + is LeanbackMainUiState.Loading, + is LeanbackMainUiState.Error -> StartScreen(state) + is LeanbackMainUiState.Ready -> MainContent( + modifier = modifier, + providerManager = state.providerManager, + ) } } @Composable fun MyTvMenuWidget( modifier: Modifier = Modifier, - epgListProvider: () -> EpgList = { EpgList() }, + providerManager: MyTvProviderManager, + // epgListProvider: () -> EpgList = { EpgList() }, + // groupListProvider: () -> TVGroupList = { TVGroupList() }, channelProvider: () -> TVChannel = { TVChannel() }, - groupListProvider: () -> TVGroupList = { TVGroupList() }, onSelected: (TVChannel) -> Unit = {}, onSettings: (() -> Unit)? = null, onUserAction: () -> Unit = {} ) { - val groupList = groupListProvider() + // val epgList = epgListProvider() + val groupList = providerManager.groups(); val currentChannel = channelProvider() - val epgList = epgListProvider() val groups = remember(groupList) { groupList.map { group -> @@ -165,15 +106,17 @@ fun MyTvMenuWidget( } val currentGroup = remember(groupList, currentChannel) { - groups.firstOrNull { it.title == groupList[groupList.findGroupIndex(currentChannel)].title } + groups.firstOrNull { it.title == currentChannel.groupTitle } ?: MyTvMenuItem() } + Log.d("currentGroup", "$currentGroup $currentChannel") + val currentMenuItem = remember(currentChannel) { MyTvMenuItem( icon = currentChannel.logo ?: "", title = currentChannel.title, - description = epgList.currentProgrammes(currentChannel)?.now?.title + // description = epgList.currentProgrammes(currentChannel)?.now?.title ) } @@ -182,7 +125,7 @@ fun MyTvMenuWidget( MyTvMenuItem( icon = channel.logo ?: "", title = channel.title, - description = epgList.currentProgrammes(channel)?.now?.title + // description = epgList.currentProgrammes(channel)?.now?.title ) } ?: emptyList() } @@ -205,11 +148,11 @@ fun MyTvMenuWidget( selectedItem = focusedGroup, onFocused = { menuItem -> focusedGroup = menuItem - items = itemsProvider(menuItem.title) + items = itemsProvider(focusedGroup.title) }, onSelected = { menuItem -> focusedGroup = menuItem - items = itemsProvider(menuItem.title) + items = itemsProvider(focusedGroup.title) focusedItem = items.firstOrNull() ?: MyTvMenuItem() rightListFocusRequester.requestFocus() }, @@ -232,6 +175,9 @@ fun MyTvMenuWidget( } MyTvMenuItemList( items = items, + modifier = Modifier + .fillMaxHeight() + .background(androidx.tv.material3.MaterialTheme.colorScheme.background.copy(0.8f)), selectedItem = focusedItem, onSelected = { menuItem -> focusedItem = menuItem @@ -240,7 +186,6 @@ fun MyTvMenuWidget( }, onUserAction = onUserAction, focusRequester = rightListFocusRequester, - modifier = Modifier.background(androidx.tv.material3.MaterialTheme.colorScheme.background.copy(0.8f)), ) } @@ -249,43 +194,16 @@ fun MyTvMenuWidget( } } -@Composable -fun MainScreen( - modifier: Modifier = Modifier, - mainViewModel: MainViewModel = viewModel(), -) { - val uiState by mainViewModel.uiState.collectAsState() - when (val state = uiState) { - is LeanbackMainUiState.Loading, - is LeanbackMainUiState.Error -> StartScreen(state) - is LeanbackMainUiState.Ready -> MainContent( - modifier = modifier, - groups = state.groups, - epgList = state.epgList, - ) - } -} - -sealed interface LeanbackMainUiState { - data class Loading(val message: String? = null) : LeanbackMainUiState - data class Error(val message: String? = null) : LeanbackMainUiState - data class Ready( - val groups: TVGroupList = TVGroupList(), - val epgList: EpgList = EpgList(), - ) : LeanbackMainUiState -} - @Composable fun MainContent( modifier: Modifier = Modifier, - epgList: EpgList = EpgList(), - groups: TVGroupList = TVGroupList(), + providerManager: MyTvProviderManager, settingsViewModel: MyTvSettingsViewModel = viewModel(), ) { val videoPlayerState = rememberLeanbackVideoPlayerState() val mainContentState = rememberMainContentState( + providerManager = providerManager, videoPlayerState = videoPlayerState, - groups = groups, ) BackHandler ( @@ -324,6 +242,8 @@ fun MainContent( onNumber = {}, ) .handleLeanbackDragGestures( + onSwipeLeft = { mainContentState.changeToPrevSource() }, + onSwipeRight = { mainContentState.changeToNextSource() }, onSwipeDown = { if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext() else mainContentState.changeCurrentChannelToPrev() @@ -332,19 +252,12 @@ fun MainContent( if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev() else mainContentState.changeCurrentChannelToNext() }, - onSwipeLeft = { - mainContentState.changeToPrevSource() - }, - onSwipeRight = { - mainContentState.changeToNextSource() - }, ), ) LeanbackVisible({ mainContentState.isMenuVisible && !mainContentState.isChannelInfoVisible }) { MyTvMenuWidget( - epgListProvider = { epgList }, - groupListProvider = { groups }, + providerManager = providerManager, channelProvider = { mainContentState.currentChannel }, onSelected = { channel -> mainContentState.changeCurrentChannel(channel) }, onSettings = { mainContentState.showSettings() } @@ -354,7 +267,6 @@ fun MainContent( LeanbackVisible({ mainContentState.isChannelInfoVisible }) { MyTvNowPlaying( modifier = modifier, - epgListProvider = { epgList }, channelProvider = { mainContentState.currentChannel }, channelIndexProvider = { mainContentState.currentChannelIndex }, sourceIndexProvider = { mainContentState.currentSourceIndex }, @@ -372,41 +284,6 @@ fun MainContent( } } -// MainViewModel.kt -class MainViewModel : ViewModel() { - private val providers: List = listOf( - IPTVProvider(EpgRepository()) - ) - private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - viewModelScope.launch { - refreshData() - } - } - - private suspend fun refreshData() { - try { - _uiState.value = LeanbackMainUiState.Loading("Initializing providers...") - providers.forEachIndexed { index, provider -> - _uiState.value = LeanbackMainUiState.Loading("Initializing provider ${index + 1}/${providers.size}...") - provider.load() - } - - val groupList = providers.flatMap { it.groups() } - val epgList = providers.map { it.epg() }.reduce { acc, epgList -> (acc + epgList) as EpgList } - - _uiState.value = LeanbackMainUiState.Ready( - groups = TVGroupList(groupList), - epgList = epgList - ) - } catch (error: Exception) { - _uiState.value = LeanbackMainUiState.Error(error.message) - } - } -} - @Preview(device = "id:pixel_5") @Composable private fun MyTvMainScreenPreview() { diff --git a/app/src/main/java/me/lsong/mytv/ui/MainState.kt b/app/src/main/java/me/lsong/mytv/ui/MainState.kt index 31474bb..dd1ba3a 100644 --- a/app/src/main/java/me/lsong/mytv/ui/MainState.kt +++ b/app/src/main/java/me/lsong/mytv/ui/MainState.kt @@ -7,10 +7,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import me.lsong.mytv.providers.MyTvProviderManager import me.lsong.mytv.providers.TVChannel import me.lsong.mytv.providers.TVGroupList import me.lsong.mytv.providers.TVGroupList.Companion.channels @@ -22,10 +20,10 @@ import kotlin.math.max @Stable class MainContentState( - coroutineScope: CoroutineScope, private val videoPlayerState: LeanbackVideoPlayerState, - private val groups: TVGroupList, + providerManager: MyTvProviderManager, ) { + private val groups: TVGroupList = providerManager.groups(); private var _currentChannel by mutableStateOf(TVChannel()) val currentChannel get() = _currentChannel @@ -58,15 +56,10 @@ class MainContentState( init { changeCurrentChannel(groups.channels.getOrElse(Settings.iptvLastIptvIdx) { - groups.firstOrNull()?.channels?.firstOrNull() ?: TVChannel() + groups.channels.firstOrNull() ?: TVChannel() }) videoPlayerState.onReady { - coroutineScope.launch { - // val name = _currentChannel.name - // val urlIdx = _currentIptvUrlIdx - } - // 记忆可播放的域名 Settings.iptvPlayableHostList += getUrlHost(_currentChannel.urls[_currentIptvUrlIdx]) } @@ -100,13 +93,10 @@ class MainContentState( } fun changeCurrentChannel(channel: TVChannel, urlIdx: Int? = null) { - // isChannelInfoVisible = false if (channel == _currentChannel && urlIdx == null) return if (channel == _currentChannel && urlIdx != _currentIptvUrlIdx) { Settings.iptvPlayableHostList -= getUrlHost(_currentChannel.urls[_currentIptvUrlIdx]) } - // _isTempPanelVisible = true - _currentChannel = channel Settings.iptvLastIptvIdx = currentChannelIndex @@ -131,7 +121,6 @@ class MainContentState( changeCurrentChannel(getNextChannel()) } - fun changeToPrevSource(){ if (currentChannel.urls.size > 1) { changeCurrentChannel( @@ -149,16 +138,18 @@ class MainContentState( } } - fun showChannelInfo() { - isMenuVisible = false - isChannelInfoVisible = true - } - fun showMenu() { isMenuVisible = true + isSettingsVisale = false isChannelInfoVisible = false } + fun showChannelInfo() { + isMenuVisible = false + isSettingsVisale = false + isChannelInfoVisible = true + } + fun showSettings() { isMenuVisible = false isSettingsVisale = true @@ -168,14 +159,12 @@ class MainContentState( @Composable fun rememberMainContentState( - coroutineScope: CoroutineScope = rememberCoroutineScope(), + providerManager: MyTvProviderManager, videoPlayerState: LeanbackVideoPlayerState = rememberLeanbackVideoPlayerState(), - groups: TVGroupList = TVGroupList(), ) = remember { MainContentState( - coroutineScope = coroutineScope, + providerManager = providerManager, videoPlayerState = videoPlayerState, - groups = groups, ) } diff --git a/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt b/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt new file mode 100644 index 0000000..15765d1 --- /dev/null +++ b/app/src/main/java/me/lsong/mytv/ui/MainViewModel.kt @@ -0,0 +1,41 @@ +package me.lsong.mytv.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import me.lsong.mytv.providers.MyTvProviderManager + + +// MainViewModel.kt +class MainViewModel : ViewModel() { + private val providerManager = MyTvProviderManager() + private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + loadData() + } + } + + private suspend fun loadData() { + try { + _uiState.value = LeanbackMainUiState.Loading("Initializing providers...") + providerManager.load() + _uiState.value = LeanbackMainUiState.Ready(providerManager) + } catch (error: Exception) { + _uiState.value = LeanbackMainUiState.Error(error.message) + } + } +} + +sealed interface LeanbackMainUiState { + data class Loading(val message: String? = null) : LeanbackMainUiState + data class Error(val message: String? = null) : LeanbackMainUiState + data class Ready( + val providerManager: MyTvProviderManager, + ) : LeanbackMainUiState +} diff --git a/app/src/main/java/me/lsong/mytv/ui/StartScreen.kt b/app/src/main/java/me/lsong/mytv/ui/StartScreen.kt new file mode 100644 index 0000000..06c972f --- /dev/null +++ b/app/src/main/java/me/lsong/mytv/ui/StartScreen.kt @@ -0,0 +1,104 @@ +package me.lsong.mytv.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.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.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.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.unit.dp +import me.lsong.mytv.R +import me.lsong.mytv.ui.components.LeanbackVisible +import me.lsong.mytv.ui.settings.SettingsScreen +import me.lsong.mytv.utils.Constants + +@Composable +fun StartScreen(state: LeanbackMainUiState) { + var isSettingsVisible by remember { mutableStateOf(false) } + BackHandler(enabled = !isSettingsVisible) { + isSettingsVisible = true + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .onPreviewKeyEvent { event -> + if (event.key == Key.Menu && event.type == KeyEventType.KeyUp) { + isSettingsVisible = !isSettingsVisible + true + } else { + false + } + }, + ) { + 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 + } + } + } + LeanbackVisible({ isSettingsVisible }) { + SettingsScreen() + } +}