diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 510cebe..458548a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 27c7a88..c676294 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt
index adf630f..221e315 100644
--- a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt
+++ b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt
@@ -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)
}
diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt
index b642231..25e9d9f 100644
--- a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt
+++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt
@@ -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(
diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt
index 6c21801..411aad6 100644
--- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt
+++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt
@@ -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 {
+ 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
+ }
+
+
}
diff --git a/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt b/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt
new file mode 100644
index 0000000..47c2723
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt
@@ -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()
+ Box(modifier = Modifier.fillMaxSize()) {
+ AndroidView(
+ factory = { context ->
+ PlayerView(context).also {
+ it.player = viewModel.player
+ }
+ },
+ modifier = Modifier.fillMaxHeight()
+ .align(Alignment.Center)
+ .aspectRatio(16f / 9f)
+
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/hu/bbara/purefin/player/model/VideoItem.kt b/app/src/main/java/hu/bbara/purefin/player/model/VideoItem.kt
new file mode 100644
index 0000000..e788408
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/player/model/VideoItem.kt
@@ -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
+)
diff --git a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt
new file mode 100644
index 0000000..6c92412
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/java/hu/bbara/purefin/player/stream/MediaSourceSelector.kt b/app/src/main/java/hu/bbara/purefin/player/stream/MediaSourceSelector.kt
new file mode 100644
index 0000000..47c688f
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/player/stream/MediaSourceSelector.kt
@@ -0,0 +1,4 @@
+package hu.bbara.purefin.player.stream
+
+class MediaSourceSelector {
+}
\ No newline at end of file
diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt
new file mode 100644
index 0000000..47aa969
--- /dev/null
+++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt
@@ -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())
+ private val _contentUri = MutableStateFlow(null)
+
+ init {
+ player.prepare()
+ loadMedia()
+ }
+
+ fun loadMedia() {
+ viewModelScope.launch {
+ val mediaSources: List = 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()
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5c8476b..ec3cd7d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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]