diff --git a/app/src/main/java/me/lsong/mytv/iptv/IPTVProvider.kt b/app/src/main/java/me/lsong/mytv/iptv/IPTVProvider.kt new file mode 100644 index 0000000..bcec91c --- /dev/null +++ b/app/src/main/java/me/lsong/mytv/iptv/IPTVProvider.kt @@ -0,0 +1,245 @@ +package me.lsong.mytv.iptv + +import android.util.Log +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import me.lsong.mytv.epg.EpgChannel +import me.lsong.mytv.epg.EpgList +import me.lsong.mytv.epg.EpgRepository +import me.lsong.mytv.utils.Constants +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 +data class TVSource( + val tvgId: String? = null, + val tvgLogo: String? = null, + val tvgName: String? = null, + val groupTitle: String? = null, + val title: String, + val url: String +) { + val name: String get() = tvgName ?: tvgId ?: title + + companion object { + val EXAMPLE = TVSource( + tvgId = "cctv1", + tvgName = "cctv1", + tvgLogo = "https://live.fanmingming.com/tv/CCTV1.png", + title = "CCTV-1", + groupTitle = "央视", + url = "https://pi.0472.org/chc/ym.m3u8" + ) + } +} + +@Immutable +data class TVChannel( + val name: String = "", + val title: String = "", + val sources: List = emptyList() +) { + val logo: String? get() = sources.firstNotNullOfOrNull { it.tvgLogo } + val groupTitle: String? get() = sources.firstNotNullOfOrNull { it.groupTitle } + val urls: List get() = sources.map { it.url } + + companion object { + val EXAMPLE = TVChannel( + title = "测试频道", + sources = listOf(TVSource.EXAMPLE) + ) + } +} + +@Immutable +data class TVGroup( + val title: String = "", + val channels: TVChannelList = TVChannelList() +) { + companion object { + val EXAMPLE = TVGroup( + title = "测试分组", + channels = TVChannelList(List(10) { TVChannel.EXAMPLE }) + ) + } +} + +@Immutable +data class TVGroupList(val value: List = emptyList()) : List by value { + companion object { + val EXAMPLE = TVGroupList(List(5) { TVGroup.EXAMPLE.copy(title = "Group $it") }) + + fun TVGroupList.findGroupIndex(channel: TVChannel) = + indexOfFirst { it.channels.contains(channel) } + + fun TVGroupList.findChannelIndex(channel: TVChannel) = + flatMap { it.channels }.indexOf(channel) + + val TVGroupList.channels: List + get() = flatMap { it.channels } + } +} + +@Immutable +data class TVChannelList(val value: List = emptyList()) : List by value { + companion object { + val EXAMPLE = TVChannelList(List(10) { TVChannel.EXAMPLE.copy() }) + } +} + +data class M3uData( + var epgUrl: String?, + val sources: List +) + +// IPTV解析器 +interface IptvParser { + fun isSupport(url: String, data: String): Boolean + suspend fun parse(data: String): M3uData + + companion object { + val instances = listOf(M3uIptvParser()) + } +} + +class M3uIptvParser : IptvParser { + override fun isSupport(url: String, data: String) = data.startsWith("#EXTM3U") + + override suspend fun parse(data: String): M3uData { + val lines = data.split("\r\n", "\n").filter { it.isNotBlank() } + val channels = mutableListOf() + var xTvgUrl: String? = null + + lines.windowed(2) { (line1, line2) -> + when { + line1.startsWith("#EXTM3U") -> { + xTvgUrl = Regex("x-tvg-url=\"(.+?)\"").find(line1)?.groupValues?.get(1)?.trim() + } + line1.startsWith("#EXTINF") && !line2.startsWith("#") -> { + val title = line1.split(",").lastOrNull()?.trim() ?: return@windowed + val attributes = parseTvgAttributes(line1) + channels.add( + TVSource( + tvgId = attributes["tvg-id"], + tvgName = attributes["tvg-name"], + tvgLogo = attributes["tvg-logo"], + groupTitle = attributes["group-title"], + title = title, + url = line2.trim() + ) + ) + } + } + } + + return M3uData(epgUrl = xTvgUrl, channels) + } + + private fun parseTvgAttributes(line: String): Map = + Regex("""(\S+?)="(.+?)"""").findAll(line) + .associate { it.groupValues[1] to it.groupValues[2].trim() } +} + +// 合并后的 IPTV 提供者和仓库 +class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { + private var groupList: TVGroupList = TVGroupList() + private var epgList: EpgList = EpgList() + + override suspend fun load() { + val (sources, epgUrls) = fetchIPTVSources() + groupList = processChannelSources(sources) + epgList = fetchEPGData(epgUrls) + } + + override fun groups(): TVGroupList = groupList + + override suspend fun epg(): EpgList = epgList + + private suspend fun fetchIPTVSources(): Pair, List> { + val allSources = mutableListOf() + val epgUrls = mutableListOf() + + val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) } + + iptvUrls.forEach { url -> + val m3u = fetchDataWithRetry { getChannelSourceList(sourceUrl = url) } + allSources.addAll(m3u.sources) + m3u.epgUrl?.let { epgUrls.add(it) } + } + + if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL) + + return Pair(allSources, epgUrls.distinct()) + } + + private suspend fun fetchEPGData(epgUrls: List): EpgList { + val epgChannels = mutableListOf() + epgUrls.forEach { url -> + val epg = fetchDataWithRetry { epgRepository.getEpgList(url) } + epgChannels.addAll(epg.value) + } + return EpgList(epgChannels.distinctBy { it.id }) + } + + private fun processChannelSources(sources: List): TVGroupList { + val channelList = sources.groupBy { it.name } + .map { (name, channelSources) -> + TVChannel( + name = name, + title = channelSources.first().title, + sources = channelSources + ) + } + + return TVGroupList( + channelList.groupBy { it.groupTitle ?: "其他" } + .map { (title, channels) -> TVGroup(title = title, channels = TVChannelList(channels)) } + ) + } + + private suspend fun fetchDataWithRetry(fetch: suspend () -> T): T { + repeat(Constants.HTTP_RETRY_COUNT) { + try { + return fetch() + } catch (e: Exception) { + if (it == Constants.HTTP_RETRY_COUNT - 1) throw e + delay(Constants.HTTP_RETRY_INTERVAL) + } + } + throw IllegalStateException("Failed to fetch data after ${Constants.HTTP_RETRY_COUNT} attempts") + } + + private suspend fun fetchSource(sourceUrl: String) = withContext(Dispatchers.IO) { + Log.d("iptv", sourceUrl) + val client = OkHttpClient() + val request = Request.Builder().url(sourceUrl).build() + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Exception("fetchSource failed: ${response.code}") + response.body?.string()?.trim() ?: throw Exception("Empty response body") + } + } catch (ex: Exception) { + Log.d("iptv", "获取远程直播源失败: $sourceUrl") + throw Exception("获取远程直播源失败,请检查网络连接", ex) + } + } + + private suspend fun getChannelSourceList(sourceUrl: String): M3uData { + val sourceData = fetchSource(sourceUrl) + val parser = IptvParser.instances.first { it.isSupport(sourceUrl, sourceData) } + return parser.parse(sourceData).also { + Log.i("iptv", "解析直播源完成:${it.sources.size}个资源, $sourceUrl") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/iptv/IptvRepository.kt b/app/src/main/java/me/lsong/mytv/iptv/IptvRepository.kt deleted file mode 100644 index 71fe0aa..0000000 --- a/app/src/main/java/me/lsong/mytv/iptv/IptvRepository.kt +++ /dev/null @@ -1,43 +0,0 @@ -package me.lsong.mytv.iptv - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import me.lsong.mytv.iptv.parser.IptvParser -import me.lsong.mytv.iptv.parser.M3uData - -/** - * 直播源获取 - */ -class IptvRepository { - /** - * 获取远程直播源数据 - */ - private suspend fun fetchSource(sourceUrl: String) = withContext(Dispatchers.IO) { - val client = OkHttpClient() - val request = Request.Builder().url(sourceUrl).build() - try { - with(client.newCall(request).execute()) { - if (!isSuccessful) { - throw Exception("fetchSource failed: $code") - } - return@with body!!.string().trim() - } - } catch (ex: Exception) { - throw Exception("获取远程直播源失败,请检查网络连接", ex) - } - } - - /** - * 获取直播源列表 - */ - suspend fun getChannelSourceList(sourceUrl: String): M3uData { - val sourceData = fetchSource(sourceUrl) - val parser = IptvParser.instances.first { it.isSupport(sourceUrl, sourceData) } - val m3u = parser.parse(sourceData) - Log.i("iptv","解析直播源完成:${m3u.sources.size}个资源, $sourceUrl") - return m3u - } -} \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/iptv/TVChannel.kt b/app/src/main/java/me/lsong/mytv/iptv/TVChannel.kt deleted file mode 100644 index 39d8504..0000000 --- a/app/src/main/java/me/lsong/mytv/iptv/TVChannel.kt +++ /dev/null @@ -1,82 +0,0 @@ -package me.lsong.mytv.iptv - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.vector.ImageVector - -data class TVSource( - val tvgId: String?, - val tvgLogo: String?, - val tvgName: String?, - val groupTitle: String?, - val title: String, - val url: String, -) { - val name: String - get() = (tvgName ?: tvgId ?: title).toString() - - companion object { - val EXAMPLE = TVSource( - tvgId = "cctv1", - tvgName = "cctv1", - tvgLogo = "https://live.fanmingming.com/tv/CCTV1.png", - title = "CCTV-1", - groupTitle = "央视", - url = "https://pi.0472.org/chc/ym.m3u8" - ) - } -} - -/** - * 直播源 - */ -@Immutable -data class TVChannel( - /** - * 直播源名称 - */ - val name: String = "", - /** - * 频道名称 - */ - val title: String = "", - val sources: List = emptyList(), -) { - - // val name: String - // get() = sources.first().name - - // val title: String - // get() = sources.first().title - - val logo: String? - get() = sources.firstNotNullOfOrNull { it.tvgLogo } - - val groupTitle: String? - get() = sources.firstNotNullOfOrNull { it.groupTitle } - /** - * 播放地址列表 - */ - val urls: List - get() = sources.map { it.url } - - companion object { - val EXAMPLE = TVChannel( - title = "测试频道", - sources = listOf( - TVSource.EXAMPLE - ) - ) - } -} - -/** - * 直播源列表 - */ -@Immutable -data class TVChannelList( - val value: List = emptyList(), -) : List by value { - companion object { - val EXAMPLE = TVChannelList(List(10) { _ -> TVChannel.EXAMPLE.copy() }) - } -} diff --git a/app/src/main/java/me/lsong/mytv/iptv/TVGroup.kt b/app/src/main/java/me/lsong/mytv/iptv/TVGroup.kt deleted file mode 100644 index 3d7572a..0000000 --- a/app/src/main/java/me/lsong/mytv/iptv/TVGroup.kt +++ /dev/null @@ -1,55 +0,0 @@ -package me.lsong.mytv.iptv - -import androidx.compose.runtime.Immutable - -/** - * 直播源分组 - */ -data class TVGroup( - /** - * 分组名称 - */ - val title: String = "", - /** - * 直播源列表 - */ - val channels: TVChannelList = TVChannelList(), -) { - // val title: String - // get() = name ?: channels.first().groupTitle ?: "其他" - - companion object { - val EXAMPLE = TVGroup( - title = "测试分组", - channels = TVChannelList( - List(10) { idx -> - TVChannel.EXAMPLE - }, - ) - ) - } -} - - -/** - * 直播源分组列表 - */ -@Immutable -data class TVGroupList( - val value: List = emptyList(), -) : List by value { - companion object { - val EXAMPLE = TVGroupList(List(5) { groupIdx -> - TVGroup.EXAMPLE.copy(title = "Group $groupIdx") - }) - - fun TVGroupList.findGroupIndex(iptv: TVChannel) = - this.indexOfFirst { group -> group.channels.any { it == iptv } } - - fun TVGroupList.findChannelIndex(iptv: TVChannel) = - this.flatMap { it.channels }.indexOfFirst { it == iptv } - - val TVGroupList.channels: List - get() = this.flatMap { it.channels } - } -} \ No newline at end of file diff --git a/app/src/main/java/me/lsong/mytv/iptv/parser/IptvParser.kt b/app/src/main/java/me/lsong/mytv/iptv/parser/IptvParser.kt deleted file mode 100644 index 22fb95d..0000000 --- a/app/src/main/java/me/lsong/mytv/iptv/parser/IptvParser.kt +++ /dev/null @@ -1,30 +0,0 @@ -package me.lsong.mytv.iptv.parser - -import me.lsong.mytv.iptv.TVSource - -data class M3uData( - var epgUrl: String?, - val sources: List, -) - -/** - * 直播源数据解析接口 - */ -interface IptvParser { - /** - * 是否支持该直播源格式 - */ - fun isSupport(url: String, data: String): Boolean - - /** - * 解析直播源数据 - */ - suspend fun parse(data: String): M3uData - - companion object { - val instances = listOf( - M3uIptvParser(), - ) - } -} - diff --git a/app/src/main/java/me/lsong/mytv/iptv/parser/M3uIptvParser.kt b/app/src/main/java/me/lsong/mytv/iptv/parser/M3uIptvParser.kt deleted file mode 100644 index 44c497d..0000000 --- a/app/src/main/java/me/lsong/mytv/iptv/parser/M3uIptvParser.kt +++ /dev/null @@ -1,55 +0,0 @@ -package me.lsong.mytv.iptv.parser - -import me.lsong.mytv.iptv.TVSource - - - -class M3uIptvParser : IptvParser { - override fun isSupport(url: String, data: String): Boolean { - return data.startsWith("#EXTM3U") - } - override suspend fun parse(data: String): M3uData { - val lines = data.split("\r\n", "\n").filter { it.isNotBlank() } - val channels = mutableListOf() - var xTvgUrl: String? = null - for (i in lines.indices) { - val line = lines[i] - when { - line.startsWith("#EXTM3U") -> { - xTvgUrl = Regex("x-tvg-url=\"(.+?)\"").find(line)?.groupValues?.get(1)?.trim() - } - line.startsWith("#EXTINF") -> { - if (i + 1 >= lines.size) break // Ensure there's a next line for the URL - - val title = line.split(",").lastOrNull()?.trim() ?: continue - val attributes = parseTvgAttributes(line) - val url = lines[i + 1].trim() - - if (url.isEmpty() || url.startsWith("#")) continue // Skip if URL is empty or another #EXTINF line - - channels.add( - TVSource( - tvgId = attributes["tvg-id"], - tvgName = attributes["tvg-name"], - tvgLogo = attributes["tvg-logo"], - groupTitle = attributes["group-title"], - title = title, - url = url, - ) - ) - } - } - } - return M3uData(epgUrl = xTvgUrl, channels.toList()) - } - - private fun parseTvgAttributes(line: String): Map { - val attributes = mutableMapOf() - val regex = Regex("""(\S+?)="(.+?)"""") - regex.findAll(line).forEach { matchResult -> - val (key, value) = matchResult.destructured - attributes[key] = value.trim() - } - return attributes - } -} \ 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 9967a7c..7814c70 100644 --- a/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt +++ b/app/src/main/java/me/lsong/mytv/ui/MainScreen.kt @@ -7,11 +7,14 @@ 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.Row 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.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -19,7 +22,9 @@ 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 @@ -39,43 +44,41 @@ 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.IPTVProvider 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.iptv.TVProvider 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.MyTvSettingsCategories import me.lsong.mytv.ui.settings.MyTvSettingsViewModel +import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryContent 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() }, + groupListProvider: () -> TVGroupList = { TVGroupList() }, onSelected: (TVChannel) -> Unit = {}, + onSettings: () -> Unit = {}, onUserAction: () -> Unit = {} ) { val groupList = groupListProvider() @@ -88,16 +91,16 @@ fun MyTvMenuWidget( } } - val currentGroup = remember(groupList, currentChannel) { + var currentGroup = remember(groupList, currentChannel) { groups.firstOrNull { it.title == groupList[groupList.findGroupIndex(currentChannel)].title } ?: MyTvMenuItem() } val currentMenuItem = remember(currentChannel) { MyTvMenuItem( - icon = currentChannel.logo, + icon = currentChannel.logo ?: "", title = currentChannel.title, - description = epgList.currentProgrammes(currentChannel)?.now?.title ?: currentChannel.name + description = epgList.currentProgrammes(currentChannel)?.now?.title ) } @@ -106,24 +109,25 @@ fun MyTvMenuWidget( MyTvMenuItem( icon = channel.logo ?: "", title = channel.title, - description = epgList.currentProgrammes(channel)?.now?.title ?: channel.name + description = epgList.currentProgrammes(channel)?.now?.title ) } ?: 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 - ) + Row { + MyTvMenu( + groups = groups, + itemsProvider = itemsProvider, + currentGroup = currentGroup, + currentItem = currentMenuItem, + onItemSelected = { selectedItem -> + val selectedChannel = groupList.channels.first { it.title == selectedItem.title } + onSelected(selectedChannel) + }, + modifier = modifier, + onUserAction = onUserAction + ) + } } @Composable @@ -144,7 +148,7 @@ fun MainScreen( is LeanbackMainUiState.Error -> StartScreen(state) is LeanbackMainUiState.Ready -> MainContent( modifier = modifier, - groupList = state.tvGroupList, + groups = state.groups, epgList = state.epgList, onBackPressed = onBackPressed, settingsViewModel = settingsViewModel @@ -206,18 +210,27 @@ private fun StartScreen(state: LeanbackMainUiState) { } } +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, onBackPressed: () -> Unit = {}, epgList: EpgList = EpgList(), - groupList: TVGroupList = TVGroupList(), + groups: TVGroupList = TVGroupList(), settingsViewModel: MyTvSettingsViewModel = viewModel(), ) { val videoPlayerState = rememberLeanbackVideoPlayerState() val mainContentState = rememberMainContentState( videoPlayerState = videoPlayerState, - tvGroupList = groupList, + groups = groups, ) val focusRequester = remember { FocusRequester() } @@ -292,7 +305,7 @@ fun MainContent( LeanbackVisible({ mainContentState.isMenuVisible && !mainContentState.isChannelInfoVisible }) { MyTvMenuWidget( epgListProvider = { epgList }, - groupListProvider = { groupList }, + groupListProvider = { groups }, channelProvider = { mainContentState.currentChannel }, onSelected = { channel -> mainContentState.changeCurrentChannel(channel) } ) @@ -336,10 +349,11 @@ fun LeanbackBackPressHandledArea( content = content, ) +// MainViewModel.kt class MainViewModel : ViewModel() { - private val iptvRepository = IptvRepository() - private val epgRepository = EpgRepository() - + private val providers: List = listOf( + IPTVProvider(EpgRepository()) + ) private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) val uiState: StateFlow = _uiState.asStateFlow() @@ -350,94 +364,24 @@ class MainViewModel : ViewModel() { } 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 + try { + _uiState.value = LeanbackMainUiState.Loading("Initializing providers...") + providers.forEachIndexed { index, provider -> + _uiState.value = LeanbackMainUiState.Loading("Initializing provider ${index + 1}/${providers.size}...") + provider.load() } - 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") - } + val groupList = providers.flatMap { it.groups() } + val epgList = providers.map { it.epg() }.reduce { acc, epgList -> (acc + epgList) as EpgList } - 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 + _uiState.value = LeanbackMainUiState.Ready( + groups = TVGroupList(groupList), + epgList = epgList + ) + } catch (error: Exception) { + _uiState.value = LeanbackMainUiState.Error(error.message) + } } - 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") 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 14f6905..0911fdf 100644 --- a/app/src/main/java/me/lsong/mytv/ui/MainState.kt +++ b/app/src/main/java/me/lsong/mytv/ui/MainState.kt @@ -26,7 +26,7 @@ import kotlin.math.max class MainContentState( coroutineScope: CoroutineScope, private val videoPlayerState: LeanbackVideoPlayerState, - private val tvGroupList: TVGroupList, + private val groups: TVGroupList, ) { private var _currentChannel by mutableStateOf(TVChannel()) val currentChannel get() = _currentChannel @@ -49,11 +49,11 @@ class MainContentState( } val currentChannelIndex - get() = tvGroupList.findChannelIndex(_currentChannel) + get() = groups.findChannelIndex(_currentChannel) init { - changeCurrentChannel(tvGroupList.channels.getOrElse(Settings.iptvLastIptvIdx) { - tvGroupList.firstOrNull()?.channels?.firstOrNull() ?: TVChannel() + changeCurrentChannel(groups.channels.getOrElse(Settings.iptvLastIptvIdx) { + groups.firstOrNull()?.channels?.firstOrNull() ?: TVChannel() }) videoPlayerState.onReady { @@ -85,16 +85,16 @@ class MainContentState( } private fun getPrevChannel(): TVChannel { - val currentIndex = tvGroupList.findChannelIndex(_currentChannel) - return tvGroupList.channels.getOrElse(currentIndex - 1) { - tvGroupList.lastOrNull()?.channels?.lastOrNull() ?: TVChannel() + val currentIndex = groups.findChannelIndex(_currentChannel) + return groups.channels.getOrElse(currentIndex - 1) { + groups.lastOrNull()?.channels?.lastOrNull() ?: TVChannel() } } private fun getNextChannel(): TVChannel { - val currentIndex = tvGroupList.findChannelIndex(_currentChannel) - return tvGroupList.channels.getOrElse(currentIndex + 1) { - tvGroupList.firstOrNull()?.channels?.firstOrNull() ?: TVChannel() + val currentIndex = groups.findChannelIndex(_currentChannel) + return groups.channels.getOrElse(currentIndex + 1) { + groups.firstOrNull()?.channels?.firstOrNull() ?: TVChannel() } } @@ -163,12 +163,12 @@ class MainContentState( fun rememberMainContentState( coroutineScope: CoroutineScope = rememberCoroutineScope(), videoPlayerState: LeanbackVideoPlayerState = rememberLeanbackVideoPlayerState(), - tvGroupList: TVGroupList = TVGroupList(), + groups: TVGroupList = TVGroupList(), ) = remember { MainContentState( coroutineScope = coroutineScope, videoPlayerState = videoPlayerState, - tvGroupList = tvGroupList, + groups = groups, ) } 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 c729b1f..eacd675 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 @@ -1,5 +1,4 @@ package me.lsong.mytv.ui.widgets - import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -7,17 +6,16 @@ import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -39,14 +37,13 @@ import androidx.tv.foundation.lazy.list.itemsIndexed import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.Icon import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.flow.distinctUntilChanged -import me.lsong.mytv.epg.EpgList -import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes -import me.lsong.mytv.iptv.TVChannel -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.ui.settings.MyTvSettingsCategories +import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryContent import me.lsong.mytv.ui.theme.LeanbackTheme import me.lsong.mytv.utils.handleLeanbackKeyEvents @@ -59,18 +56,19 @@ data class MyTvMenuItem( @Composable fun MyTvMenuItem( modifier: Modifier = Modifier, - menuItemProvider: () -> MyTvMenuItem = { MyTvMenuItem(title = "") }, - focusRequesterProvider: () -> FocusRequester = { FocusRequester() }, - isSelectedProvider: () -> Boolean = { false }, - isFocusedProvider: () -> Boolean = { false }, + item: MyTvMenuItem, + isFocused: Boolean = false, + isSelected: Boolean = false, + onFocused: () -> Unit = {}, onSelected: () -> Unit = {}, - onFocused: (MyTvMenuItem) -> Unit = {}, - onFavoriteToggle: () -> Unit = {} + onFavoriteToggle: () -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { - val menuItem = menuItemProvider() - val focusRequester = focusRequesterProvider() - var isFocused by remember { mutableStateOf(isFocusedProvider()) } - + LaunchedEffect(isSelected) { + if (isSelected) { + focusRequester.requestFocus() + } + } CompositionLocalProvider( LocalContentColor provides if (isFocused) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onBackground @@ -80,36 +78,27 @@ fun MyTvMenuItem( ) { androidx.tv.material3.ListItem( modifier = modifier + .align(Alignment.Center) .focusRequester(focusRequester) - .onFocusChanged { - isFocused = it.isFocused || it.hasFocus - if (isFocused) { - onFocused(menuItem) - } - } + .onFocusChanged { if (it.isFocused) onFocused() } .handleLeanbackKeyEvents( - key = menuItem.hashCode(), - onSelect = { - if (isFocused) onSelected() - else focusRequester.requestFocus() - }, - onLongSelect = { - if (isFocused) onFavoriteToggle() - else focusRequester.requestFocus() - }, + key = item.hashCode(), + onSelect = onSelected, + onLongSelect = onFavoriteToggle, ), colors = ListItemDefaults.colors( + focusedContentColor = MaterialTheme.colorScheme.background, focusedContainerColor = MaterialTheme.colorScheme.onBackground, selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), ), - onClick = { onSelected() }, - selected = isSelectedProvider(), - leadingContent = menuItem.icon?.let { icon -> + onClick = onSelected, + selected = isSelected, + leadingContent = item.icon?.let { icon -> { when (icon) { is ImageVector -> Icon( imageVector = icon, - contentDescription = menuItem.title, + contentDescription = item.title, modifier = Modifier.size(24.dp) ) is String -> if (icon.isEmpty()) { @@ -119,25 +108,26 @@ fun MyTvMenuItem( .background(color = MaterialTheme.colorScheme.primary) .wrapContentHeight(align = Alignment.CenterVertically), textAlign = TextAlign.Center, - text = menuItem.title.take(2).uppercase(), + text = item.title.take(2).uppercase(), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimary, ) } else { AsyncImage( - model = menuItem.icon, - contentDescription = menuItem.title, + model = icon, + contentDescription = item.title, modifier = Modifier.size(40.dp) ) } + else -> null } } }, - headlineContent = { Text(text = menuItem.title, maxLines = 2) }, - supportingContent = { - if (menuItem.description != null) { + headlineContent = { Text(text = item.title, maxLines = 2) }, + supportingContent = item.description?.let { + { Text( - text = menuItem.description, + text = it, style = MaterialTheme.typography.labelMedium, maxLines = 1, modifier = Modifier.alpha(0.8f), @@ -153,49 +143,48 @@ fun MyTvMenuItem( fun MyTvMenu( groups: List, itemsProvider: (String) -> List, - currentGroupProvider: () -> MyTvMenuItem, - currentItemProvider: () -> MyTvMenuItem, - onGroupSelected: (MyTvMenuItem) -> Unit, - onItemSelected: (MyTvMenuItem) -> Unit, + currentGroup: MyTvMenuItem, + currentItem: MyTvMenuItem, + onGroupFocused: (MyTvMenuItem) -> Unit = {}, + onGroupSelected: (MyTvMenuItem) -> Unit = {}, + onItemSelected: (MyTvMenuItem) -> Unit = {}, modifier: Modifier = Modifier, - onUserAction: () -> Unit = {} + onUserAction: () -> Unit = {}, ) { - var focusedGroup by remember { mutableStateOf(currentGroupProvider()) } - var focusedItem by remember { mutableStateOf(currentItemProvider()) } - var currentItems by remember { mutableStateOf(itemsProvider(focusedGroup.title)) } - + var focusedGroup by remember { mutableStateOf(currentGroup) } + var focusedItem by remember { mutableStateOf(currentItem) } + var items by remember { mutableStateOf(itemsProvider(focusedGroup.title)) } val rightListFocusRequester = remember { FocusRequester() } Row(modifier = modifier) { MyTvMenuItemList( - onUserAction = onUserAction, - menuItemsProvider = { groups }, - selectedItemProvider = { focusedGroup }, + items = groups, + selectedItem = focusedGroup, onFocused = { menuGroupItem -> focusedGroup = menuGroupItem - currentItems = itemsProvider(menuGroupItem.title) + items = itemsProvider(menuGroupItem.title) + onGroupFocused(focusedGroup) }, onSelected = { menuGroupItem -> focusedGroup = menuGroupItem - currentItems = itemsProvider(menuGroupItem.title) - focusedItem = currentItems.firstOrNull { it.title == focusedItem.title } ?: currentItems.firstOrNull() ?: MyTvMenuItem() - onGroupSelected(menuGroupItem) + items = itemsProvider(menuGroupItem.title) + focusedItem = items.firstOrNull() ?: MyTvMenuItem() + onGroupSelected(focusedGroup) rightListFocusRequester.requestFocus() - } + }, + onUserAction = onUserAction ) MyTvMenuItemList( - focusRequester = rightListFocusRequester, - menuItemsProvider = { currentItems }, - selectedItemProvider = { focusedItem }, - onUserAction = onUserAction, - onFocused = { menuItem -> - focusedItem = menuItem - }, + items = items, + selectedItem = focusedItem, onSelected = { menuItem -> focusedItem = menuItem - onItemSelected(menuItem) - } + onItemSelected(focusedItem) + }, + onUserAction = onUserAction, + focusRequester = rightListFocusRequester ) + } LaunchedEffect(Unit) { @@ -205,40 +194,29 @@ fun MyTvMenu( @Composable fun MyTvMenuItemList( - modifier: Modifier = Modifier, - focusRequester: FocusRequester = remember { FocusRequester() }, - menuItemsProvider: () -> List = { emptyList() }, - selectedItemProvider: () -> MyTvMenuItem = { MyTvMenuItem() }, + items: List, + selectedItem: MyTvMenuItem = items.firstOrNull() ?: MyTvMenuItem(), onUserAction: () -> Unit = {}, onFocused: (MyTvMenuItem) -> Unit = {}, onSelected: (MyTvMenuItem) -> Unit = {}, - onFavoriteToggle: (MyTvMenuItem) -> Unit = {} + onFavoriteToggle: (MyTvMenuItem) -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, + modifier: Modifier = Modifier ) { - val menuItems = menuItemsProvider() - val selectedItem = selectedItemProvider() - val itemFocusRequesterList = remember(menuItems) { - List(menuItems.size) { FocusRequester() } - } - var focusedMenuItem by remember { mutableStateOf(selectedItem) } - val selectedIndex = remember(selectedItem, menuItems) { - menuItems.indexOf(selectedItem).takeIf { it != -1 } ?: 0 - } - val listState = rememberTvLazyListState( - initialFirstVisibleItemIndex = maxOf(0, selectedIndex - 2) - ) + var focusedItem by remember { mutableStateOf(selectedItem) } + val selectedIndex = remember(selectedItem, items) { items.indexOf(selectedItem) } + val itemFocusRequesterList = remember(items) { List(items.size) { FocusRequester() } } + val listState = rememberTvLazyListState() LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .distinctUntilChanged() - .collect { _ -> onUserAction() } + .collect { onUserAction() } } - LaunchedEffect(selectedItem, menuItems) { - val index = menuItems.indexOf(selectedItem) - if (index != -1) { - listState.scrollToItem(maxOf(0, index - 2)) - itemFocusRequesterList[index].requestFocus() - } + LaunchedEffect(selectedItem, items) { + val index = items.indexOf(selectedItem) + listState.scrollToItem(maxOf(0, index)) } TvLazyColumn( @@ -251,17 +229,16 @@ fun MyTvMenuItemList( .background(MaterialTheme.colorScheme.background.copy(0.8f)) .focusRequester(focusRequester), ) { - itemsIndexed(menuItems, key = { _, item -> item.hashCode() }) { index, item -> - val isSelected by remember { derivedStateOf { item == selectedItem } } + itemsIndexed(items, key = { _, item -> item.hashCode() }) { index, item -> MyTvMenuItem( - menuItemProvider = { item }, - focusRequesterProvider = { itemFocusRequesterList[index] }, - isSelectedProvider = { isSelected }, - isFocusedProvider = { item == focusedMenuItem }, + item = item, + focusRequester = itemFocusRequesterList[index], + isSelected = selectedIndex == index, + isFocused = selectedIndex == index, onSelected = { onSelected(item) }, onFocused = { - focusedMenuItem = it - onFocused(it) + focusedItem = item + onFocused(item) }, onFavoriteToggle = { onFavoriteToggle(item) } ) @@ -269,39 +246,17 @@ fun MyTvMenuItemList( } } -@Preview -@Composable -private fun MyTvMenuItemComponentPreview() { - LeanbackTheme { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - MyTvMenuItem( - menuItemProvider = { MyTvMenuItem(title = "Channel 1", description = "Current Program 1") }, - ) - - MyTvMenuItem( - isFocusedProvider = { true }, - menuItemProvider = { MyTvMenuItem(title = "Channel 2", description = "Current Program 2") }, - ) - } - } -} - @Preview @Composable private fun MyTvMenuItemListPreview() { LeanbackTheme { MyTvMenuItemList( modifier = Modifier.padding(20.dp), - menuItemsProvider = { - listOf( - MyTvMenuItem(title = "Channel 1", description = "Current Program 1"), - MyTvMenuItem(title = "Channel 2", description = "Current Program 2"), - MyTvMenuItem(title = "Channel 3", description = "Current Program 3") - ) - }, + items = listOf( + MyTvMenuItem(title = "Channel 1", description = "Current Program 1"), + MyTvMenuItem(title = "Channel 2", description = "Current Program 2"), + MyTvMenuItem(title = "Channel 3", description = "Current Program 3") + ) ) } } diff --git a/app/src/main/java/me/lsong/mytv/utils/Constants.kt b/app/src/main/java/me/lsong/mytv/utils/Constants.kt index e9ccc50..0977e57 100644 --- a/app/src/main/java/me/lsong/mytv/utils/Constants.kt +++ b/app/src/main/java/me/lsong/mytv/utils/Constants.kt @@ -1,7 +1,5 @@ package me.lsong.mytv.utils -import androidx.compose.ui.text.intl.Locale - /** * 常量 */ @@ -36,15 +34,10 @@ object Constants { */ const val EPG_REFRESH_TIME_THRESHOLD = 2 // 不到2点不刷新 - /** - * GitHub加速代理地址 - */ - const val GITHUB_PROXY = "https://mirror.ghproxy.com/" - /** * HTTP请求重试次数 */ - const val HTTP_RETRY_COUNT = 10L + const val HTTP_RETRY_COUNT = 10 /** * HTTP请求重试间隔时间(毫秒) @@ -56,26 +49,11 @@ object Constants { */ const val VIDEO_PLAYER_USER_AGENT = "ExoPlayer" - /** - * 日志历史最大保留条数 - */ - const val LOG_HISTORY_MAX_SIZE = 50 - /** * 播放器加载超时 */ const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒 - /** - * 界面 超时未操作自动关闭界面 - */ - const val UI_SCREEN_AUTO_CLOSE_DELAY = 1000L * 15 // 15秒 - - /** - * 界面 时间显示前后范围 - */ - const val UI_TIME_SHOW_RANGE = 1000L * 30 // 前后30秒 - /** * 界面 临时面板界面显示时间 */