WIP playlist updates

This commit is contained in:
Damontecres 2026-03-07 11:38:32 -05:00
parent 5f5b07e861
commit a699170583
No known key found for this signature in database
2 changed files with 163 additions and 113 deletions

View file

@ -265,7 +265,7 @@ suspend fun upgradeApp(
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.5.2-1=g0"))) { if (previous.isEqualOrBefore(Version.fromString("0.5.3-1-g0"))) {
appPreferences.updateData { appPreferences.updateData {
it.updateMusicPreferences { it.updateMusicPreferences {
showBackdrop = true showBackdrop = true

View file

@ -21,14 +21,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -47,7 +45,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -63,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -70,7 +68,6 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -83,7 +80,10 @@ import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
@ -92,21 +92,24 @@ import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
@ -115,55 +118,55 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@HiltViewModel @HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
class PlaylistViewModel class PlaylistViewModel
@Inject @AssistedInject
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, navigationManager: NavigationManager,
musicService: MusicService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val musicService: MusicService, private val favoriteWatchManager: FavoriteWatchManager,
) : ItemViewModel(api) { @Assisted val itemId: UUID,
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) ) : MusicViewModel(api, musicService, navigationManager) {
val items = MutableLiveData<List<BaseItem?>>(listOf()) @AssistedFactory
val filterAndSort = interface Factory {
MutableStateFlow<FilterAndSort>( fun create(itemId: UUID): PlaylistViewModel
FilterAndSort( }
filter = GetItemsFilter(),
sortAndDirection = val state = MutableStateFlow(PlaylistDetailsState())
SortAndDirection( val musicState = musicService.state
init {
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
try {
val playlist =
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it, false) }
state.update { it.copy(playlist = playlist) }
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT, ItemSortBy.DEFAULT,
SortOrder.ASCENDING, SortOrder.ASCENDING,
), )
), loadItems(filter, sortAndDirection).join()
) determineMediaType()
val musicState = musicService.state } catch (ex: Exception) {
val playlistMediaType = MutableStateFlow(MediaType.UNKNOWN) Timber.e(ex, "Error fetching playlist %s", itemId)
state.update { it.copy(loading = LoadingState.Error(ex)) }
fun init(playlistId: UUID) { }
loading.value = LoadingState.Loading
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) {
val playlist = fetchItem(playlistId)
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
loadItems(filter, sortAndDirection).join()
determineMediaType()
} }
} }
@ -172,13 +175,14 @@ class PlaylistViewModel
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
) = viewModelScope.launchIO { ) = viewModelScope.launchIO {
backdropService.clearBackdrop() backdropService.clearBackdrop()
loading.setValueOnMain(LoadingState.Loading) state.update {
this@PlaylistViewModel.filterAndSort.update { it.copy(
FilterAndSort(filter, sortAndDirection) loading = LoadingState.Loading,
filterAndSort = FilterAndSort(filter, sortAndDirection),
)
} }
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
val playlistId = item.value!!.id
viewModelScope.launchIO { viewModelScope.launchIO {
val libraryDisplayInfo = val libraryDisplayInfo =
libraryDisplayInfoDao.getItem(user, itemId)?.copy( libraryDisplayInfoDao.getItem(user, itemId)?.copy(
@ -188,7 +192,7 @@ class PlaylistViewModel
) )
?: LibraryDisplayInfo( ?: LibraryDisplayInfo(
userId = user.rowId, userId = user.rowId,
itemId = itemId, itemId = itemId.toServerString(),
sort = sortAndDirection.sort, sort = sortAndDirection.sort,
direction = sortAndDirection.direction, direction = sortAndDirection.direction,
filter = filter, filter = filter,
@ -200,7 +204,7 @@ class PlaylistViewModel
val request = val request =
filter.applyTo( filter.applyTo(
GetItemsRequest( GetItemsRequest(
parentId = playlistId, parentId = itemId,
userId = user.id, userId = user.id,
fields = DefaultItemFields, fields = DefaultItemFields,
sortBy = listOf(sortAndDirection.sort), sortBy = listOf(sortAndDirection.sort),
@ -215,16 +219,19 @@ class PlaylistViewModel
GetItemsRequestHandler, GetItemsRequestHandler,
viewModelScope, viewModelScope,
).init() ).init()
state.update {
withContext(Dispatchers.Main) { it.copy(
items.value = pager items = pager,
loading.value = LoadingState.Success loading = LoadingState.Success,
)
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching playlist %s", itemId) Timber.e(ex, "Error fetching playlist %s", itemId)
withContext(Dispatchers.Main) { state.update {
items.value = listOf() it.copy(
loading.value = LoadingState.Error(ex) items = emptyList(),
loading = LoadingState.Error(ex),
)
} }
} }
} }
@ -238,13 +245,13 @@ class PlaylistViewModel
private suspend fun determineMediaType() { private suspend fun determineMediaType() {
// Use the type the server says // Use the type the server says
var mediaType = var mediaType =
super.item.value state.value.playlist
?.data ?.data
?.mediaType ?: MediaType.UNKNOWN ?.mediaType ?: MediaType.UNKNOWN
mediaType = mediaType =
if (mediaType == MediaType.UNKNOWN) { if (mediaType == MediaType.UNKNOWN) {
// Otherwise, if a most of the list is one type, we can assume that type // Otherwise, if a most of the list is one type, we can assume that type
val pager = (this@PlaylistViewModel.items.value as? ApiRequestPager<*>) val pager = (state.value.items as? ApiRequestPager<*>)
if (pager != null && pager.size <= 50) { if (pager != null && pager.size <= 50) {
val types = val types =
(0..<50.coerceAtMost(pager.size)).groupBy { index -> (0..<50.coerceAtMost(pager.size)).groupBy { index ->
@ -273,14 +280,16 @@ class PlaylistViewModel
mediaType mediaType
} }
Timber.d("mediaType=%s", mediaType) Timber.d("mediaType=%s", mediaType)
playlistMediaType.update { mediaType } state.update {
it.copy(mediaType = mediaType)
}
} }
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues( FilterUtils.getFilterOptionValues(
api, api,
serverRepository.currentUser.value?.id, serverRepository.currentUser.value?.id,
itemUuid, itemId,
filterOption, filterOption,
) )
@ -290,15 +299,18 @@ class PlaylistViewModel
} }
} }
fun playMusic( fun setWatched(
index: Int, itemId: UUID,
shuffle: Boolean, played: Boolean,
) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
viewModelScope.launchDefault { favoriteWatchManager.setWatched(itemId, played)
(items.value as? ApiRequestPager<*>)?.let { }
musicService.setQueue(it, index, shuffle)
} fun setFavorite(
} itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
} }
} }
@ -308,68 +320,100 @@ data class FilterAndSort(
val sortAndDirection: SortAndDirection, val sortAndDirection: SortAndDirection,
) )
data class PlaylistDetailsState(
val playlist: BaseItem? = null,
val mediaType: MediaType = MediaType.UNKNOWN,
val items: List<BaseItem?> = emptyList(),
val filterAndSort: FilterAndSort =
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
val loading: LoadingState = LoadingState.Pending,
)
@Composable @Composable
fun PlaylistDetails( fun PlaylistDetails(
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaylistViewModel = hiltViewModel(), viewModel: PlaylistViewModel =
hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { val state by viewModel.state.collectAsState()
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())
val filterAndSort by viewModel.filterAndSort.collectAsState()
val musicState by viewModel.musicState.collectAsState() val musicState by viewModel.musicState.collectAsState()
val playlistMediaType by viewModel.playlistMediaType.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
var showConfirmTypeDialog by remember { mutableStateOf<Pair<Int, Boolean>?>(null) } var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
val goToString = stringResource(R.string.go_to)
val playFromHereString = stringResource(R.string.play_from_here)
fun play( fun play(
index: Int, index: Int,
item: BaseItem,
shuffle: Boolean, shuffle: Boolean,
mediaTypeOverride: MediaType? = null, mediaTypeOverride: MediaType? = null,
) { ) {
when (mediaTypeOverride ?: playlistMediaType) { when (mediaTypeOverride ?: state.mediaType) {
MediaType.VIDEO -> { MediaType.VIDEO -> {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
itemId = destination.itemId, itemId = destination.itemId,
startIndex = index, startIndex = index,
shuffle = shuffle, shuffle = shuffle,
filter = filterAndSort.filter, filter = state.filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection, sortAndDirection = state.filterAndSort.sortAndDirection,
), ),
) )
} }
MediaType.AUDIO -> { MediaType.AUDIO -> {
viewModel.playMusic(index, shuffle) viewModel.play(item, index, shuffle)
} }
else -> { else -> {
showConfirmTypeDialog = Pair(index, shuffle) showConfirmTypeDialog = Triple(index, item, shuffle)
} }
} }
} }
val musicMoreActions =
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
onClickPlayNext = { index, item -> viewModel.playNext(item) },
onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
onClickFavorite = { id, favorite -> },
onClickAddPlaylist = {},
onClickRemoveFromQueue = {},
)
val moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { id, watched -> },
onClickFavorite = { id, favorite -> },
onClickAddPlaylist = {},
onSendMediaInfo = {},
onClickDelete = {},
)
PlaylistDetailsContent( PlaylistDetailsContent(
loadingState = loading, loadingState = state.loading,
playlist = playlist, playlist = state.playlist,
items = items, items = state.items,
musicState = musicState, musicState = musicState,
onChangeBackdrop = viewModel::updateBackdrop, onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, item -> onClickIndex = { index, item ->
play(index, false) play(index, item, false)
}, },
onClickPlay = { shuffle -> onClickPlay = { shuffle ->
play(0, shuffle) state.playlist?.let {
play(0, it, shuffle)
}
}, },
onLongClickIndex = { index, item -> onLongClickIndex = { index, item ->
longClickDialog = longClickDialog =
@ -377,23 +421,29 @@ fun PlaylistDetails(
fromLongClick = true, fromLongClick = true,
title = item.name ?: "", title = item.name ?: "",
items = items =
listOf( if (item.type == BaseItemKind.AUDIO) {
DialogItem( buildMoreDialogForMusic(
goToString, context = context,
Icons.Default.ArrowForward, actions = musicMoreActions,
) { item = item,
viewModel.navigationManager.navigateTo(item.destination()) index = index,
}, canRemove = false,
DialogItem( )
playFromHereString, } else {
Icons.Default.PlayArrow, buildMoreDialogItemsForHome(
) { context = context,
play(index, false) item = item,
}, seriesId = item.data.seriesId,
), playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = false,
actions = moreActions,
)
},
) )
}, },
filterAndSort = filterAndSort, filterAndSort = state.filterAndSort,
onFilterAndSortChange = viewModel::loadItems, onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues, getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier, modifier = modifier,
@ -404,9 +454,9 @@ fun PlaylistDetails(
onDismissRequest = { longClickDialog = null }, onDismissRequest = { longClickDialog = null },
) )
} }
showConfirmTypeDialog?.let { (index, shuffle) -> showConfirmTypeDialog?.let { (index, item, shuffle) ->
ConfirmMediaTypeDialog( ConfirmMediaTypeDialog(
onConfirm = { mediaType -> play(index, shuffle, mediaType) }, onConfirm = { mediaType -> play(index, item, shuffle, mediaType) },
onCancel = { showConfirmTypeDialog = null }, onCancel = { showConfirmTypeDialog = null },
) )
} }