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:
2026-01-18 02:09:11 +01:00
parent 6529b109f8
commit fd100816cc
11 changed files with 262 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,4 @@
package hu.bbara.purefin.player.stream
class MediaSourceSelector {
}

View File

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

View File

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