mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-05-19 20:36:34 +08:00
feat(tv): preview channel.
This commit is contained in:
@ -585,7 +585,7 @@ private fun SliderImpl(
|
||||
thumb = {
|
||||
SliderDefaults.Thumb(
|
||||
interactionSource = sliderInteractionSource,
|
||||
thumbSize = DpSize(sliderThumbWidthDp, 44.dp)
|
||||
thumbSize = DpSize(sliderThumbWidthDp, 20.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
|
@ -11,6 +11,7 @@ import android.content.res.Configuration.UI_MODE_TYPE_NORMAL
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_VR_HEADSET
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_WATCH
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
@ -283,6 +284,9 @@ internal fun PlaylistRoute(
|
||||
isVodPlaylist = isVodPlaylist,
|
||||
isSeriesPlaylist = isSeriesPlaylist,
|
||||
getProgrammeCurrently = { channelId -> viewModel.getProgrammeCurrently(channelId) },
|
||||
reloadThumbnail = { channelUrl -> viewModel.reloadThumbnail(channelUrl) },
|
||||
syncThumbnail = { channelUrl ->
|
||||
/** disabled in smartphone because it will cost too much data*/ null },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.thenIf(preferences.godMode) {
|
||||
@ -353,6 +357,8 @@ private fun PlaylistScreen(
|
||||
isVodPlaylist: Boolean,
|
||||
isSeriesPlaylist: Boolean,
|
||||
getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
|
||||
reloadThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
syncThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val currentOnScrollUp by rememberUpdatedState(onScrollUp)
|
||||
@ -523,6 +529,8 @@ private fun PlaylistScreen(
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen(it)
|
||||
},
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
reloadThumbnail = reloadThumbnail,
|
||||
syncThumbnail = syncThumbnail,
|
||||
modifier = Modifier.hazeSource(LocalHazeState.current)
|
||||
)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.m3u.smartphone.ui.business.playlist.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@ -27,6 +28,8 @@ import com.m3u.core.foundation.components.CircularProgressIndicator
|
||||
import com.m3u.smartphone.ui.material.components.VerticalDraggableScrollbar
|
||||
import com.m3u.smartphone.ui.material.ktx.plus
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
internal fun ChannelGallery(
|
||||
@ -39,6 +42,8 @@ internal fun ChannelGallery(
|
||||
onClick: (Channel) -> Unit,
|
||||
onLongClick: (Channel) -> Unit,
|
||||
getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
|
||||
reloadThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
syncThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
@ -54,6 +59,8 @@ internal fun ChannelGallery(
|
||||
val channels = categoryWithChannels?.channels?.collectAsLazyPagingItems()
|
||||
|
||||
val currentGetProgrammeCurrently by rememberUpdatedState(getProgrammeCurrently)
|
||||
val currentReloadThumbnail by rememberUpdatedState(reloadThumbnail)
|
||||
val currentSyncThumbnail by rememberUpdatedState(syncThumbnail)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
@ -80,9 +87,23 @@ internal fun ChannelGallery(
|
||||
) {
|
||||
value = currentGetProgrammeCurrently(channel.id)
|
||||
}
|
||||
val loadedUrl: Any? by produceState<Any?>(initialValue = channel.cover) {
|
||||
val default = channel.cover
|
||||
delay(1200.milliseconds)
|
||||
val channelUrl = channel.url
|
||||
val reloaded = currentReloadThumbnail(channelUrl)
|
||||
if (reloaded == null) {
|
||||
value = currentSyncThumbnail(channelUrl) ?: default
|
||||
} else {
|
||||
value = reloaded
|
||||
delay(2400.milliseconds)
|
||||
value = currentSyncThumbnail(channelUrl) ?: default
|
||||
}
|
||||
}
|
||||
ChannelItem(
|
||||
channel = channel,
|
||||
programme = programme,
|
||||
cover = loadedUrl,
|
||||
recently = recently,
|
||||
zapping = zapping == channel,
|
||||
isVodOrSeriesPlaylist = isVodOrSeriesPlaylist,
|
||||
|
@ -64,6 +64,7 @@ internal fun ChannelItem(
|
||||
channel: Channel,
|
||||
recently: Boolean,
|
||||
zapping: Boolean,
|
||||
cover: Any?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
programme: Programme?,
|
||||
@ -115,9 +116,9 @@ internal fun ChannelItem(
|
||||
.then(modifier)
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = remember(channel.cover) {
|
||||
model = remember(cover) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(channel.cover)
|
||||
.data(cover)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
},
|
||||
@ -178,7 +179,7 @@ internal fun ChannelItem(
|
||||
},
|
||||
leadingContent = composableOf(!noPictureMode) {
|
||||
AsyncImage(
|
||||
model = channel.cover,
|
||||
model = cover,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.size(56.dp)
|
||||
|
@ -1,8 +1,6 @@
|
||||
package com.m3u.smartphone.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -26,10 +24,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@ -63,7 +59,6 @@ import com.m3u.smartphone.ui.material.components.Destination
|
||||
import com.m3u.smartphone.ui.material.components.FontFamilies
|
||||
import com.m3u.smartphone.ui.material.effects.currentBackStackEntry
|
||||
import com.m3u.smartphone.ui.material.model.LocalHazeState
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
@ -241,7 +236,9 @@ internal fun MainContent(
|
||||
}
|
||||
},
|
||||
onLongClick = {},
|
||||
getProgrammeCurrently = { null }
|
||||
getProgrammeCurrently = { null },
|
||||
reloadThumbnail = { null },
|
||||
syncThumbnail = { null }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.m3u.tv
|
||||
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -73,7 +74,8 @@ fun App(
|
||||
navArgument(PlaylistNavigation.TYPE_URL) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
),
|
||||
enterTransition = { fadeIn() }
|
||||
) {
|
||||
PlaylistScreen(
|
||||
onChannelClick = { channel -> navigateToChannel(channel.id) }
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.m3u.tv.screens.playlist
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@ -45,6 +46,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -54,10 +56,12 @@ import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.CompactCard
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.IconButton
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.IconButtonDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.m3u.business.playlist.PlaylistViewModel
|
||||
import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
|
||||
import com.m3u.core.foundation.ui.thenIf
|
||||
@ -73,12 +77,14 @@ fun LazyListScope.channelGallery(
|
||||
onHideCategory: (String) -> Unit,
|
||||
startPadding: Dp,
|
||||
endPadding: Dp,
|
||||
onChannelClick: (channel: Channel) -> Unit
|
||||
onChannelClick: (channel: Channel) -> Unit,
|
||||
reloadThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
syncThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
) {
|
||||
itemsIndexed(channels, key = { _, (category, _) -> category }) { i, (category, channels) ->
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
Column {
|
||||
val pagingChannels = channels.collectAsLazyPagingItems()
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
AnimatedVisibility(
|
||||
visible = hasFocus,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -141,17 +147,23 @@ fun LazyListScope.channelGallery(
|
||||
.fillMaxHeight()
|
||||
.padding(end = startPadding)
|
||||
) {
|
||||
val pinned = category in pinnedCategories
|
||||
IconButton(
|
||||
colors = IconButtonDefaults.colors(
|
||||
containerColor = if (pinned) MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.8f
|
||||
)
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
|
||||
focusedContainerColor = if (pinned) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
onClick = {
|
||||
onPinOrUnpinCategory(category)
|
||||
}
|
||||
) {
|
||||
val pinned = category in pinnedCategories
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PushPin,
|
||||
contentDescription = "PushPin",
|
||||
tint = if (pinned) MaterialTheme.colorScheme.primary
|
||||
else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@ -173,6 +185,8 @@ fun LazyListScope.channelGallery(
|
||||
ChannelGalleryItem(
|
||||
itemWidth = 382.dp,
|
||||
onChannelClick = onChannelClick,
|
||||
reloadThumbnail = reloadThumbnail,
|
||||
syncThumbnail = syncThumbnail,
|
||||
channel = channel,
|
||||
modifier = Modifier
|
||||
.thenIf(j == 0 && showControl) {
|
||||
@ -193,8 +207,18 @@ private fun ChannelGalleryItem(
|
||||
itemWidth: Dp,
|
||||
channel: Channel,
|
||||
modifier: Modifier = Modifier,
|
||||
onChannelClick: (channel: Channel) -> Unit
|
||||
onChannelClick: (channel: Channel) -> Unit,
|
||||
reloadThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
syncThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val crossfadeImageRequest = { data: Any? ->
|
||||
ImageRequest.Builder(context)
|
||||
.crossfade(800)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.data(data)
|
||||
.build()
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(JetStreamBorderWidth))
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
@ -233,8 +257,32 @@ private fun ChannelGalleryItem(
|
||||
targetValue = if (isFocused) 1f else 0.5f,
|
||||
label = "",
|
||||
)
|
||||
val model: Any? by produceState<Any?>(
|
||||
initialValue = channel.cover,
|
||||
key1 = isFocused
|
||||
) {
|
||||
val default = channel.cover
|
||||
if (isFocused) {
|
||||
val channelUrl = channel.url
|
||||
delay(600.milliseconds)
|
||||
val reloaded = reloadThumbnail(channelUrl)
|
||||
?.let { crossfadeImageRequest(it) }
|
||||
if (reloaded == null) {
|
||||
value = syncThumbnail(channelUrl)
|
||||
?.let { crossfadeImageRequest(it) }
|
||||
?: default
|
||||
} else {
|
||||
value = reloaded
|
||||
delay(600.milliseconds)
|
||||
value = syncThumbnail(channelUrl)
|
||||
?.let { crossfadeImageRequest(it) } ?: default
|
||||
}
|
||||
} else {
|
||||
value = default
|
||||
}
|
||||
}
|
||||
AsyncImage(
|
||||
model = channel.cover,
|
||||
model = model,
|
||||
contentDescription = channel.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.m3u.tv.screens.playlist
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -27,6 +28,8 @@ fun PlaylistScreen(
|
||||
onPinOrUnpinCategory = viewModel::onPinOrUnpinCategory,
|
||||
onHideCategory = viewModel::onHideCategory,
|
||||
onChannelClick = onChannelClick,
|
||||
reloadThumbnail = viewModel::reloadThumbnail,
|
||||
syncThumbnail = viewModel::syncThumbnail,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@ -38,6 +41,8 @@ private fun Catalog(
|
||||
onPinOrUnpinCategory: (String) -> Unit,
|
||||
onHideCategory: (String) -> Unit,
|
||||
onChannelClick: (channel: Channel) -> Unit,
|
||||
reloadThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
syncThumbnail: suspend (channelUrl: String) -> Uri?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val childPadding = rememberChildPadding()
|
||||
@ -55,6 +60,8 @@ private fun Catalog(
|
||||
onChannelClick = onChannelClick,
|
||||
startPadding = childPadding.start,
|
||||
endPadding = childPadding.end,
|
||||
reloadThumbnail = reloadThumbnail,
|
||||
syncThumbnail = syncThumbnail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package com.m3u.business.playlist
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
@ -80,7 +81,7 @@ class PlaylistViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val programmeRepository: ProgrammeRepository,
|
||||
private val messager: Messager,
|
||||
playerManager: PlayerManager,
|
||||
private val playerManager: PlayerManager,
|
||||
preferences: Preferences,
|
||||
workManager: WorkManager,
|
||||
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
|
||||
@ -363,6 +364,13 @@ class PlaylistViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reloadThumbnail(channelUrl: String): Uri? {
|
||||
return playerManager.reloadThumbnail(channelUrl)
|
||||
}
|
||||
suspend fun syncThumbnail(channelUrl: String): Uri? {
|
||||
return playerManager.syncThumbnail(channelUrl)
|
||||
}
|
||||
|
||||
val series = MutableStateFlow<Channel?>(null)
|
||||
val seriesReplay = MutableStateFlow(0)
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
) {
|
||||
|
@ -163,7 +163,8 @@ androidx-benchmark-benchmark-macro-junit4 = { group = "androidx.benchmark", name
|
||||
chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" }
|
||||
chucker-no-op = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" }
|
||||
logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback" }
|
||||
nextlib-media3-ext = { module = "com.github.anilbeesetti.nextlib:nextlib-media3ext", version.ref = "nextLib" }
|
||||
nextlib-media3ext = { module = "com.github.anilbeesetti.nextlib:nextlib-media3ext", version.ref = "nextLib" }
|
||||
nextlib-mediainfo = { module = "com.github.anilbeesetti.nextlib:nextlib-mediainfo", version.ref = "nextLib" }
|
||||
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" }
|
||||
|
||||
|
Reference in New Issue
Block a user