Support for playlists

This commit is contained in:
Damontecres 2025-10-13 15:18:27 -04:00
parent 07dabee8b9
commit aab7e2eb17
No known key found for this signature in database
11 changed files with 356 additions and 18 deletions

View file

@ -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.detail.series.SeasonEpisode
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -23,6 +24,13 @@ data class BaseItem(
@Transient val name = data.name @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 @Transient
val indexNumber = data.indexNumber val indexNumber = data.indexNumber

View file

@ -18,6 +18,7 @@ fun convertModel(
): DolphinModel = ): DolphinModel =
when (dto.type) { when (dto.type) {
BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api) BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api)
BaseItemKind.USER_VIEW -> Library.fromDto(dto, api)
// TODO // TODO
BaseItemKind.VIDEO -> Video.fromDto(dto, api) BaseItemKind.VIDEO -> Video.fromDto(dto, api)

View file

@ -61,11 +61,15 @@ class PlaylistCreator
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) 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 = val request =
GetPlaylistItemsRequest( GetPlaylistItemsRequest(
playlistId = playlistId, playlistId = playlistId,
fields = DefaultItemFields, fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
) )
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items

View file

@ -181,7 +181,6 @@ fun CollectionFolderGrid(
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection) val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val library by viewModel.model.observeAsState()
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
when (val state = loading) { when (val state = loading) {
@ -193,7 +192,6 @@ fun CollectionFolderGrid(
pager?.let { pager -> pager?.let { pager ->
CollectionFolderGridContent( CollectionFolderGridContent(
preferences, preferences,
library!!,
item!!, item!!,
pager, pager,
sortAndDirection = sortAndDirection, sortAndDirection = sortAndDirection,
@ -214,7 +212,6 @@ fun CollectionFolderGrid(
@Composable @Composable
fun CollectionFolderGridContent( fun CollectionFolderGridContent(
preferences: UserPreferences, preferences: UserPreferences,
library: Library,
item: BaseItem, item: BaseItem,
pager: List<BaseItem?>, pager: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
@ -225,7 +222,7 @@ fun CollectionFolderGridContent(
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, 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 = val sortOptions =
when (item.data.collectionType) { when (item.data.collectionType) {
CollectionType.MOVIES -> MovieSortOptions CollectionType.MOVIES -> MovieSortOptions

View file

@ -31,6 +31,7 @@ fun OverviewText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textBoxHeight: Dp = maxLines * 20.dp, textBoxHeight: Dp = maxLines * 20.dp,
enabled: Boolean = true,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
@ -46,7 +47,7 @@ fun OverviewText(
.background(bgColor, shape = RoundedCornerShape(8.dp)) .background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true) .playSoundOnFocus(true)
.clickable( .clickable(
enabled = true, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = LocalIndication.current, indication = LocalIndication.current,
) { ) {

View file

@ -4,8 +4,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem 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.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -22,16 +20,15 @@ import java.util.UUID
/** /**
* Basic [ViewModel] for a single fetchable item from the API * Basic [ViewModel] for a single fetchable item from the API
*/ */
abstract class ItemViewModel<T : DolphinModel>( abstract class ItemViewModel<T>(
val api: ApiClient, val api: ApiClient,
) : ViewModel() { ) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null)
suspend fun fetchItem( suspend fun fetchItem(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): BaseItem? = ): BaseItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// val fetchedItem = // val fetchedItem =
// when { // when {
@ -44,11 +41,9 @@ abstract class ItemViewModel<T : DolphinModel>(
// } // }
val it = api.userLibraryApi.getItem(itemId).content val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api) val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem?.let { return@withContext fetchedItem.let {
val modelInstance = convertModel(fetchedItem.data, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
item.value = fetchedItem item.value = fetchedItem
model.value = modelInstance as T
} }
fetchedItem fetchedItem
} }
@ -63,7 +58,7 @@ abstract class ItemViewModel<T : DolphinModel>(
/** /**
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
*/ */
abstract class LoadingItemViewModel<T : DolphinModel>( abstract class LoadingItemViewModel<T>(
api: ApiClient, api: ApiClient,
) : ItemViewModel<T>(api) { ) : ItemViewModel<T>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
@ -80,10 +75,8 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
) { ) {
try { try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api) item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -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<Library>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(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<BaseItem?>,
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,
)
}
}

View file

@ -67,6 +67,7 @@ sealed class Destination(
val itemId: UUID, val itemId: UUID,
val positionMs: Long, val positionMs: Long,
@Transient val item: BaseItem? = null, @Transient val item: BaseItem? = null,
val startIndex: Int? = null,
) : Destination(true) { ) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"

View file

@ -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.CollectionFolderMovie
import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails 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.SeasonDetails
import com.github.damontecres.dolphin.ui.detail.SeriesDetails import com.github.damontecres.dolphin.ui.detail.SeriesDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails 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 -> { else -> {
Text("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}")
} }

View file

@ -138,7 +138,11 @@ class PlaybackViewModel
val base = val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) { if (queriedItem.type == BaseItemKind.PLAYLIST) {
isPlaylist = true isPlaylist = true
val playlist = playlistCreator.createFromPlaylistId(queriedItem.id) val playlist =
playlistCreator.createFromPlaylistId(
queriedItem.id,
destination.startIndex,
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist this@PlaybackViewModel.playlist.value = playlist
} }

View file

@ -27,4 +27,5 @@ val supportedCollectionTypes =
CollectionType.MOVIES, CollectionType.MOVIES,
CollectionType.TVSHOWS, CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.PLAYLISTS,
) )