feat: enhance channel list with metadata display and improve playback reliability
This commit is contained in:
+5
-1
@@ -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)
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本软件不提供任何直播内容,不内置任何订阅源。用户需自行承担使用非法或未经授权内容源的法律后果,开发者不对此承担任何法律责任。
|
||||
|
||||
## 📃 许可证
|
||||
|
||||
[](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
## ⚠️ 免責聲明
|
||||
|
||||
本軟體不提供任何直播內容,不內建任何訂閱源。使用者需自行承擔使用非法或未經授權內容源的法律後果,開發者不對此承擔任何法律責任。
|
||||
|
||||
## 📃 授權條款
|
||||
|
||||
[](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://github.com/antoniegil/Astronia/blob/main/LICENSE)
|
||||
|
||||
@@ -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) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user