diff --git a/README.md b/README.md index 836b27f..3979425 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- DuckTV Icon + DuckTV Icon
DuckTV

diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 658a5d1..2c0ee28 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,10 +20,10 @@ android { defaultConfig { applicationId = "me.lsong.mytv" - minSdk = 21 + minSdk = 31 targetSdk = 34 - versionCode = 2 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.0.$versionCode" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 722d9d0..cc335b8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,10 +9,10 @@ () - var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - when (eventType) { - XmlPullParser.START_TAG -> { - if (parser.name == "channel") { - val channelId = parser.getAttributeValue(null, "id") - parser.nextTag() - val channelDisplayName = parser.nextText() - val channel = EpgChannel( - id = channelId, - title = channelDisplayName, - ) - // Log.d("epg", "${channel.id}, ${channel.title}") - epgMap[channelId] = channel - } else if (parser.name == "programme") { - val channelId = parser.getAttributeValue(null, "channel") - val startTime = parser.getAttributeValue(null, "start") - val stopTime = parser.getAttributeValue(null, "stop") - parser.nextTag() - val title = parser.nextText() - fun parseTime(time: String): Long { - if (time.length < 14) return 0 - return SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()).parse(time)?.time ?: 0 - } - val programme = EpgProgramme( - channelId = channelId, - startAt = parseTime(startTime), - endAt = parseTime(stopTime), - title = title, - ) - if (epgMap.containsKey(channelId)) { - // Log.d("epg", "${programme.channelId}, ${programme.title}") - epgMap[channelId] = epgMap[channelId]!!.copy( - programmes = epgMap[channelId]!!.programmes + listOf(programme) - ) - } - } - } - } - eventType = parser.next() + suspend fun getEpgList(url: String): EpgList = withContext(Dispatchers.Default) { + try { + return@withContext parseEpgXml(request(url)) + } catch (ex: Exception) { + Log.e("epg", "获取节目单失败", ex) + throw Exception(ex) } - - Log.i("epg","解析节目单完成,共${epgMap.size}个频道") - return EpgList(epgMap.values.toList()) } +} - private suspend fun fetchXml(url: String): String = withContext(Dispatchers.IO) { - Log.d("epg", "获取远程节目单xml: $url") - - val client = OkHttpClient() - val request = Request.Builder().url(url).build() +fun request(url: String): String{ + val client = OkHttpClient() + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("request failed: $response.code") + } + val contentType = response.header("content-type") + if (contentType?.startsWith("text/")!!) { + return response.body!!.string() + } + val gzData = response.body!!.bytes() + val stringBuilder = StringBuilder() + val gzipInputStream = GZIPInputStream(ByteArrayInputStream(gzData)); + val reader = BufferedReader(InputStreamReader(gzipInputStream)); + var line: String? + while (reader.readLine().also { line = it } != null) { + stringBuilder.append(line).append("\n") + } + response.close() + return stringBuilder.toString() + } +} - try { - with(client.newCall(request).execute()) { - if (!isSuccessful) { - throw Exception("获取远程节目单xml失败: $code") +/** + * 解析节目单xml + */ +private fun parseEpgXml(xmlString: String): EpgList { + val parser: XmlPullParser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(StringReader(xmlString)) + val epgMap = mutableMapOf() + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + if (parser.name == "channel") { + val channelId = parser.getAttributeValue(null, "id") + parser.nextTag() + val channelDisplayName = parser.nextText() + val channel = EpgChannel( + id = channelId, + title = channelDisplayName, + ) + // Log.d("epg", "${channel.id}, ${channel.title}") + epgMap[channelId] = channel + } else if (parser.name == "programme") { + val channelId = parser.getAttributeValue(null, "channel") + val startTime = parser.getAttributeValue(null, "start") + val stopTime = parser.getAttributeValue(null, "stop") + parser.nextTag() + val title = parser.nextText() + fun parseTime(time: String): Long { + if (time.length < 14) return 0 + return SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()).parse(time)?.time ?: 0 + } + val programme = EpgProgramme( + channelId = channelId, + startAt = parseTime(startTime), + endAt = parseTime(stopTime), + title = title, + ) + if (epgMap.containsKey(channelId)) { + // Log.d("epg", "${programme.channelId}, ${programme.title}") + epgMap[channelId] = epgMap[channelId]!!.copy( + programmes = epgMap[channelId]!!.programmes + listOf(programme) + ) + } } - - val fetcher = EpgFetcher.instances.first { it.isSupport(url) } - - return@with fetcher.fetch(this) } - } catch (ex: Exception) { - throw Exception("获取远程节目单xml失败,请检查网络连接", ex) } + eventType = parser.next() } - suspend fun getEpgList(xmlUrl: String): EpgList = withContext(Dispatchers.Default) { - try { - val xmlString = fetchXml(xmlUrl) - return@withContext parseFromXml(xmlString) - } catch (ex: Exception) { - Log.e("epg", "获取节目单失败", ex) - throw Exception(ex) - } - } -} + Log.i("epg","解析节目单完成,共${epgMap.size}个频道") + return EpgList(epgMap.values.toList()) +} \ No newline at end of file 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 c95ad63..eea929a 100644 --- a/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt +++ b/app/src/main/java/me/lsong/mytv/providers/IPTVProvider.kt @@ -1,17 +1,23 @@ package me.lsong.mytv.providers import android.util.Log +import android.util.Xml 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.EpgProgramme import me.lsong.mytv.epg.EpgRepository import me.lsong.mytv.utils.Constants import me.lsong.mytv.utils.Settings import okhttp3.OkHttpClient import okhttp3.Request +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader +import java.text.SimpleDateFormat +import java.util.Locale // 数据类定义 @@ -92,48 +98,6 @@ data class TVChannelList(val value: List = emptyList()) : List -) - -class M3uParser { - fun parse(data: String): M3uData { - var xTvgUrl: String? = null - val channels = mutableListOf() - data - .trim() - .split("\r\n", "\n") - .filter { it.isNotBlank() } - .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() } -} - class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { private var groupList: TVGroupList = TVGroupList() private var epgList: EpgList = EpgList() @@ -157,8 +121,17 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) } iptvUrls.forEach { url -> val m3u = retry { getM3uChannels(sourceUrl = url) } - allSources.addAll(m3u.sources) - m3u.epgUrl?.let { epgUrls.add(it) } + allSources.addAll(m3u.channels.map { + TVSource( + tvgId = it.attributes["tvg-id"], + tvgLogo = it.attributes["tvg-logo"], + tvgName = it.attributes["tvg-name"], + groupTitle = it.attributes["group-title"], + title = it.title, + url = it.url, + ) + }) + epgUrls += m3u.headers["x-tvg-url"]?.split(",").orEmpty() } if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL) return Pair(allSources, epgUrls.distinct()) @@ -218,11 +191,7 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider { } private suspend fun getM3uChannels(sourceUrl: String): M3uData { - val parser = M3uParser() - val content = request(sourceUrl) - return parser.parse(content).also { - Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl") - } + return parseM3u(request(sourceUrl)) } } @@ -243,4 +212,123 @@ class MyTvProviderManager : TVProvider { } 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 +} + +data class M3uData( + val headers: Map, + val channels: List +) + +data class M3uSource( + val attributes: Map, + val title: String, + val url: String +) + +fun parseM3u(data: String): M3uData { + val lines = data.trim().split("\r\n", "\n").filter { it.isNotBlank() } + val headers = mutableMapOf() + val channels = mutableListOf() + var currentAttributes = mutableMapOf() + var currentTitle = "" + for (processedLine in lines) { + when { + processedLine.startsWith("#EXTM3U") -> { + headers.putAll(parseAttributes(processedLine.substring(7).trim())) + } + processedLine.startsWith("#EXTINF:") -> { + val (duration, rest) = processedLine.substring(8).split(',', limit = 2) + currentAttributes = parseAttributes(duration).toMutableMap() + currentTitle = rest.trim() + } + !processedLine.startsWith("#") -> { + channels.add(M3uSource(currentAttributes, currentTitle, processedLine.trim())) + currentAttributes = mutableMapOf() + currentTitle = "" + } + } + } + + return M3uData(headers, channels) +} + +fun parseAttributes(input: String): Map { + val attributes = mutableMapOf() + var remaining = input.trim().replace("\",\"", ",") + while (remaining.isNotEmpty()) { + val equalIndex = remaining.indexOf('=') + if (equalIndex == -1) break + + val key = remaining.substring(0, equalIndex).trim() + remaining = remaining.substring(equalIndex + 1).trim() + val value: String + if (remaining.startsWith("\"")) { + val endQuoteIndex = remaining.indexOf("\"", 1) + if (endQuoteIndex == -1) break + value = remaining.substring(1, endQuoteIndex) + remaining = remaining.substring(endQuoteIndex + 1).trim() + } else { + val spaceIndex = remaining.indexOf(' ') + if (spaceIndex == -1) { + value = remaining + remaining = "" + } else { + value = remaining.substring(0, spaceIndex) + remaining = remaining.substring(spaceIndex + 1).trim() + } + } + attributes[key] = value + } + return attributes +} + + +fun parseEpgXML(xmlString: String): List { + val epgMap = mutableMapOf() + val parser: XmlPullParser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(StringReader(xmlString)) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + if (parser.name == "channel") { + val channelId = parser.getAttributeValue(null, "id") + parser.nextTag() + val channelDisplayName = parser.nextText() + val channel = EpgChannel( + id = channelId, + title = channelDisplayName, + ) + Log.d("epg", "${channel.id}, ${channel.title}") + epgMap[channelId] = channel + } else if (parser.name == "programme") { + val channelId = parser.getAttributeValue(null, "channel") + val startTime = parser.getAttributeValue(null, "start") + val stopTime = parser.getAttributeValue(null, "stop") + parser.nextTag() + val title = parser.nextText() + fun parseTime(time: String): Long { + if (time.length < 14) return 0 + return SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()).parse(time)?.time ?: 0 + } + val programme = EpgProgramme( + channelId = channelId, + startAt = parseTime(startTime), + endAt = parseTime(stopTime), + title = title, + ) + if (epgMap.containsKey(channelId)) { + // Log.d("epg", "${programme.channelId}, ${programme.title}") + epgMap[channelId] = epgMap[channelId]!!.copy( + programmes = epgMap[channelId]!!.programmes + listOf(programme) + ) + } + } + } + } + eventType = parser.next() + } + Log.i("epg","解析节目单完成,共${epgMap.size}个频道") + return epgMap.values.toList() +} 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 af9411e..b1d1379 100644 --- a/app/src/main/java/me/lsong/mytv/utils/Constants.kt +++ b/app/src/main/java/me/lsong/mytv/utils/Constants.kt @@ -15,15 +15,16 @@ object Constants { */ const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u" // http://lsong.one:8888/IPTV.m3u + // https://live.fanmingming.com/tv/m3u/index.m3u // https://raw.githubusercontent.com/YueChan/Live/main/IPTV.m3u // https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.m3u - // https://live.fanmingming.com/tv/m3u/index.m3u + // https://raw.githubusercontent.com/yuanzl77/IPTV/main/live.m3u /** * 节目单XML地址 */ const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz" - // const val EPG_XML_URL = "https://live.fanmingming.com/e.xml" + // const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml" /** * HTTP请求重试次数 @@ -39,13 +40,4 @@ object Constants { * 播放器加载超时 */ const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒 - - /** - * 播放器 userAgent - */ - // const val VIDEO_PLAYER_USER_AGENT = "ExoPlayer" - // /** - // * 界面 临时面板界面显示时间 - // */ - // const val UI_TEMP_PANEL_SCREEN_SHOW_DURATION = 1500L // 1.5秒 } \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_banner.png b/app/src/main/res/drawable-xhdpi/ic_banner.png deleted file mode 100644 index e0d0b55..0000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_banner.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..b25f2d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xhdpi/tv_banner.png b/app/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 0000000..6c900a9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/icon-x1024.png b/icon-x1024.png deleted file mode 100644 index 13f9155..0000000 Binary files a/icon-x1024.png and /dev/null differ diff --git a/icon.png b/icon.png index b25f2d2..13f9155 100644 Binary files a/icon.png and b/icon.png differ