From 9c56965c82db934879fb94fb3c0f2576239d78ef Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 10 Sep 2026 17:18:26 -0400 Subject: [PATCH] Add controls to edit playlists (#1927) ## Description Adds support to remove items from a playlist and move items within a playlist The remove option is in the context menu. Moving has buttons on the right side. The buttons are available if the user has edit permissions and the playlist is sorted by "Default" (the playlist order). Remove from playlist option is available if the user has edit permissions. Also, thumb images are now preferred instead of primary/poster images since the layout is much wider than tall. ### Related issues Closes #1881 ### Testing Emulator with both a user with & without permissions to edit ## Screenshots ### With move buttons playlist_buttons Large ### Without move buttons playlist_no_buttons Large ## AI or LLM usage None --- .../wholphin/ui/components/ContextMenu.kt | 55 ++- .../wholphin/ui/detail/PlaylistDetails.kt | 387 +++++++++++++----- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 328 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index cf7b32d6..2f39f238 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -41,6 +41,7 @@ sealed interface ContextMenu { data class ForBaseItem( val fromLongClick: Boolean, val item: BaseItem, + val index: Int = UNSPECIFIED_INDEX, val chosenStreams: ChosenStreams?, val showGoTo: Boolean, val showStreamChoices: Boolean, @@ -48,6 +49,7 @@ sealed interface ContextMenu { val canRemoveContinueWatching: Boolean, val canRemoveNextUp: Boolean, val actions: ContextMenuActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForPerson( @@ -63,6 +65,7 @@ sealed interface ContextMenu { val canDelete: Boolean, val canRemoveFromQueue: Boolean, val actions: MusicContextActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForQueue( @@ -71,6 +74,10 @@ sealed interface ContextMenu { val index: Int, val actions: QueueContextActions, ) : ContextMenu + + companion object { + const val UNSPECIFIED_INDEX = -12 + } } data class ContextMenuActions( @@ -87,6 +94,7 @@ data class ContextMenuActions( val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, val onClickAddToQueue: (BaseItem) -> Unit = {}, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class PersonContextActions( @@ -109,6 +117,7 @@ data class MusicContextActions( val onClickGoToArtist: (UUID) -> Unit = { navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) }, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class QueueContextActions( @@ -200,11 +209,7 @@ fun ContextMenu( seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, - showGoTo = contextMenu.showGoTo, - showStreamChoices = contextMenu.showStreamChoices, - canDelete = contextMenu.canDelete, - canRemoveContinueWatching = contextMenu.canRemoveContinueWatching, - canRemoveNextUp = contextMenu.canRemoveNextUp, + contextMenu = contextMenu, actions = actions, onChooseVersion = { chooseVersion = @@ -319,17 +324,13 @@ fun ContextMenu( private fun buildContextMenuItems( resources: Resources, + contextMenu: ContextMenu.ForBaseItem, item: BaseItem, seriesId: UUID?, sourceId: UUID?, watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - showGoTo: Boolean, - showStreamChoices: Boolean, - canDelete: Boolean, - canRemoveContinueWatching: Boolean, - canRemoveNextUp: Boolean, actions: ContextMenuActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -340,7 +341,7 @@ private fun buildContextMenuItems( ): List = buildList { // Songs should not show Go to - if (showGoTo && item.type != BaseItemKind.AUDIO) { + if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.go_to), @@ -414,7 +415,7 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices) { + if (contextMenu.showStreamChoices) { item.data.mediaSources?.letNotEmpty { sources -> val source = sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } @@ -478,6 +479,17 @@ private fun buildContextMenuItems( }, ) } + if (contextMenu.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(contextMenu.index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -487,7 +499,7 @@ private fun buildContextMenuItems( actions.onClickAddPlaylist.invoke(item.id) }, ) - if (canDelete) { + if (contextMenu.canDelete) { add( DialogItem( resources.getString(R.string.delete), @@ -499,7 +511,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { + if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { add( DialogItem( text = R.string.remove_continue_watching, @@ -510,7 +522,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { add( DialogItem( text = R.string.remove_next_up, @@ -602,7 +614,7 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices && canClearChosenStreams) { + if (contextMenu.showStreamChoices && canClearChosenStreams) { add( DialogItem( resources.getString(R.string.clear_track_choices), @@ -785,6 +797,17 @@ fun buildContextForMusic( }, ) } + if (music.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index fa3443e3..b7085db6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -14,9 +14,11 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -34,6 +36,7 @@ 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.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -41,6 +44,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -69,10 +73,13 @@ import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -100,9 +107,11 @@ import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundSeconds +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.LocalClock @@ -116,12 +125,16 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder @@ -146,6 +159,7 @@ class PlaylistViewModel private val favoriteWatchManager: FavoriteWatchManager, private val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, + private val playlistCreator: PlaylistCreator, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory @@ -169,9 +183,47 @@ class PlaylistViewModel .getItem(itemId) .content .let { BaseItem(it, false) } - state.update { it.copy(playlist = playlist) } + val user = serverRepository.currentUser + val canEdit = + user?.let { user -> + try { + val permission by api.playlistsApi.getPlaylistUser(itemId, user.id) + permission.canEdit + } catch (ex: CancellationException) { + throw ex + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + // Server will return this if no permission exists + Timber.w( + "User doesn't have permission to edit playlist %s", + itemId, + ) + } else { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + } + false + } catch (ex: Exception) { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + false + } + } ?: false + state.update { + it.copy( + playlist = playlist, + canEdit = canEdit, + ) + } + val libraryDisplayInfo = - serverRepository.currentUser?.let { user -> + user?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() @@ -333,6 +385,47 @@ class PlaylistViewModel fun sendMediaReport(itemId: UUID) { viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } } + + fun removeFromPlaylist( + index: Int, + itemId: UUID, + ) { + viewModelScope.launchIO { + try { + playlistCreator.removeFromServerPlaylist( + playlistId = this@PlaylistViewModel.itemId, + itemId = itemId, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e( + ex, + "Error removing %s from playlist %s", + itemId, + this@PlaylistViewModel.itemId, + ) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onMoveItem( + index: Int, + itemId: UUID, + direction: MoveDirection, + ) { + viewModelScope.launchIO { + val newIndex = index + if (direction == MoveDirection.UP) -1 else 1 + api.playlistsApi.moveItem( + playlistId = this@PlaylistViewModel.itemId.toServerString(), + itemId = itemId.toServerString(), + newIndex = newIndex, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1) + } + } } @Immutable @@ -355,6 +448,7 @@ data class PlaylistDetailsState( ), ), val loading: LoadingState = LoadingState.Pending, + val canEdit: Boolean = false, ) @Composable @@ -418,6 +512,7 @@ fun PlaylistDetails( }, onClickRemoveFromQueue = { _, _ -> }, onDeleteItem = viewModel::deleteItem, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } val contextActions = @@ -438,6 +533,7 @@ fun PlaylistDetails( onChooseTracks = {}, onClearChosenStreams = {}, onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } @@ -455,21 +551,23 @@ fun PlaylistDetails( play(0, it, shuffle) } }, - onLongClickIndex = { index, item -> + onShowContextMenu = { index, item, fromLongClick -> showContextMenu = if (item.type == BaseItemKind.AUDIO) { ContextMenu.ForMusic( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, index = index, canDelete = viewModel.canDelete(item, preferences.appPreferences), canRemoveFromQueue = false, actions = musicContextActions, + showRemoveFromPlaylist = state.canEdit, ) } else { ContextMenu.ForBaseItem( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, + index = index, chosenStreams = null, showGoTo = true, showStreamChoices = false, @@ -477,12 +575,15 @@ fun PlaylistDetails( canRemoveContinueWatching = false, canRemoveNextUp = false, actions = contextActions, + showRemoveFromPlaylist = state.canEdit, ) } }, filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, + canEdit = state.canEdit, + onMoveItem = viewModel::onMoveItem, modifier = modifier, ) showContextMenu?.let { contextMenu -> @@ -525,13 +626,15 @@ fun PlaylistDetailsContent( items: List, musicState: MusicServiceState, onClickIndex: (Int, BaseItem) -> Unit, - onLongClickIndex: (Int, BaseItem) -> Unit, + onShowContextMenu: (Int, BaseItem, Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, filterAndSort: FilterAndSort, onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, loadingState: LoadingState, + canEdit: Boolean, modifier: Modifier = Modifier, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } @@ -579,11 +682,16 @@ fun PlaylistDetailsContent( filterAndSort = filterAndSort, onFilterAndSortChange = onFilterAndSortChange, getPossibleFilterValues = getPossibleFilterValues, + filterOptions = DefaultPlaylistItemsOptions, modifier = Modifier .padding(top = 80.dp) .fillMaxWidth(.25f), ) + val filterCount = + remember(filterAndSort) { + filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions) + } PlaylistItems( loadingState = loadingState, items = items, @@ -596,15 +704,22 @@ fun PlaylistDetailsContent( savedIndex = index item?.let { onClickIndex.invoke(index, item) } }, - onLongClickItem = { index, item -> + onShowContextMenu = { index, item, fromLongClick -> savedIndex = index - item?.let { onLongClickIndex.invoke(index, item) } + item?.let { onShowContextMenu.invoke(index, item, fromLongClick) } }, + canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0, + onMoveItem = onMoveItem, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(start = 16.dp) .weight(1f) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }, ) } } @@ -614,6 +729,7 @@ fun PlaylistDetailsContent( @Composable fun PlaylistDetailsHeader( focusedItem: BaseItem?, + filterOptions: List>, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, focusRequester: FocusRequester, @@ -648,7 +764,7 @@ fun PlaylistDetailsHeader( modifier = Modifier, ) { FilterByButton( - filterOptions = DefaultPlaylistItemsOptions, + filterOptions = filterOptions, current = filterAndSort.filter, onFilterChange = { onFilterAndSortChange.invoke( @@ -697,11 +813,14 @@ fun PlaylistItems( items: List, musicState: MusicServiceState, playButtonFocusRequester: FocusRequester, + canMove: Boolean, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, onFocusItem: (Int, BaseItem?) -> Unit, onClickItem: (Int, BaseItem?) -> Unit, - onLongClickItem: (Int, BaseItem?) -> Unit, + onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val focusManager = LocalFocusManager.current when (loadingState) { is LoadingState.Error -> { ErrorMessage(loadingState, modifier) @@ -724,10 +843,7 @@ fun PlaylistItems( .surfaceColorAtElevation(1.dp) .copy(alpha = .75f), shape = RoundedCornerShape(16.dp), - ).focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }.focusGroup() + ).focusGroup() .focusRestorer(), ) { itemsIndexed(items) { index, item -> @@ -738,7 +854,7 @@ fun PlaylistItems( onClickItem.invoke(index, item) }, onLongClick = { - onLongClickItem.invoke(index, item) + onShowContextMenu.invoke(index, item, true) }, isPlaying = equalsNotNull( @@ -746,18 +862,27 @@ fun PlaylistItems( item?.id, ), isQueued = item?.id in musicState.queuedIds, + canMove = canMove, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClickMove = { direction -> + item?.let { onMoveItem.invoke(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { onShowContextMenu.invoke(index, item, false) }, modifier = Modifier + .animateItem() .ifElse( item?.type != BaseItemKind.AUDIO, Modifier.height(80.dp), ).onFocusChanged { - if (it.isFocused) { + if (it.hasFocus) { onFocusItem(index, item) } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester }, ) } @@ -788,6 +913,11 @@ fun PlaylistItem( index: Int, onClick: () -> Unit, onLongClick: () -> Unit, + canMove: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClickMove: (MoveDirection) -> Unit, + onClickMore: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, isPlaying: Boolean = false, @@ -797,97 +927,154 @@ fun PlaylistItem( val imageWidth = 160.dp val density = LocalDensity.current val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } - ListItem( - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - trailingContent = { - val duration = - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + val duration = + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } - else -> { - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } + } + duration?.let { duration -> + val now by LocalClock.current.now + val context = LocalContext.current + val endTimeStr = + remember(item, now, context) { + val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) + formatTime(context, endTime) + } + val resources = LocalResources.current + val durationText = + remember(resources, duration) { resources.formatDuration(duration) } + Column { + Text( + text = durationText, + ) + if (item?.type != BaseItemKind.AUDIO) { + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } } } - duration?.let { duration -> - val now by LocalClock.current.now - val context = LocalContext.current - val endTimeStr = - remember(item, now, context) { - val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - formatTime(context, endTime) - } - val resources = LocalResources.current - val durationText = - remember(resources, duration) { resources.formatDuration(duration) } - Column { + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { Text( - text = durationText, + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, ) - if (item?.type != BaseItemKind.AUDIO) { - Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } else { + val imageType = + remember(item) { + if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) { + ImageType.THUMB + } else { + ImageType.PRIMARY + } + } + ItemCardImage( + item = item, + name = item?.name, + imageType = imageType, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + modifier = Modifier.width(imageWidth), + useFallbackText = false, + fillWidth = imageWidthPx, ) } } - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) - if (item?.type == BaseItemKind.AUDIO) { - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, + }, + modifier = Modifier.weight(1f), + ) + val contentHeight = 24.dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentWidth(), + ) { + if (canMove) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, ) - } else { - ItemCardImage( - item = item, - 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, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, ) } } - }, - modifier = modifier, - ) + Button( + onClick = onClickMore, + enabled = true, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_ellipsis_vertical), + fontFamily = FontAwesome, + ) + } + } + } } @Composable diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 61c7af06..45a6f7b2 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -61,4 +61,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8ae0187..a233096f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -922,4 +922,5 @@ Dim screen Photo albums No favorites found! + Remove from playlist