diff --git a/README.md b/README.md
index 836b27f..3979425 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
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