mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
implement Media3 ExoPlayer for video playback
- Add Media3 UI, ExoPlayer, and Compose dependencies. - Implement `PlayerActivity` with landscape orientation for video playback. - Create `PlayerViewModel` to manage ExoPlayer lifecycle and media loading via Hilt. - Define `VideoPlayerModule` to provide a configured `ExoPlayer` instance with tunneling and custom load control. - Extend `JellyfinApiClient` to fetch media sources and video stream URLs. - Update `ContinueWatchingCard` to launch `PlayerActivity` when the play button is clicked. - Enforce portrait orientation for the main application and filter duplicate items in `HomePageViewModel`.
This commit is contained in:
@@ -67,6 +67,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.foundation)
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
implementation(libs.medi3.ui)
|
||||||
|
implementation(libs.medi3.exoplayer)
|
||||||
|
implementation(libs.medi3.ui.compose)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".PurefinApplication"
|
android:name=".PurefinApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -22,6 +23,11 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".player.PlayerActivity"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.Purefin" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ class HomePageViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun loadLatestLibraryItems(libraryId: UUID) {
|
fun loadLatestLibraryItems(libraryId: UUID) {
|
||||||
if (_libraryItems.value.containsKey(libraryId)) return
|
if (_libraryItems.value.containsKey(libraryId)) return
|
||||||
val libraryItems = _libraryItems.value[libraryId]
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
|
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
|
||||||
val latestLibraryPosterItem = latestLibraryItems.mapNotNull {
|
val latestLibraryPosterItem = latestLibraryItems.mapNotNull {
|
||||||
@@ -146,7 +145,7 @@ class HomePageViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}.distinctBy { it.id }
|
||||||
_latestLibraryContent.update { currentMap ->
|
_latestLibraryContent.update { currentMap ->
|
||||||
currentMap + (libraryId to latestLibraryPosterItem)
|
currentMap + (libraryId to latestLibraryPosterItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package hu.bbara.purefin.app.home
|
package hu.bbara.purefin.app.home
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -18,6 +19,10 @@ import androidx.compose.foundation.layout.wrapContentHeight
|
|||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -26,12 +31,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||||
|
import hu.bbara.purefin.player.PlayerActivity
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import kotlin.math.nextUp
|
import kotlin.math.nextUp
|
||||||
|
|
||||||
@@ -67,8 +74,10 @@ fun ContinueWatchingSection(
|
|||||||
fun ContinueWatchingCard(
|
fun ContinueWatchingCard(
|
||||||
item: ContinueWatchingItem,
|
item: ContinueWatchingItem,
|
||||||
colors: HomeColors,
|
colors: HomeColors,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(280.dp)
|
.width(280.dp)
|
||||||
@@ -110,6 +119,15 @@ fun ContinueWatchingCard(
|
|||||||
.background(colors.primary)
|
.background(colors.primary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(context, PlayerActivity::class.java)
|
||||||
|
intent.putExtra("MEDIA_ID", item.id.toString())
|
||||||
|
context.startActivity(intent)
|
||||||
|
}) {
|
||||||
|
Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = "Play")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ import kotlinx.coroutines.flow.first
|
|||||||
import org.jellyfin.sdk.api.client.Response
|
import org.jellyfin.sdk.api.client.Response
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -139,4 +146,47 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return response.content
|
return response.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> {
|
||||||
|
val result = api.mediaInfoApi
|
||||||
|
.getPostedPlaybackInfo(
|
||||||
|
mediaId,
|
||||||
|
PlaybackInfoDto(
|
||||||
|
userId = getUserId(),
|
||||||
|
deviceProfile =
|
||||||
|
//TODO check this
|
||||||
|
DeviceProfile(
|
||||||
|
name = "Direct play all",
|
||||||
|
maxStaticBitrate = 1_000_000_000,
|
||||||
|
maxStreamingBitrate = 1_000_000_000,
|
||||||
|
codecProfiles = emptyList(),
|
||||||
|
containerProfiles = emptyList(),
|
||||||
|
directPlayProfiles = emptyList(),
|
||||||
|
transcodingProfiles = emptyList(),
|
||||||
|
subtitleProfiles =
|
||||||
|
listOf(
|
||||||
|
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxStreamingBitrate = 1_000_000_000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.d("getMediaSources result: {}", result.toString())
|
||||||
|
return result.content.mediaSources
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMediaPlaybackInfo(mediaId: UUID, mediaSourceId: String? = null): String? {
|
||||||
|
if (!ensureConfigured()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val response = api.videosApi.getVideoStreamUrl(
|
||||||
|
itemId = mediaId,
|
||||||
|
static = true,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
|
)
|
||||||
|
Log.d("getMediaPlaybackInfo response: {}", response.toString())
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt
Normal file
40
app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package hu.bbara.purefin.player
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PlayerActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val viewModel = hiltViewModel<PlayerViewModel>()
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
PlayerView(context).also {
|
||||||
|
it.player = viewModel.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxHeight()
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/java/hu/bbara/purefin/player/model/VideoItem.kt
Normal file
10
app/src/main/java/hu/bbara/purefin/player/model/VideoItem.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package hu.bbara.purefin.player.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
|
||||||
|
data class VideoItem(
|
||||||
|
val title: String,
|
||||||
|
val mediaItem: MediaItem,
|
||||||
|
val uri: Uri
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package hu.bbara.purefin.player.module
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ViewModelComponent
|
||||||
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ViewModelComponent::class)
|
||||||
|
object VideoPlayerModule {
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Provides
|
||||||
|
@ViewModelScoped
|
||||||
|
fun provideVideoPlayer(application: Application): Player {
|
||||||
|
val trackSelector = DefaultTrackSelector(application)
|
||||||
|
val audioAttributes =
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
trackSelector.setParameters(
|
||||||
|
trackSelector
|
||||||
|
.buildUponParameters()
|
||||||
|
.setTunnelingEnabled(true)
|
||||||
|
// .setPreferredAudioLanguage(
|
||||||
|
// appPreferences.getValue(appPreferences.preferredAudioLanguage)
|
||||||
|
// )
|
||||||
|
// .setPreferredTextLanguage(
|
||||||
|
// appPreferences.getValue(appPreferences.preferredSubtitleLanguage)
|
||||||
|
// )
|
||||||
|
)
|
||||||
|
val loadControl = DefaultLoadControl.Builder()
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
25_000,
|
||||||
|
55_000,
|
||||||
|
5_000,
|
||||||
|
5_000
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
return ExoPlayer.Builder(application)
|
||||||
|
.setPauseAtEndOfMediaItems(true)
|
||||||
|
.setLoadControl(loadControl)
|
||||||
|
.setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
||||||
|
.build()
|
||||||
|
.apply {
|
||||||
|
playWhenReady = true
|
||||||
|
pauseAtEndOfMediaItems = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package hu.bbara.purefin.player.stream
|
||||||
|
|
||||||
|
class MediaSourceSelector {
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package hu.bbara.purefin.player.viewmodel
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PlayerViewModel @Inject constructor(
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
|
val player: Player,
|
||||||
|
val jellyfinApiClient: JellyfinApiClient
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val mediaId: String? = savedStateHandle["MEDIA_ID"]
|
||||||
|
private val videoUris = savedStateHandle.getStateFlow("videoUris", emptyList<Uri>())
|
||||||
|
private val _contentUri = MutableStateFlow<Uri?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
player.prepare()
|
||||||
|
loadMedia()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMedia() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val mediaSources: List<MediaSourceInfo> = jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
|
||||||
|
val contentUriString =
|
||||||
|
jellyfinApiClient.getMediaPlaybackInfo(mediaId = UUID.fromString(mediaId), mediaSourceId = mediaSources.first().id)
|
||||||
|
contentUriString?.toUri()?.let {
|
||||||
|
_contentUri.value = it
|
||||||
|
playVideo(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addVideoUri(contentUri: Uri) {
|
||||||
|
savedStateHandle["videoUris"] = videoUris.value + contentUri
|
||||||
|
player.addMediaItem(MediaItem.fromUri(contentUri))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playVideo(uri: Uri) {
|
||||||
|
player.setMediaItem(
|
||||||
|
MediaItem.fromUri(uri)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ kotlinxSerializationJson = "1.7.3"
|
|||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
foundation = "1.10.1"
|
foundation = "1.10.1"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
|
media3 = "1.9.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -46,8 +47,9 @@ logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-intercep
|
|||||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
|
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||||
|
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
|
||||||
|
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
||||||
|
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
|
||||||
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user