update
This commit is contained in:
parent
8486ea4bf6
commit
a48acc6862
@ -1,5 +1,5 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<img src="./icon.png" alt="DuckTV Icon" width="200">
|
<img src="./app/src/main/res/drawable-xhdpi/ic_launcher.png" alt="DuckTV Icon" width="200">
|
||||||
<br>DuckTV<br>
|
<br>DuckTV<br>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
@ -20,10 +20,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.lsong.mytv"
|
applicationId = "me.lsong.mytv"
|
||||||
minSdk = 21
|
minSdk = 31
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 2
|
versionCode = 4
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.$versionCode"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".MyTVApplication"
|
android:name=".MyTVApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:banner="@drawable/ic_banner"
|
android:banner="@drawable/tv_banner"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
@ -8,19 +8,56 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import me.lsong.mytv.epg.fetcher.EpgFetcher
|
import me.lsong.mytv.epg.fetcher.EpgFetcher
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节目单获取
|
* 节目单获取
|
||||||
*/
|
*/
|
||||||
class EpgRepository {
|
class EpgRepository {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* 解析节目单xml
|
* 解析节目单xml
|
||||||
*/
|
*/
|
||||||
private fun parseFromXml(xmlString: String): EpgList {
|
private fun parseEpgXml(xmlString: String): EpgList {
|
||||||
val parser: XmlPullParser = Xml.newPullParser()
|
val parser: XmlPullParser = Xml.newPullParser()
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
parser.setInput(StringReader(xmlString))
|
parser.setInput(StringReader(xmlString))
|
||||||
@ -69,36 +106,4 @@ class EpgRepository {
|
|||||||
|
|
||||||
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
|
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
|
||||||
return EpgList(epgMap.values.toList())
|
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()
|
|
||||||
|
|
||||||
try {
|
|
||||||
with(client.newCall(request).execute()) {
|
|
||||||
if (!isSuccessful) {
|
|
||||||
throw Exception("获取远程节目单xml失败: $code")
|
|
||||||
}
|
|
||||||
|
|
||||||
val fetcher = EpgFetcher.instances.first { it.isSupport(url) }
|
|
||||||
|
|
||||||
return@with fetcher.fetch(this)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
throw Exception("获取远程节目单xml失败,请检查网络连接", ex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,17 +1,23 @@
|
|||||||
package me.lsong.mytv.providers
|
package me.lsong.mytv.providers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.util.Xml
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.lsong.mytv.epg.EpgChannel
|
import me.lsong.mytv.epg.EpgChannel
|
||||||
import me.lsong.mytv.epg.EpgList
|
import me.lsong.mytv.epg.EpgList
|
||||||
|
import me.lsong.mytv.epg.EpgProgramme
|
||||||
import me.lsong.mytv.epg.EpgRepository
|
import me.lsong.mytv.epg.EpgRepository
|
||||||
import me.lsong.mytv.utils.Constants
|
import me.lsong.mytv.utils.Constants
|
||||||
import me.lsong.mytv.utils.Settings
|
import me.lsong.mytv.utils.Settings
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
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<TVChannel> = emptyList()) : List<TVChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class M3uData(
|
|
||||||
var epgUrl: String?,
|
|
||||||
val sources: List<TVSource>
|
|
||||||
)
|
|
||||||
|
|
||||||
class M3uParser {
|
|
||||||
fun parse(data: String): M3uData {
|
|
||||||
var xTvgUrl: String? = null
|
|
||||||
val channels = mutableListOf<TVSource>()
|
|
||||||
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<String, String> =
|
|
||||||
Regex("""(\S+?)="(.+?)"""").findAll(line)
|
|
||||||
.associate { it.groupValues[1] to it.groupValues[2].trim() }
|
|
||||||
}
|
|
||||||
|
|
||||||
class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
||||||
private var groupList: TVGroupList = TVGroupList()
|
private var groupList: TVGroupList = TVGroupList()
|
||||||
private var epgList: EpgList = EpgList()
|
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) }
|
val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
|
||||||
iptvUrls.forEach { url ->
|
iptvUrls.forEach { url ->
|
||||||
val m3u = retry { getM3uChannels(sourceUrl = url) }
|
val m3u = retry { getM3uChannels(sourceUrl = url) }
|
||||||
allSources.addAll(m3u.sources)
|
allSources.addAll(m3u.channels.map {
|
||||||
m3u.epgUrl?.let { epgUrls.add(it) }
|
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)
|
if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL)
|
||||||
return Pair(allSources, epgUrls.distinct())
|
return Pair(allSources, epgUrls.distinct())
|
||||||
@ -218,11 +191,7 @@ class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getM3uChannels(sourceUrl: String): M3uData {
|
private suspend fun getM3uChannels(sourceUrl: String): M3uData {
|
||||||
val parser = M3uParser()
|
return parseM3u(request(sourceUrl))
|
||||||
val content = request(sourceUrl)
|
|
||||||
return parser.parse(content).also {
|
|
||||||
Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,3 +213,122 @@ class MyTvProviderManager : TVProvider {
|
|||||||
override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() })
|
override fun groups(): TVGroupList = TVGroupList(providers.flatMap { it.groups() })
|
||||||
override fun channels(groupTitle: String): TVChannelList = TVChannelList(providers.flatMap { it.channels(groupTitle) })
|
override fun channels(groupTitle: String): TVChannelList = TVChannelList(providers.flatMap { it.channels(groupTitle) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class M3uData(
|
||||||
|
val headers: Map<String, String>,
|
||||||
|
val channels: List<M3uSource>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class M3uSource(
|
||||||
|
val attributes: Map<String, String>,
|
||||||
|
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<String, String>()
|
||||||
|
val channels = mutableListOf<M3uSource>()
|
||||||
|
var currentAttributes = mutableMapOf<String, String>()
|
||||||
|
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<String, String> {
|
||||||
|
val attributes = mutableMapOf<String, String>()
|
||||||
|
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<EpgChannel> {
|
||||||
|
val epgMap = mutableMapOf<String, EpgChannel>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
@ -15,15 +15,16 @@ object Constants {
|
|||||||
*/
|
*/
|
||||||
const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u"
|
const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u"
|
||||||
// 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/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/ipv6.m3u
|
||||||
// https://live.fanmingming.com/tv/m3u/index.m3u
|
// https://raw.githubusercontent.com/yuanzl77/IPTV/main/live.m3u
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节目单XML地址
|
* 节目单XML地址
|
||||||
*/
|
*/
|
||||||
const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz"
|
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请求重试次数
|
* HTTP请求重试次数
|
||||||
@ -39,13 +40,4 @@ object Constants {
|
|||||||
* 播放器加载超时
|
* 播放器加载超时
|
||||||
*/
|
*/
|
||||||
const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒
|
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秒
|
|
||||||
}
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 182 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 237 KiB |
BIN
app/src/main/res/drawable-xhdpi/tv_banner.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/tv_banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
icon-x1024.png
BIN
icon-x1024.png
Binary file not shown.
Before Width: | Height: | Size: 781 KiB |
Loading…
x
Reference in New Issue
Block a user