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]