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

@ -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)

View File

@ -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)
)
}

View File

@ -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,

View File

@ -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)

View File

@ -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 }
)
}
},

View File

@ -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) }

View File

@ -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

View File

@ -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
)
}
}

View File

@ -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)

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
) {

View File

@ -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" }