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.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.medi3.ui)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.medi3.ui.compose)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<application
|
||||
android:name=".PurefinApplication"
|
||||
android:allowBackup="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -22,6 +23,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".player.PlayerActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Purefin" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -126,7 +126,6 @@ class HomePageViewModel @Inject constructor(
|
||||
|
||||
fun loadLatestLibraryItems(libraryId: UUID) {
|
||||
if (_libraryItems.value.containsKey(libraryId)) return
|
||||
val libraryItems = _libraryItems.value[libraryId]
|
||||
viewModelScope.launch {
|
||||
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
|
||||
val latestLibraryPosterItem = latestLibraryItems.mapNotNull {
|
||||
@@ -146,7 +145,7 @@ class HomePageViewModel @Inject constructor(
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
_latestLibraryContent.update { currentMap ->
|
||||
currentMap + (libraryId to latestLibraryPosterItem)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.app.home
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.items
|
||||
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.runtime.Composable
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import kotlin.math.nextUp
|
||||
|
||||
@@ -67,8 +74,10 @@ fun ContinueWatchingSection(
|
||||
fun ContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
colors: HomeColors,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(280.dp)
|
||||
@@ -110,6 +119,15 @@ fun ContinueWatchingCard(
|
||||
.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)) {
|
||||
Text(
|
||||
|
||||
@@ -8,14 +8,21 @@ import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
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.userLibraryApi
|
||||
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.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
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.GetResumeItemsRequest
|
||||
import java.util.UUID
|
||||
@@ -139,4 +146,47 @@ class JellyfinApiClient @Inject constructor(
|
||||
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"
|
||||
foundation = "1.10.1"
|
||||
coil = "3.3.0"
|
||||
media3 = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
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" }
|
||||
|
||||
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user