feat(tv): preview channel.

This commit is contained in:
oxy-macmini
2025-04-13 22:07:46 +08:00
parent fff49c2367
commit b45e5ad72e
17 changed files with 211 additions and 37 deletions

View File

@ -1,6 +1,8 @@
package com.m3u.data.codec
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.RenderersFactory
import com.google.auto.service.AutoService
@ -12,4 +14,6 @@ class LiteCodecs: Codecs {
setEnableDecoderFallback(true)
}
}
override fun getThumbnail(context: Context, uri: Uri): Bitmap? = null
}

View File

@ -18,7 +18,8 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.nextlib.media3.ext)
implementation(libs.nextlib.media3ext)
implementation(libs.nextlib.mediainfo)
implementation(libs.auto.service.annotations)

View File

@ -1,10 +1,13 @@
package com.m3u.data.codec
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.RenderersFactory
import com.google.auto.service.AutoService
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
@AutoService(Codecs::class)
class RichCodec: Codecs {
@ -14,4 +17,13 @@ class RichCodec: Codecs {
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
}
override fun getThumbnail(context: Context, uri: Uri): Bitmap? {
val mediaInfo = MediaInfoBuilder()
.from(context, uri)
.build()
val frame = mediaInfo?.getFrame()
mediaInfo?.release()
return frame
}
}

View File

@ -1,11 +1,14 @@
package com.m3u.data.codec
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.media3.exoplayer.RenderersFactory
import java.util.ServiceLoader
interface Codecs {
fun createRenderersFactory(context: Context): RenderersFactory
fun getThumbnail(context: Context, uri: Uri): Bitmap?
companion object {
fun load(): Codecs {

View File

@ -49,6 +49,8 @@ interface PlayerManager {
val cwPositionObserver: SharedFlow<Long>
suspend fun onRewind(channelUrl: String)
suspend fun reloadThumbnail(channelUrl: String): Uri?
suspend fun syncThumbnail(channelUrl: String): Uri?
}
@Immutable

View File

@ -1,5 +1,8 @@
package com.m3u.data.service.internal
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import com.jakewharton.disklrucache.DiskLruCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@ -9,29 +12,46 @@ import java.security.MessageDigest
internal class ChannelPreferenceProvider(
directory: File,
appVersion: Int,
private val ioDispatcher: CoroutineDispatcher
ioDispatcher: CoroutineDispatcher
) {
private val cache = DiskLruCache.open(directory, appVersion, 2, 4 * 1024 * 1024) // 4mb
private val limitedParallelism = ioDispatcher.limitedParallelism(1, "channel-preference")
private val cache = DiskLruCache.open(directory, appVersion, 3, 4 * 1024 * 1024) // 4mb
suspend operator fun get(channelUrl: String): ChannelPreference? = withContext(ioDispatcher) {
suspend operator fun get(
channelUrl: String
): ChannelPreference? = withContext(limitedParallelism) {
runCatching {
val key = encodeKey(channelUrl)
val snapshot = cache.get(key) ?: return@withContext null
ChannelPreference(
cwPosition = snapshot.getString(0).toLong(),
mineType = snapshot.getString(1)
)
snapshot.use {
ChannelPreference(
cwPosition = it.getString(0)?.toLong() ?: -1L,
mineType = it.getString(1)?.takeIf { it.isNotEmpty() },
thumbnail = it.getString(2)?.toUri()
)
}
}
.onFailure {
Log.e("TAG", "get: ", it)
}
.getOrNull()
}
suspend operator fun set(channelUrl: String, value: ChannelPreference) = withContext(ioDispatcher) {
suspend operator fun set(
channelUrl: String,
value: ChannelPreference
) = withContext(limitedParallelism) {
runCatching {
val key = encodeKey(channelUrl)
val editor = cache.edit(key) ?: return@withContext
editor.set(0, value.cwPosition.toString())
editor.set(1, value.mineType)
editor.set(1, value.mineType.orEmpty())
editor.set(2, value.thumbnail?.toString().orEmpty())
editor.commit()
}
.onFailure {
Log.e("TAG", "set: ", it)
}
}
// [a-z0-9_-]{1,64}
@ -49,4 +69,5 @@ internal class ChannelPreferenceProvider(
internal data class ChannelPreference(
val cwPosition: Long = -1L,
val mineType: String? = null,
val thumbnail: Uri? = null
)

View File

@ -1,9 +1,11 @@
package com.m3u.data.service.internal
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.net.Uri
import androidx.compose.runtime.snapshotFlow
import androidx.core.net.toUri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@ -99,6 +101,9 @@ import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ -425,6 +430,38 @@ class PlayerManagerImpl @Inject constructor(
}
.flowOn(ioDispatcher)
override suspend fun reloadThumbnail(channelUrl: String): Uri? {
val channelPreference = getChannelPreference(channelUrl)
return channelPreference?.thumbnail
}
private val thumbnailDir by lazy {
context.cacheDir.resolve("thumbnails").apply {
if (!exists()) {
mkdirs()
}
}
}
override suspend fun syncThumbnail(channelUrl: String): Uri? = withContext(ioDispatcher) {
val thumbnail = codecs.getThumbnail(context, channelUrl.toUri())?: return@withContext null
val filename = UUID.randomUUID().toString() + ".jpeg"
val file = File(thumbnailDir, filename)
while (!file.createNewFile()) {
ensureActive()
file.delete()
}
FileOutputStream(file).use {
thumbnail.compress(Bitmap.CompressFormat.JPEG, 50, it)
}
val uri = file.toUri()
addChannelPreference(
channelUrl,
getChannelPreference(channelUrl)?.copy(
thumbnail = uri
) ?: ChannelPreference(thumbnail = uri)
)
uri
}
private fun createPlayer(
mediaSourceFactory: MediaSource.Factory,
tunneling: Boolean
@ -444,8 +481,9 @@ class PlayerManagerImpl @Inject constructor(
addListener(this@PlayerManagerImpl)
}
private val codecs: Codecs by lazy { Codecs.load() }
private val renderersFactory: RenderersFactory by lazy {
Codecs.load().createRenderersFactory(context)
codecs.createRenderersFactory(context)
}
private fun createTrackSelector(tunneling: Boolean): TrackSelector {
@ -514,7 +552,7 @@ class PlayerManagerImpl @Inject constructor(
logger.post { "onPlayerErrorChanged, parsing error! invalidate remembered mimeType!" }
val channelPreference = getChannelPreference(chain.url)
if (channelPreference != null) {
updateChannelPreference(
addChannelPreference(
chain.url,
channelPreference.copy(mineType = null)
)
@ -697,7 +735,7 @@ class PlayerManagerImpl @Inject constructor(
is MimetypeChain.Trying -> {
val channelPreference = getChannelPreference(chain.url)
updateChannelPreference(
addChannelPreference(
chain.url,
channelPreference?.copy(mineType = chain.mimetype)
?: ChannelPreference(mineType = chain.mimetype)
@ -737,7 +775,7 @@ class PlayerManagerImpl @Inject constructor(
logger.post { "storeContinueWatching, received new position: $cwPosition" }
if (cwPosition == -1L) return@collect
val channelPreference = getChannelPreference(channelUrl)
updateChannelPreference(
addChannelPreference(
channelUrl,
channelPreference?.copy(cwPosition = cwPosition)
?: ChannelPreference(cwPosition = cwPosition)
@ -766,7 +804,7 @@ class PlayerManagerImpl @Inject constructor(
val player = this@PlayerManagerImpl.player.value
withContext(mainDispatcher) {
if (player != null && continueWatchingCondition.isResettingSupported(player, ignorePositionCondition)) {
updateChannelPreference(
addChannelPreference(
channelUrl,
channelPreference?.copy(cwPosition = -1L) ?: ChannelPreference(cwPosition = -1L)
)
@ -815,7 +853,7 @@ class PlayerManagerImpl @Inject constructor(
return channelPreferenceProvider[channelUrl]
}
private suspend fun updateChannelPreference(
private suspend fun addChannelPreference(
channelUrl: String,
channelPreference: ChannelPreference
) {