mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Minimal playback
This commit is contained in:
parent
80070f4174
commit
3e7f11c588
14 changed files with 405 additions and 12 deletions
|
|
@ -90,6 +90,7 @@ dependencies {
|
|||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
|
||||
implementation(libs.coil.core)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.github.damontecres.dolphin
|
||||
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import timber.log.Timber
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
|
|
@ -10,3 +12,15 @@ fun CharSequence?.isNotNullOrBlank(): Boolean {
|
|||
}
|
||||
return !this.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
||||
*/
|
||||
fun FocusRequester.tryRequestFocus(): Boolean =
|
||||
try {
|
||||
requestFocus()
|
||||
true
|
||||
} catch (ex: IllegalStateException) {
|
||||
Timber.w(ex, "Failed to request focus")
|
||||
false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import java.util.UUID
|
|||
sealed interface DolphinModel {
|
||||
val id: UUID
|
||||
val name: String?
|
||||
val type: BaseItemKind
|
||||
val imageUrl: String?
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.data.model
|
|||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
|
|
@ -10,8 +11,9 @@ import java.util.UUID
|
|||
data class Library(
|
||||
override val id: UUID,
|
||||
override val name: String?,
|
||||
override val type: BaseItemKind,
|
||||
override val imageUrl: String?,
|
||||
val type: CollectionType,
|
||||
val collectionType: CollectionType,
|
||||
) : DolphinModel {
|
||||
companion object {
|
||||
fun fromDto(
|
||||
|
|
@ -21,8 +23,9 @@ data class Library(
|
|||
Library(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
type = dto.collectionType ?: CollectionType.UNKNOWN,
|
||||
collectionType = dto.collectionType ?: CollectionType.UNKNOWN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ package com.github.damontecres.dolphin.data.model
|
|||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
|
||||
data class Video(
|
||||
override val id: UUID,
|
||||
override val name: String?,
|
||||
override val type: BaseItemKind,
|
||||
override val imageUrl: String?,
|
||||
) : DolphinModel {
|
||||
companion object {
|
||||
|
|
@ -19,6 +21,7 @@ data class Video(
|
|||
Video(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package com.github.damontecres.dolphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
val api: ApiClient,
|
||||
) : ViewModel() {
|
||||
val item = MutableLiveData<BaseItemDto?>(null)
|
||||
|
||||
fun init(
|
||||
itemId: UUID,
|
||||
potential: BaseItemDto?,
|
||||
) {
|
||||
if (item.value == null && potential?.id == itemId) {
|
||||
item.value = potential
|
||||
return
|
||||
}
|
||||
if (item.value?.id == itemId) {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
item.value = api.userLibraryApi.getItem(itemId).content
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to load item $itemId")
|
||||
item.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpisodeDetails(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: EpisodeViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(destination.itemId, destination.item)
|
||||
}
|
||||
val item by viewModel.item.observeAsState()
|
||||
if (item == null) {
|
||||
Text(text = "Loading...")
|
||||
} else {
|
||||
item?.let { item ->
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Text(text = item.name ?: "Unknown")
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
|
||||
},
|
||||
) {
|
||||
Text(text = "Play")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.github.damontecres.dolphin.ui.detail
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@Composable
|
||||
fun MediaItemContent(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (destination.type) {
|
||||
BaseItemKind.SERIES -> TODO()
|
||||
BaseItemKind.SEASON -> TODO()
|
||||
BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
|
||||
BaseItemKind.MOVIE -> TODO()
|
||||
BaseItemKind.VIDEO -> TODO()
|
||||
else -> {
|
||||
Text("Unsupported item type: ${destination.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,9 +32,11 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -87,8 +89,6 @@ class MainViewModel
|
|||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { convertModel(it, api) }
|
||||
Timber.v("latest: %s", latest)
|
||||
// api.itemsApi.getItems(request)
|
||||
HomeRow(
|
||||
section = section,
|
||||
items = latest,
|
||||
|
|
@ -100,7 +100,26 @@ class MainViewModel
|
|||
HomeSection.LIBRARY_TILES_SMALL -> null
|
||||
HomeSection.RESUME -> null
|
||||
HomeSection.ACTIVE_RECORDINGS -> null
|
||||
HomeSection.NEXT_UP -> null
|
||||
HomeSection.NEXT_UP -> {
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
fields = listOf(),
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
limit = 25,
|
||||
enableResumable = false,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { convertModel(it, api) }
|
||||
HomeRow(
|
||||
section = section,
|
||||
items = nextUp,
|
||||
)
|
||||
}
|
||||
HomeSection.LIVE_TV -> null
|
||||
|
||||
// TODO Not supported?
|
||||
|
|
@ -132,7 +151,14 @@ fun MainPage(
|
|||
item {
|
||||
HomePageRow(
|
||||
row = row,
|
||||
onClickItem = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
|
||||
onClickItem = {
|
||||
navigationManager.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
it.type,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.dolphin.ui.nav
|
|||
import androidx.navigation3.runtime.NavKey
|
||||
import com.github.damontecres.dolphin.util.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
|
|
@ -24,11 +26,14 @@ sealed class Destination(
|
|||
@Serializable
|
||||
data class MediaItem(
|
||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Transient val item: BaseItemDto? = null,
|
||||
) : Destination()
|
||||
|
||||
@Serializable
|
||||
data class Playback(
|
||||
val itemId: String,
|
||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||
val positionMs: Long,
|
||||
@Transient val item: BaseItemDto? = null,
|
||||
) : Destination(true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ package com.github.damontecres.dolphin.ui.nav
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.detail.MediaItemContent
|
||||
import com.github.damontecres.dolphin.ui.main.MainPage
|
||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||
|
||||
@Composable
|
||||
fun DestinationContent(
|
||||
|
|
@ -22,9 +23,22 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
is Destination.MediaItem -> {
|
||||
Text("MediaItem: ${destination.itemId}")
|
||||
MediaItemContent(
|
||||
preferences = preferences,
|
||||
navigationManager = navigationManager,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
is Destination.Playback -> {
|
||||
PlaybackContent(
|
||||
preferences = preferences,
|
||||
navigationManager = navigationManager,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
is Destination.Playback -> TODO()
|
||||
Destination.Search -> TODO()
|
||||
Destination.Settings -> TODO()
|
||||
Destination.Setup -> TODO()
|
||||
|
|
|
|||
|
|
@ -133,7 +133,14 @@ fun NavDrawer(
|
|||
items(libraries) {
|
||||
LibraryNavItem(
|
||||
library = it,
|
||||
onClick = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
|
||||
onClick = {
|
||||
navigationManager.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
it.type,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -192,7 +199,7 @@ fun NavigationDrawerScope.LibraryNavItem(
|
|||
) {
|
||||
// TODO
|
||||
val icon =
|
||||
when (library.type) {
|
||||
when (library.collectionType) {
|
||||
CollectionType.MOVIES -> Icons.Default.Email
|
||||
CollectionType.TVSHOWS -> Icons.Default.DateRange
|
||||
CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
package com.github.damontecres.dolphin.ui.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.PlayerSurface
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackContent(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.Playback,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(destination.itemId) {
|
||||
viewModel.init(destination.itemId)
|
||||
}
|
||||
val player = viewModel.player
|
||||
val stream by viewModel.stream.observeAsState(null)
|
||||
if (stream == null) {
|
||||
// TODO loading
|
||||
} else {
|
||||
stream?.let {
|
||||
var contentScale by remember { mutableStateOf(ContentScale.Fit) }
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
|
||||
val presentationState = rememberPresentationState(player)
|
||||
val scaledModifier =
|
||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.background(Color.Black)
|
||||
.onKeyEvent {
|
||||
// TODO handle key events for playback controls
|
||||
false
|
||||
}.focusRequester(focusRequester)
|
||||
.focusable(),
|
||||
) {
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier = scaledModifier,
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.github.damontecres.dolphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class TranscodeType {
|
||||
DIRECT_PLAY,
|
||||
DIRECT_STREAM,
|
||||
TRANSCODE,
|
||||
}
|
||||
|
||||
data class StreamDecision(
|
||||
val itemId: UUID,
|
||||
val url: String,
|
||||
val type: TranscodeType,
|
||||
) {
|
||||
val mediaItem: MediaItem
|
||||
get() =
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setMediaId(itemId.toString())
|
||||
.setUri(url.toUri())
|
||||
.build()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class PlaybackViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
val api: ApiClient,
|
||||
) : ViewModel() {
|
||||
val player: ExoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.build()
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
|
||||
val stream = MutableLiveData<StreamDecision?>(null)
|
||||
|
||||
init {
|
||||
addCloseable { player.release() }
|
||||
}
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val response =
|
||||
api.mediaInfoApi
|
||||
.getPostedPlaybackInfo(
|
||||
itemId,
|
||||
// TODO device profile, etc
|
||||
PlaybackInfoDto(
|
||||
startTimeTicks = null,
|
||||
deviceProfile = null,
|
||||
enableDirectStream = true,
|
||||
enableDirectPlay = true,
|
||||
maxAudioChannels = null,
|
||||
audioStreamIndex = null,
|
||||
subtitleStreamIndex = null,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = true,
|
||||
mediaSourceId = null,
|
||||
),
|
||||
).content
|
||||
if (response.errorCode != null) {
|
||||
// TODO handle error
|
||||
throw Exception("Playback error: ${response.errorCode}")
|
||||
} else {
|
||||
val source = response.mediaSources.firstOrNull()
|
||||
source?.let {
|
||||
val mediaUrl =
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = itemId,
|
||||
mediaSourceId = it.id,
|
||||
static = true,
|
||||
tag = it.eTag,
|
||||
allowAudioStreamCopy = true,
|
||||
allowVideoStreamCopy = true,
|
||||
)
|
||||
val transcodeType =
|
||||
when {
|
||||
it.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
|
||||
it.supportsDirectStream -> TranscodeType.DIRECT_STREAM
|
||||
it.supportsTranscoding -> TranscodeType.TRANSCODE
|
||||
else -> throw Exception("No supported playback method")
|
||||
}
|
||||
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
|
||||
withContext(Dispatchers.Main) {
|
||||
stream.value = decision
|
||||
player.setMediaItem(decision.mediaItem)
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi
|
|||
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
||||
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||
|
||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue