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

View File

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

View File

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

View File

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

View File

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

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