diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82fc9620..8d288f7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,7 @@ dependencies { implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.ui.compose) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt index 0fb80ed8..b64f8fa6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt @@ -1,5 +1,7 @@ package com.github.damontecres.dolphin +import androidx.compose.ui.focus.FocusRequester +import timber.log.Timber import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -10,3 +12,15 @@ fun CharSequence?.isNotNullOrBlank(): Boolean { } return !this.isNullOrBlank() } + +/** + * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly + */ +fun FocusRequester.tryRequestFocus(): Boolean = + try { + requestFocus() + true + } catch (ex: IllegalStateException) { + Timber.w(ex, "Failed to request focus") + false + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt index dee37cd3..9632e318 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt @@ -8,6 +8,7 @@ import java.util.UUID sealed interface DolphinModel { val id: UUID val name: String? + val type: BaseItemKind val imageUrl: String? } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt index e2b834e6..06d59d39 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt @@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.data.model import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import java.util.UUID @@ -10,8 +11,9 @@ import java.util.UUID data class Library( override val id: UUID, override val name: String?, + override val type: BaseItemKind, override val imageUrl: String?, - val type: CollectionType, + val collectionType: CollectionType, ) : DolphinModel { companion object { fun fromDto( @@ -21,8 +23,9 @@ data class Library( Library( id = dto.id, name = dto.name, + type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), - type = dto.collectionType ?: CollectionType.UNKNOWN, + collectionType = dto.collectionType ?: CollectionType.UNKNOWN, ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt index dcdcd016..99ba6d43 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt @@ -3,12 +3,14 @@ package com.github.damontecres.dolphin.data.model import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import java.util.UUID data class Video( override val id: UUID, override val name: String?, + override val type: BaseItemKind, override val imageUrl: String?, ) : DolphinModel { companion object { @@ -19,6 +21,7 @@ data class Video( Video( id = dto.id, name = dto.name, + type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt new file mode 100644 index 00000000..0ffc3dab --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Button +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class EpisodeViewModel + @Inject + constructor( + val api: ApiClient, + ) : ViewModel() { + val item = MutableLiveData(null) + + fun init( + itemId: UUID, + potential: BaseItemDto?, + ) { + if (item.value == null && potential?.id == itemId) { + item.value = potential + return + } + if (item.value?.id == itemId) { + return + } + viewModelScope.launch { + try { + item.value = api.userLibraryApi.getItem(itemId).content + } catch (e: Exception) { + Timber.e(e, "Failed to load item $itemId") + item.value = null + } + } + } + } + +@Composable +fun EpisodeDetails( + preferences: UserPreferences, + navigationManager: NavigationManager, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: EpisodeViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.init(destination.itemId, destination.item) + } + val item by viewModel.item.observeAsState() + if (item == null) { + Text(text = "Loading...") + } else { + item?.let { item -> + LazyColumn(modifier = modifier) { + item { + Text(text = item.name ?: "Unknown") + } + item { + Button( + onClick = { + navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) + }, + ) { + Text(text = "Play") + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt new file mode 100644 index 00000000..09c8f8c0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun MediaItemContent( + preferences: UserPreferences, + navigationManager: NavigationManager, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, +) { + when (destination.type) { + BaseItemKind.SERIES -> TODO() + BaseItemKind.SEASON -> TODO() + BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier) + BaseItemKind.MOVIE -> TODO() + BaseItemKind.VIDEO -> TODO() + else -> { + Text("Unsupported item type: ${destination.type}") + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt index 1601f84a..f6d767fa 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt @@ -32,9 +32,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest import timber.log.Timber import javax.inject.Inject @@ -87,8 +89,6 @@ class MainViewModel .getLatestMedia(request) .content .map { convertModel(it, api) } - Timber.v("latest: %s", latest) - // api.itemsApi.getItems(request) HomeRow( section = section, items = latest, @@ -100,7 +100,26 @@ class MainViewModel HomeSection.LIBRARY_TILES_SMALL -> null HomeSection.RESUME -> null HomeSection.ACTIVE_RECORDINGS -> null - HomeSection.NEXT_UP -> null + HomeSection.NEXT_UP -> { + val request = + GetNextUpRequest( + fields = listOf(), + imageTypeLimit = 1, + parentId = null, + limit = 25, + enableResumable = false, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { convertModel(it, api) } + HomeRow( + section = section, + items = nextUp, + ) + } HomeSection.LIVE_TV -> null // TODO Not supported? @@ -132,7 +151,14 @@ fun MainPage( item { HomePageRow( row = row, - onClickItem = { navigationManager.navigateTo(Destination.MediaItem(it.id)) }, + onClickItem = { + navigationManager.navigateTo( + Destination.MediaItem( + it.id, + it.type, + ), + ) + }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 7ac457de..8188b24a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -3,6 +3,8 @@ package com.github.damontecres.dolphin.ui.nav import androidx.navigation3.runtime.NavKey import com.github.damontecres.dolphin.util.UuidSerializer import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import java.util.UUID @Serializable @@ -24,11 +26,14 @@ sealed class Destination( @Serializable data class MediaItem( @Serializable(with = UuidSerializer::class) val itemId: UUID, + val type: BaseItemKind, + @Transient val item: BaseItemDto? = null, ) : Destination() @Serializable data class Playback( - val itemId: String, + @Serializable(with = UuidSerializer::class) val itemId: UUID, val positionMs: Long, + @Transient val item: BaseItemDto? = null, ) : Destination(true) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 4e408c2f..34b5bd14 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -2,9 +2,10 @@ package com.github.damontecres.dolphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.tv.material3.Text import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.detail.MediaItemContent import com.github.damontecres.dolphin.ui.main.MainPage +import com.github.damontecres.dolphin.ui.playback.PlaybackContent @Composable fun DestinationContent( @@ -22,9 +23,22 @@ fun DestinationContent( ) } is Destination.MediaItem -> { - Text("MediaItem: ${destination.itemId}") + MediaItemContent( + preferences = preferences, + navigationManager = navigationManager, + destination = destination, + modifier = modifier, + ) + } + + is Destination.Playback -> { + PlaybackContent( + preferences = preferences, + navigationManager = navigationManager, + destination = destination, + modifier = modifier, + ) } - is Destination.Playback -> TODO() Destination.Search -> TODO() Destination.Settings -> TODO() Destination.Setup -> TODO() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index fbeaa66f..03c4f411 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -133,7 +133,14 @@ fun NavDrawer( items(libraries) { LibraryNavItem( library = it, - onClick = { navigationManager.navigateTo(Destination.MediaItem(it.id)) }, + onClick = { + navigationManager.navigateTo( + Destination.MediaItem( + it.id, + it.type, + ), + ) + }, ) } item { @@ -192,7 +199,7 @@ fun NavigationDrawerScope.LibraryNavItem( ) { // TODO val icon = - when (library.type) { + when (library.collectionType) { CollectionType.MOVIES -> Icons.Default.Email CollectionType.TVSHOWS -> Icons.Default.DateRange CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt new file mode 100644 index 00000000..4af5631c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -0,0 +1,85 @@ +package com.github.damontecres.dolphin.ui.playback + +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.rememberPresentationState +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.tryRequestFocus +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager + +@OptIn(UnstableApi::class) +@Composable +fun PlaybackContent( + preferences: UserPreferences, + navigationManager: NavigationManager, + destination: Destination.Playback, + modifier: Modifier = Modifier, + viewModel: PlaybackViewModel = hiltViewModel(), +) { + LaunchedEffect(destination.itemId) { + viewModel.init(destination.itemId) + } + val player = viewModel.player + val stream by viewModel.stream.observeAsState(null) + if (stream == null) { + // TODO loading + } else { + stream?.let { + var contentScale by remember { mutableStateOf(ContentScale.Fit) } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } + LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + + val presentationState = rememberPresentationState(player) + val scaledModifier = + Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + Box( + modifier + .background(Color.Black) + .onKeyEvent { + // TODO handle key events for playback controls + false + }.focusRequester(focusRequester) + .focusable(), + ) { + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = scaledModifier, + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt new file mode 100644 index 00000000..bbd217f1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -0,0 +1,116 @@ +package com.github.damontecres.dolphin.ui.playback + +import android.content.Context +import androidx.core.net.toUri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.model.api.PlaybackInfoDto +import java.util.UUID +import javax.inject.Inject + +enum class TranscodeType { + DIRECT_PLAY, + DIRECT_STREAM, + TRANSCODE, +} + +data class StreamDecision( + val itemId: UUID, + val url: String, + val type: TranscodeType, +) { + val mediaItem: MediaItem + get() = + MediaItem + .Builder() + .setMediaId(itemId.toString()) + .setUri(url.toUri()) + .build() +} + +@HiltViewModel +class PlaybackViewModel + @Inject + constructor( + @ApplicationContext context: Context, + val api: ApiClient, + ) : ViewModel() { + val player: ExoPlayer = + ExoPlayer + .Builder(context) + .build() + .apply { + playWhenReady = true + } + + val stream = MutableLiveData(null) + + init { + addCloseable { player.release() } + } + + fun init(itemId: UUID) { + viewModelScope.launch(Dispatchers.IO) { + val response = + api.mediaInfoApi + .getPostedPlaybackInfo( + itemId, + // TODO device profile, etc + PlaybackInfoDto( + startTimeTicks = null, + deviceProfile = null, + enableDirectStream = true, + enableDirectPlay = true, + maxAudioChannels = null, + audioStreamIndex = null, + subtitleStreamIndex = null, + allowVideoStreamCopy = true, + allowAudioStreamCopy = true, + autoOpenLiveStream = true, + mediaSourceId = null, + ), + ).content + if (response.errorCode != null) { + // TODO handle error + throw Exception("Playback error: ${response.errorCode}") + } else { + val source = response.mediaSources.firstOrNull() + source?.let { + val mediaUrl = + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = it.id, + static = true, + tag = it.eTag, + allowAudioStreamCopy = true, + allowVideoStreamCopy = true, + ) + val transcodeType = + when { + it.supportsDirectPlay -> TranscodeType.DIRECT_PLAY + it.supportsDirectStream -> TranscodeType.DIRECT_STREAM + it.supportsTranscoding -> TranscodeType.TRANSCODE + else -> throw Exception("No supported playback method") + } + val decision = StreamDecision(itemId, mediaUrl, transcodeType) + withContext(Dispatchers.Main) { + stream.value = decision + player.setMediaItem(decision.mediaItem) + player.prepare() + } + } + } + } + } + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d9e5904..d29e4382 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,7 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } +androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }