feat: enhance channel list with metadata display and improve playback reliability

This commit is contained in:
tunbmoon
2026-02-08 21:16:35 +08:00
parent 7024b38052
commit 75ef0b8bd2
8 changed files with 235 additions and 35 deletions
+5 -1
View File
@@ -16,7 +16,7 @@
Astronia 是一款轻量级的现代化 Android M3U8 视频播放器,采用 Material Design 3 设计。它提供简洁直观的界面,支持多种格式的流媒体视频播放。UI 设计灵感来自 [Seal](https://github.com/JunkFood02/Seal)。
## 屏幕截图
## 📱 屏幕截图
<div align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="30%" />
@@ -57,6 +57,10 @@ UI 设计和实现深受 [Seal](https://github.com/JunkFood02/Seal) 启发,许
- [Material color utilities](https://github.com/material-foundation/material-color-utilities)
- [Monet](https://github.com/Kyant0/Monet)
## ⚠️ 免责声明
本软件不提供任何直播内容,不内置任何订阅源。用户需自行承担使用非法或未经授权内容源的法律后果,开发者不对此承担任何法律责任。
## 📃 许可证
[![GitHub](https://img.shields.io/github/license/antoniegil/Astronia?style=for-the-badge)](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
+5 -1
View File
@@ -16,7 +16,7 @@
Astronia 是一款輕量級的現代化 Android M3U8 視訊播放器,採用 Material Design 3 設計。它提供簡潔直觀的介面,支援多種格式的串流媒體視訊播放。UI 設計靈感來自 [Seal](https://github.com/JunkFood02/Seal)。
## 熒幕截圖
## 📱 熒幕截圖
<div align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="30%" />
@@ -57,6 +57,10 @@ UI 設計和實作深受 [Seal](https://github.com/JunkFood02/Seal) 啟發,許
- [Material color utilities](https://github.com/material-foundation/material-color-utilities)
- [Monet](https://github.com/Kyant0/Monet)
## ⚠️ 免責聲明
本軟體不提供任何直播內容,不內建任何訂閱源。使用者需自行承擔使用非法或未經授權內容源的法律後果,開發者不對此承擔任何法律責任。
## 📃 授權條款
[![GitHub](https://img.shields.io/github/license/antoniegil/Astronia?style=for-the-badge)](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
+5 -1
View File
@@ -16,7 +16,7 @@ English
Astronia is a lightweight and modern M3U8 video player for Android, built with Material Design 3. It provides a clean and intuitive interface for streaming video content with support for various formats and playback features. The UI design is inspired by [Seal](https://github.com/JunkFood02/Seal).
## Screenshoot
## 📱 Screenshoots
<div align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="30%" />
@@ -87,6 +87,10 @@ Libraries and tools:
- [Material color utilities](https://github.com/material-foundation/material-color-utilities)
- [Monet](https://github.com/Kyant0/Monet)
## ⚠️ Disclaimer
This software does not provide any live streaming content or built-in subscription sources. Users are solely responsible for the legality of the content sources they use. The developers assume no liability for any legal consequences arising from the use of illegal or unauthorized content sources.
## 📃 License
[![GitHub](https://img.shields.io/github/license/antoniegil/Astronia?style=for-the-badge)](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "com.antoniegil.astronia"
minSdk = 24
targetSdk = 36
versionCode = 1031
versionName = "1.0.3-alpha.1"
versionCode = 1032
versionName = "1.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
@@ -24,12 +24,15 @@ class Media3Player(private val context: Context) {
internal var surface: Surface? = null
private var currentHardwareAcceleration: Boolean = true
private var shouldPlayWhenReady: Boolean = false
private var errorRetryCount: Int = 0
private var parserErrorRetryCount: Int = 0
private var currentMediaUrl: String? = null
private val maxParserRetries = 2
var onPreparedListener: (() -> Unit)? = null
var onInfoListener: ((what: Int, extra: Int) -> Boolean)? = null
var onBufferingListener: ((Boolean) -> Unit)? = null
var onPlaybackStateChanged: ((isPlaying: Boolean, position: Long, bufferedPosition: Long, duration: Long) -> Unit)? = null
var onErrorListener: ((error: String, isRetriable: Boolean) -> Unit)? = null
init {
createPlayer(true)
@@ -52,7 +55,7 @@ class Media3Player(private val context: Context) {
}
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(2000, 10000, 1000, 1000)
.setBufferDurationsMs(3000, 15000, 2000, 2000)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
@@ -76,7 +79,7 @@ class Media3Player(private val context: Context) {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
errorRetryCount = 0
parserErrorRetryCount = 0
onPreparedListener?.invoke()
onInfoListener?.invoke(MEDIA_INFO_VIDEO_RENDERING_START, 0)
onBufferingListener?.invoke(false)
@@ -123,22 +126,60 @@ class Media3Player(private val context: Context) {
ErrorHandler.logError("Media3Player", "Playback error occurred", error)
val wasPlaying = shouldPlayWhenReady
if (error.cause is androidx.media3.exoplayer.source.BehindLiveWindowException) {
exoPlayer?.let { player ->
player.seekToDefaultPosition()
player.prepare()
if (wasPlaying) {
player.play()
}
}
} else {
exoPlayer?.let { player ->
if (wasPlaying && player.playbackState == Player.STATE_IDLE) {
val isParserError = error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED ||
error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED ||
error.errorCode == PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED ||
error.errorCode == PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED
val isNetworkError = error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS
val isBehindLiveWindow = error.cause is androidx.media3.exoplayer.source.BehindLiveWindowException
when {
isBehindLiveWindow -> {
exoPlayer?.let { player ->
player.seekToDefaultPosition()
player.prepare()
player.play()
if (wasPlaying) {
player.play()
}
}
}
onBufferingListener?.invoke(false)
isParserError && parserErrorRetryCount < maxParserRetries -> {
parserErrorRetryCount++
ErrorHandler.logError("Media3Player", "Parser error, retrying ($parserErrorRetryCount/$maxParserRetries)", null)
currentMediaUrl?.let { url ->
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
exoPlayer?.let { player ->
player.stop()
player.clearMediaItems()
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
if (wasPlaying) {
player.play()
}
}
}, 500)
}
}
isNetworkError && parserErrorRetryCount < maxParserRetries -> {
parserErrorRetryCount++
ErrorHandler.logError("Media3Player", "Network error, retrying ($parserErrorRetryCount/$maxParserRetries)", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
exoPlayer?.let { player ->
player.prepare()
if (wasPlaying) {
player.play()
}
}
}, 1000)
}
else -> {
ErrorHandler.logError("Media3Player", "Unrecoverable playback error: ${error.errorCodeName}", error)
onBufferingListener?.invoke(false)
}
}
}
@@ -170,6 +211,8 @@ class Media3Player(private val context: Context) {
}
fun setDataSource(url: String) {
currentMediaUrl = url
parserErrorRetryCount = 0
val mediaItem = MediaItem.fromUri(url)
exoPlayer?.apply {
stop()
@@ -1,12 +1,14 @@
package com.antoniegil.astronia.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Info
@@ -250,11 +252,20 @@ fun ChannelItem(
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = channel.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
ChannelLogo(
logoUrl = channel.logoUrl,
contentDescription = channel.name
)
Text(
text = channel.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = channel.url,
@@ -264,12 +275,55 @@ fun ChannelItem(
overflow = TextOverflow.Ellipsis
)
if (channel.group.isNotEmpty()) {
Text(
text = channel.group,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (channel.group.isNotEmpty() || channel.country.isNotEmpty() || channel.language.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
if (channel.group.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Text(
text = channel.group,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (channel.country.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Text(
text = channel.country,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (channel.language.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Text(
text = channel.language,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
@@ -0,0 +1,49 @@
package com.antoniegil.astronia.ui.component
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
@Composable
fun ChannelLogo(
logoUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier
) {
if (logoUrl.isNotEmpty()) {
var isVisible by remember(logoUrl) { mutableStateOf(true) }
if (isVisible) {
val context = LocalContext.current
AsyncImage(
model = ImageRequest.Builder(context)
.data(logoUrl)
.crossfade(true)
.memoryCacheKey(logoUrl)
.diskCacheKey(logoUrl)
.listener(
onError = { _, _ -> isVisible = false }
)
.build(),
contentDescription = contentDescription,
modifier = modifier
.heightIn(max = 18.dp)
.widthIn(max = 34.dp),
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
onError = { isVisible = false }
)
}
}
}
@@ -17,7 +17,10 @@ data class M3U8Channel(
val id: String,
val name: String,
val url: String,
val group: String = ""
val group: String = "",
val country: String = "",
val language: String = "",
val logoUrl: String = ""
)
object M3U8Parser {
@@ -58,6 +61,9 @@ object M3U8Parser {
var currentName = ""
var currentGroup = ""
var currentCountry = ""
var currentLanguage = ""
var currentLogoUrl = ""
for (line in lines) {
val trimmedLine = line.trim()
@@ -67,6 +73,9 @@ object M3U8Parser {
if (trimmedLine.startsWith("#EXTINF:")) {
currentName = extractChannelName(trimmedLine)
currentGroup = extractGroup(trimmedLine)
currentCountry = extractCountry(trimmedLine)
currentLanguage = extractLanguage(trimmedLine)
currentLogoUrl = extractLogoUrl(trimmedLine)
} else if (!trimmedLine.startsWith("#")) {
if (trimmedLine.startsWith("http") || trimmedLine.startsWith("rtmp") ||
trimmedLine.startsWith("rtsp") || trimmedLine.startsWith("udp")) {
@@ -78,7 +87,10 @@ object M3U8Parser {
id = finalId,
name = name,
url = trimmedLine,
group = currentGroup
group = currentGroup,
country = currentCountry,
language = currentLanguage,
logoUrl = currentLogoUrl
)
)
@@ -88,6 +100,9 @@ object M3U8Parser {
}
currentName = ""
currentGroup = ""
currentCountry = ""
currentLanguage = ""
currentLogoUrl = ""
}
}
@@ -126,6 +141,9 @@ object M3U8Parser {
val channels = mutableListOf<M3U8Channel>()
var currentName = ""
var currentGroup = ""
var currentCountry = ""
var currentLanguage = ""
var currentLogoUrl = ""
var totalBytesRead = 0
response.body.byteStream().bufferedReader().use { reader ->
@@ -143,6 +161,9 @@ object M3U8Parser {
if (trimmedLine.startsWith("#EXTINF:")) {
currentName = extractChannelName(trimmedLine)
currentGroup = extractGroup(trimmedLine)
currentCountry = extractCountry(trimmedLine)
currentLanguage = extractLanguage(trimmedLine)
currentLogoUrl = extractLogoUrl(trimmedLine)
} else if (!trimmedLine.startsWith("#")) {
if (trimmedLine.startsWith("http") || trimmedLine.startsWith("rtmp") ||
trimmedLine.startsWith("rtsp") || trimmedLine.startsWith("udp")) {
@@ -154,7 +175,10 @@ object M3U8Parser {
id = finalId,
name = name,
url = trimmedLine,
group = currentGroup
group = currentGroup,
country = currentCountry,
language = currentLanguage,
logoUrl = currentLogoUrl
)
)
@@ -164,6 +188,9 @@ object M3U8Parser {
}
currentName = ""
currentGroup = ""
currentCountry = ""
currentLanguage = ""
currentLogoUrl = ""
}
}
}
@@ -199,4 +226,19 @@ object M3U8Parser {
val groupMatch = Regex("""group-title="([^"]+)"""").find(line)
return groupMatch?.groupValues?.get(1) ?: ""
}
private fun extractCountry(line: String): String {
val countryMatch = Regex("""tvg-country="([^"]+)"""").find(line)
return countryMatch?.groupValues?.get(1) ?: ""
}
private fun extractLanguage(line: String): String {
val languageMatch = Regex("""tvg-language="([^"]+)"""").find(line)
return languageMatch?.groupValues?.get(1) ?: ""
}
private fun extractLogoUrl(line: String): String {
val logoMatch = Regex("""tvg-logo="([^"]+)"""").find(line)
return logoMatch?.groupValues?.get(1) ?: ""
}
}