diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt index 7a0334a5..4c4b3fd6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -2,6 +2,7 @@ package com.github.damontecres.dolphin.data.model import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.util.seasonEpisode import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient @@ -23,6 +24,13 @@ data class BaseItem( @Transient val name = data.name + @Transient + val title = if (type == BaseItemKind.EPISODE) data.seriesName else name + + @Transient + val subtitle = + if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() + @Transient val indexNumber = data.indexNumber 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 9632e318..c26598de 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 @@ -18,6 +18,7 @@ fun convertModel( ): DolphinModel = when (dto.type) { BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api) + BaseItemKind.USER_VIEW -> Library.fromDto(dto, api) // TODO BaseItemKind.VIDEO -> Video.fromDto(dto, api) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt index 32406465..7735e326 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt @@ -61,11 +61,15 @@ class PlaylistCreator return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) } - suspend fun createFromPlaylistId(playlistId: UUID): Playlist { + suspend fun createFromPlaylistId( + playlistId: UUID, + startIndex: Int?, + ): Playlist { val request = GetPlaylistItemsRequest( playlistId = playlistId, fields = DefaultItemFields, + startIndex = startIndex, limit = Playlist.MAX_SIZE, ) val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index d27556d8..cd81afe8 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt @@ -181,7 +181,6 @@ fun CollectionFolderGrid( val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() - val library by viewModel.model.observeAsState() val pager by viewModel.pager.observeAsState() when (val state = loading) { @@ -193,7 +192,6 @@ fun CollectionFolderGrid( pager?.let { pager -> CollectionFolderGridContent( preferences, - library!!, item!!, pager, sortAndDirection = sortAndDirection, @@ -214,7 +212,6 @@ fun CollectionFolderGrid( @Composable fun CollectionFolderGridContent( preferences: UserPreferences, - library: Library, item: BaseItem, pager: List, sortAndDirection: SortAndDirection, @@ -225,7 +222,7 @@ fun CollectionFolderGridContent( showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { - val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection" + val title = item.name ?: item.data.collectionType?.name ?: "Collection" val sortOptions = when (item.data.collectionType) { CollectionType.MOVIES -> MovieSortOptions diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt index c14e916e..07807567 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt @@ -31,6 +31,7 @@ fun OverviewText( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textBoxHeight: Dp = maxLines * 20.dp, + enabled: Boolean = true, ) { val context = LocalContext.current val isFocused = interactionSource.collectIsFocusedAsState().value @@ -46,7 +47,7 @@ fun OverviewText( .background(bgColor, shape = RoundedCornerShape(8.dp)) .playSoundOnFocus(true) .clickable( - enabled = true, + enabled = enabled, interactionSource = interactionSource, indication = LocalIndication.current, ) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 590085bb..49123c44 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.data.model.DolphinModel -import com.github.damontecres.dolphin.data.model.convertModel import com.github.damontecres.dolphin.util.LoadingExceptionHandler import com.github.damontecres.dolphin.util.LoadingState import kotlinx.coroutines.Dispatchers @@ -22,16 +20,15 @@ import java.util.UUID /** * Basic [ViewModel] for a single fetchable item from the API */ -abstract class ItemViewModel( +abstract class ItemViewModel( val api: ApiClient, ) : ViewModel() { val item = MutableLiveData(null) - val model = MutableLiveData(null) suspend fun fetchItem( itemId: UUID, potential: BaseItem?, - ): BaseItem? = + ): BaseItem = withContext(Dispatchers.IO) { // val fetchedItem = // when { @@ -44,11 +41,9 @@ abstract class ItemViewModel( // } val it = api.userLibraryApi.getItem(itemId).content val fetchedItem = BaseItem.from(it, api) - return@withContext fetchedItem?.let { - val modelInstance = convertModel(fetchedItem.data, api) + return@withContext fetchedItem.let { withContext(Dispatchers.Main) { item.value = fetchedItem - model.value = modelInstance as T } fetchedItem } @@ -63,7 +58,7 @@ abstract class ItemViewModel( /** * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred */ -abstract class LoadingItemViewModel( +abstract class LoadingItemViewModel( api: ApiClient, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) @@ -80,10 +75,8 @@ abstract class LoadingItemViewModel( ) { try { val fetchedItem = api.userLibraryApi.getItem(itemId).content - val modelInstance = convertModel(fetchedItem, api) withContext(Dispatchers.Main) { item.value = BaseItem.from(fetchedItem, api) - model.value = modelInstance as T loading.value = LoadingState.Success } } catch (e: Exception) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt new file mode 100644 index 00000000..f9807ae9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt @@ -0,0 +1,314 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +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.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.data.model.Library +import com.github.damontecres.dolphin.ui.DefaultItemFields +import com.github.damontecres.dolphin.ui.cards.ItemCardImage +import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.LoadingPage +import com.github.damontecres.dolphin.ui.components.OverviewText +import com.github.damontecres.dolphin.ui.ifElse +import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.roundMinutes +import com.github.damontecres.dolphin.ui.tryRequestFocus +import com.github.damontecres.dolphin.util.ApiRequestPager +import com.github.damontecres.dolphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.dolphin.util.LoadingExceptionHandler +import com.github.damontecres.dolphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class PlaylistViewModel + @Inject + constructor( + api: ApiClient, + val navigationManager: NavigationManager, + ) : ItemViewModel(api) { + val loading = MutableLiveData(LoadingState.Pending) + val items = MutableLiveData>(listOf()) + + fun init(playlistId: UUID) { + loading.value = LoadingState.Loading + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), + ) { + val playlist = fetchItem(playlistId, null) + val request = + GetPlaylistItemsRequest( + playlistId = playlist.id, + fields = DefaultItemFields, + ) + val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } + } + } + +@Composable +fun PlaylistDetails( + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: PlaylistViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.init(destination.itemId) + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val playlist by viewModel.item.observeAsState(null) + val items by viewModel.items.observeAsState(listOf()) + + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier) + LoadingState.Success -> + playlist?.let { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + PlaylistDetailsContent( + playlist = it, + items = items, + onClickIndex = { index -> + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it.id, + positionMs = 0L, + startIndex = index, + ), + ) + }, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} + +@Composable +fun PlaylistDetailsContent( + playlist: BaseItem, + items: List, + onClickIndex: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var savedIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedIndex by remember { mutableIntStateOf(savedIndex) } + val focusRequester = remember { FocusRequester() } + + val focusedItem = items.getOrNull(focusedIndex) + + Box( + modifier = modifier, + ) { + if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = focusedItem.backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxHeight(.85f) + .alpha(.4f) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = 500f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + endX = 400f, + startX = 100f, + ), + ) + }, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .fillMaxSize(), + ) { + Text( + text = playlist.name ?: "Playlist", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + ) + PlaylistDetailsHeader( + focusedItem = focusedItem, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth(.66f), + ) + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .fillMaxWidth(.8f) + .align(Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).focusGroup() + .focusRestorer(focusRequester), + ) { + itemsIndexed(items) { index, item -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = false, + onClick = { + savedIndex = index + onClickIndex.invoke(index) + }, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + style = MaterialTheme.typography.titleLarge, + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + style = MaterialTheme.typography.titleSmall, + ) + }, + trailingContent = { + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { + Text( + text = it.toString(), + ) + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + ItemCardImage( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + modifier = Modifier.width(160.dp), + useFallbackText = false, + ) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }, + ) + } + } + } + } +} + +@Composable +fun PlaylistDetailsHeader( + focusedItem: BaseItem?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = focusedItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = focusedItem?.subtitle ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + OverviewText( + overview = focusedItem?.data?.overview ?: "", + maxLines = 2, + onClick = {}, + enabled = false, + ) + } +} 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 e384f8bf..4c13c09b 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 @@ -67,6 +67,7 @@ sealed class Destination( val itemId: UUID, val positionMs: Long, @Transient val item: BaseItem? = null, + val startIndex: Int? = null, ) : Destination(true) { override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" 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 9eaa1c36..05914f03 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 @@ -9,6 +9,7 @@ import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv import com.github.damontecres.dolphin.ui.detail.EpisodeDetails +import com.github.damontecres.dolphin.ui.detail.PlaylistDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.SeriesDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails @@ -130,6 +131,19 @@ fun DestinationContent( } } + BaseItemKind.PLAYLIST -> + PlaylistDetails( + destination = destination, + modifier = modifier, + ) + + BaseItemKind.USER_VIEW -> + CollectionFolderGeneric( + preferences, + destination, + modifier, + ) + else -> { Text("Unsupported item type: ${destination.type}") } 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 index b333911c..8ce8e7ea 100644 --- 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 @@ -138,7 +138,11 @@ class PlaybackViewModel val base = if (queriedItem.type == BaseItemKind.PLAYLIST) { isPlaylist = true - val playlist = playlistCreator.createFromPlaylistId(queriedItem.id) + val playlist = + playlistCreator.createFromPlaylistId( + queriedItem.id, + destination.startIndex, + ) withContext(Dispatchers.Main) { this@PlaybackViewModel.playlist.value = playlist } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt index 243b6bac..e65b2c7a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt @@ -27,4 +27,5 @@ val supportedCollectionTypes = CollectionType.MOVIES, CollectionType.TVSHOWS, CollectionType.HOMEVIDEOS, + CollectionType.PLAYLISTS, )