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
<img width="1280" height="720" alt="playlist_buttons Large"
src="https://github.com/user-attachments/assets/ce7880c3-b823-4b69-9ad4-cfc9e7ee5fd5"
/>

### Without move buttons
<img width="1280" height="720" alt="playlist_no_buttons Large"
src="https://github.com/user-attachments/assets/f2027052-4628-4d44-8257-6c2a5e8c3af4"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-10 17:18:26 -04:00 • committed by GitHub
parent 0b995b5404
commit 9c56965c82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 328 additions and 116 deletions

View file

@ -41,6 +41,7 @@ sealed interface ContextMenu {
data class ForBaseItem( data class ForBaseItem(
val fromLongClick: Boolean, val fromLongClick: Boolean,
val item: BaseItem, val item: BaseItem,
val index: Int = UNSPECIFIED_INDEX,
val chosenStreams: ChosenStreams?, val chosenStreams: ChosenStreams?,
val showGoTo: Boolean, val showGoTo: Boolean,
val showStreamChoices: Boolean, val showStreamChoices: Boolean,
@ -48,6 +49,7 @@ sealed interface ContextMenu {
val canRemoveContinueWatching: Boolean, val canRemoveContinueWatching: Boolean,
val canRemoveNextUp: Boolean, val canRemoveNextUp: Boolean,
val actions: ContextMenuActions, val actions: ContextMenuActions,
val showRemoveFromPlaylist: Boolean = false,
) : ContextMenu ) : ContextMenu
data class ForPerson( data class ForPerson(
@ -63,6 +65,7 @@ sealed interface ContextMenu {
val canDelete: Boolean, val canDelete: Boolean,
val canRemoveFromQueue: Boolean, val canRemoveFromQueue: Boolean,
val actions: MusicContextActions, val actions: MusicContextActions,
val showRemoveFromPlaylist: Boolean = false,
) : ContextMenu ) : ContextMenu
data class ForQueue( data class ForQueue(
@ -71,6 +74,10 @@ sealed interface ContextMenu {
val index: Int, val index: Int,
val actions: QueueContextActions, val actions: QueueContextActions,
) : ContextMenu ) : ContextMenu
companion object {
const val UNSPECIFIED_INDEX = -12
}
} }
data class ContextMenuActions( data class ContextMenuActions(
@ -87,6 +94,7 @@ data class ContextMenuActions(
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
val onClickAddToQueue: (BaseItem) -> Unit = {}, val onClickAddToQueue: (BaseItem) -> Unit = {},
val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> },
) )
data class PersonContextActions( data class PersonContextActions(
@ -109,6 +117,7 @@ data class MusicContextActions(
val onClickGoToArtist: (UUID) -> Unit = { val onClickGoToArtist: (UUID) -> Unit = {
navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
}, },
val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> },
) )
data class QueueContextActions( data class QueueContextActions(
@ -200,11 +209,7 @@ fun ContextMenu(
seriesId = item.data.seriesId, seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
showGoTo = contextMenu.showGoTo, contextMenu = contextMenu,
showStreamChoices = contextMenu.showStreamChoices,
canDelete = contextMenu.canDelete,
canRemoveContinueWatching = contextMenu.canRemoveContinueWatching,
canRemoveNextUp = contextMenu.canRemoveNextUp,
actions = actions, actions = actions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -319,17 +324,13 @@ fun ContextMenu(
private fun buildContextMenuItems( private fun buildContextMenuItems(
resources: Resources, resources: Resources,
contextMenu: ContextMenu.ForBaseItem,
item: BaseItem, item: BaseItem,
seriesId: UUID?, seriesId: UUID?,
sourceId: UUID?, sourceId: UUID?,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canClearChosenStreams: Boolean, canClearChosenStreams: Boolean,
showGoTo: Boolean,
showStreamChoices: Boolean,
canDelete: Boolean,
canRemoveContinueWatching: Boolean,
canRemoveNextUp: Boolean,
actions: ContextMenuActions, actions: ContextMenuActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
@ -340,7 +341,7 @@ private fun buildContextMenuItems(
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
// Songs should not show Go to // Songs should not show Go to
if (showGoTo && item.type != BaseItemKind.AUDIO) { if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) {
add( add(
DialogItem( DialogItem(
resources.getString(R.string.go_to), resources.getString(R.string.go_to),
@ -414,7 +415,7 @@ private fun buildContextMenuItems(
}, },
) )
} }
if (showStreamChoices) { if (contextMenu.showStreamChoices) {
item.data.mediaSources?.letNotEmpty { sources -> item.data.mediaSources?.letNotEmpty { sources ->
val source = val source =
sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } 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( add(
DialogItem( DialogItem(
text = R.string.add_to_playlist, text = R.string.add_to_playlist,
@ -487,7 +499,7 @@ private fun buildContextMenuItems(
actions.onClickAddPlaylist.invoke(item.id) actions.onClickAddPlaylist.invoke(item.id)
}, },
) )
if (canDelete) { if (contextMenu.canDelete) {
add( add(
DialogItem( DialogItem(
resources.getString(R.string.delete), 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( add(
DialogItem( DialogItem(
text = R.string.remove_continue_watching, 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( add(
DialogItem( DialogItem(
text = R.string.remove_next_up, text = R.string.remove_next_up,
@ -602,7 +614,7 @@ private fun buildContextMenuItems(
}, },
) )
} }
if (showStreamChoices && canClearChosenStreams) { if (contextMenu.showStreamChoices && canClearChosenStreams) {
add( add(
DialogItem( DialogItem(
resources.getString(R.string.clear_track_choices), 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( add(
DialogItem( DialogItem(
text = R.string.add_to_playlist, text = R.string.add_to_playlist,

View file

@ -14,9 +14,11 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn 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
@ -34,6 +36,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester 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.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.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
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.ServerReportService 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.SlimItemFields
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.Button
import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog 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.ifElse
import com.github.damontecres.wholphin.ui.launchDefault 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.main.settings.MoveDirection
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.roundSeconds 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.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
@ -116,12 +125,16 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
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 org.jellyfin.sdk.api.client.ApiClient 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.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind 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.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@ -146,6 +159,7 @@ class PlaylistViewModel
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val serverReportService: ServerReportService, private val serverReportService: ServerReportService,
private val filterOptionCache: FilterOptionCache, private val filterOptionCache: FilterOptionCache,
private val playlistCreator: PlaylistCreator,
@Assisted itemId: UUID, @Assisted itemId: UUID,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory @AssistedFactory
@ -169,9 +183,47 @@ class PlaylistViewModel
.getItem(itemId) .getItem(itemId)
.content .content
.let { BaseItem(it, false) } .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 = val libraryDisplayInfo =
serverRepository.currentUser?.let { user -> user?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId) libraryDisplayInfoDao.getItem(user, itemId)
} }
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
@ -333,6 +385,47 @@ class PlaylistViewModel
fun sendMediaReport(itemId: UUID) { fun sendMediaReport(itemId: UUID) {
viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } 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 @Immutable
@ -355,6 +448,7 @@ data class PlaylistDetailsState(
), ),
), ),
val loading: LoadingState = LoadingState.Pending, val loading: LoadingState = LoadingState.Pending,
val canEdit: Boolean = false,
) )
@Composable @Composable
@ -418,6 +512,7 @@ fun PlaylistDetails(
}, },
onClickRemoveFromQueue = { _, _ -> }, onClickRemoveFromQueue = { _, _ -> },
onDeleteItem = viewModel::deleteItem, onDeleteItem = viewModel::deleteItem,
onRemoveFromPlaylist = viewModel::removeFromPlaylist,
) )
} }
val contextActions = val contextActions =
@ -438,6 +533,7 @@ fun PlaylistDetails(
onChooseTracks = {}, onChooseTracks = {},
onClearChosenStreams = {}, onClearChosenStreams = {},
onClickRemoveFromNextUp = {}, onClickRemoveFromNextUp = {},
onRemoveFromPlaylist = viewModel::removeFromPlaylist,
) )
} }
@ -455,21 +551,23 @@ fun PlaylistDetails(
play(0, it, shuffle) play(0, it, shuffle)
} }
}, },
onLongClickIndex = { index, item -> onShowContextMenu = { index, item, fromLongClick ->
showContextMenu = showContextMenu =
if (item.type == BaseItemKind.AUDIO) { if (item.type == BaseItemKind.AUDIO) {
ContextMenu.ForMusic( ContextMenu.ForMusic(
fromLongClick = true, fromLongClick = fromLongClick,
item = item, item = item,
index = index, index = index,
canDelete = viewModel.canDelete(item, preferences.appPreferences), canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveFromQueue = false, canRemoveFromQueue = false,
actions = musicContextActions, actions = musicContextActions,
showRemoveFromPlaylist = state.canEdit,
) )
} else { } else {
ContextMenu.ForBaseItem( ContextMenu.ForBaseItem(
fromLongClick = true, fromLongClick = fromLongClick,
item = item, item = item,
index = index,
chosenStreams = null, chosenStreams = null,
showGoTo = true, showGoTo = true,
showStreamChoices = false, showStreamChoices = false,
@ -477,12 +575,15 @@ fun PlaylistDetails(
canRemoveContinueWatching = false, canRemoveContinueWatching = false,
canRemoveNextUp = false, canRemoveNextUp = false,
actions = contextActions, actions = contextActions,
showRemoveFromPlaylist = state.canEdit,
) )
} }
}, },
filterAndSort = state.filterAndSort, filterAndSort = state.filterAndSort,
onFilterAndSortChange = viewModel::loadItems, onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues, getPossibleFilterValues = viewModel::getFilterOptionValues,
canEdit = state.canEdit,
onMoveItem = viewModel::onMoveItem,
modifier = modifier, modifier = modifier,
) )
showContextMenu?.let { contextMenu -> showContextMenu?.let { contextMenu ->
@ -525,13 +626,15 @@ fun PlaylistDetailsContent(
items: List<BaseItem?>, items: List<BaseItem?>,
musicState: MusicServiceState, musicState: MusicServiceState,
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onShowContextMenu: (Int, BaseItem, Boolean) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit,
onMoveItem: (Int, UUID, MoveDirection) -> Unit,
filterAndSort: FilterAndSort, filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
loadingState: LoadingState, loadingState: LoadingState,
canEdit: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) } var savedIndex by rememberSaveable { mutableIntStateOf(0) }
@ -579,11 +682,16 @@ fun PlaylistDetailsContent(
filterAndSort = filterAndSort, filterAndSort = filterAndSort,
onFilterAndSortChange = onFilterAndSortChange, onFilterAndSortChange = onFilterAndSortChange,
getPossibleFilterValues = getPossibleFilterValues, getPossibleFilterValues = getPossibleFilterValues,
filterOptions = DefaultPlaylistItemsOptions,
modifier = modifier =
Modifier Modifier
.padding(top = 80.dp) .padding(top = 80.dp)
.fillMaxWidth(.25f), .fillMaxWidth(.25f),
) )
val filterCount =
remember(filterAndSort) {
filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions)
}
PlaylistItems( PlaylistItems(
loadingState = loadingState, loadingState = loadingState,
items = items, items = items,
@ -596,15 +704,22 @@ fun PlaylistDetailsContent(
savedIndex = index savedIndex = index
item?.let { onClickIndex.invoke(index, item) } item?.let { onClickIndex.invoke(index, item) }
}, },
onLongClickItem = { index, item -> onShowContextMenu = { index, item, fromLongClick ->
savedIndex = index 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 =
Modifier Modifier
.padding(horizontal = 16.dp) .padding(start = 16.dp)
.weight(1f) .weight(1f)
.focusRequester(focusRequester), .focusRequester(focusRequester)
.focusProperties {
onExit = {
playButtonFocusRequester.tryRequestFocus()
}
},
) )
} }
} }
@ -614,6 +729,7 @@ fun PlaylistDetailsContent(
@Composable @Composable
fun PlaylistDetailsHeader( fun PlaylistDetailsHeader(
focusedItem: BaseItem?, focusedItem: BaseItem?,
filterOptions: List<ItemFilterBy<*>>,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
playButtonFocusRequester: FocusRequester, playButtonFocusRequester: FocusRequester,
focusRequester: FocusRequester, focusRequester: FocusRequester,
@ -648,7 +764,7 @@ fun PlaylistDetailsHeader(
modifier = Modifier, modifier = Modifier,
) { ) {
FilterByButton( FilterByButton(
filterOptions = DefaultPlaylistItemsOptions, filterOptions = filterOptions,
current = filterAndSort.filter, current = filterAndSort.filter,
onFilterChange = { onFilterChange = {
onFilterAndSortChange.invoke( onFilterAndSortChange.invoke(
@ -697,11 +813,14 @@ fun PlaylistItems(
items: List<BaseItem?>, items: List<BaseItem?>,
musicState: MusicServiceState, musicState: MusicServiceState,
playButtonFocusRequester: FocusRequester, playButtonFocusRequester: FocusRequester,
canMove: Boolean,
onMoveItem: (Int, UUID, MoveDirection) -> Unit,
onFocusItem: (Int, BaseItem?) -> Unit, onFocusItem: (Int, BaseItem?) -> Unit,
onClickItem: (Int, BaseItem?) -> Unit, onClickItem: (Int, BaseItem?) -> Unit,
onLongClickItem: (Int, BaseItem?) -> Unit, onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusManager = LocalFocusManager.current
when (loadingState) { when (loadingState) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(loadingState, modifier) ErrorMessage(loadingState, modifier)
@ -724,10 +843,7 @@ fun PlaylistItems(
.surfaceColorAtElevation(1.dp) .surfaceColorAtElevation(1.dp)
.copy(alpha = .75f), .copy(alpha = .75f),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
).focusProperties { ).focusGroup()
left = playButtonFocusRequester
previous = playButtonFocusRequester
}.focusGroup()
.focusRestorer(), .focusRestorer(),
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
@ -738,7 +854,7 @@ fun PlaylistItems(
onClickItem.invoke(index, item) onClickItem.invoke(index, item)
}, },
onLongClick = { onLongClick = {
onLongClickItem.invoke(index, item) onShowContextMenu.invoke(index, item, true)
}, },
isPlaying = isPlaying =
equalsNotNull( equalsNotNull(
@ -746,18 +862,27 @@ fun PlaylistItems(
item?.id, item?.id,
), ),
isQueued = item?.id in musicState.queuedIds, 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 =
Modifier Modifier
.animateItem()
.ifElse( .ifElse(
item?.type != BaseItemKind.AUDIO, item?.type != BaseItemKind.AUDIO,
Modifier.height(80.dp), Modifier.height(80.dp),
).onFocusChanged { ).onFocusChanged {
if (it.isFocused) { if (it.hasFocus) {
onFocusItem(index, item) onFocusItem(index, item)
} }
}.focusProperties {
left = playButtonFocusRequester
previous = playButtonFocusRequester
}, },
) )
} }
@ -788,6 +913,11 @@ fun PlaylistItem(
index: Int, index: Int,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
canMove: Boolean,
moveUpAllowed: Boolean,
moveDownAllowed: Boolean,
onClickMove: (MoveDirection) -> Unit,
onClickMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
isPlaying: Boolean = false, isPlaying: Boolean = false,
@ -797,6 +927,14 @@ fun PlaylistItem(
val imageWidth = 160.dp val imageWidth = 160.dp
val density = LocalDensity.current val density = LocalDensity.current
val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } }
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 88.dp),
) {
ListItem( ListItem(
selected = false, selected = false,
onClick = onClick, onClick = onClick,
@ -870,9 +1008,18 @@ fun PlaylistItem(
isQueued = isQueued, isQueued = isQueued,
) )
} else { } else {
val imageType =
remember(item) {
if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) {
ImageType.THUMB
} else {
ImageType.PRIMARY
}
}
ItemCardImage( ItemCardImage(
item = item, item = item,
name = item?.name, name = item?.name,
imageType = imageType,
showOverlay = true, showOverlay = true,
favorite = item?.data?.userData?.isFavorite ?: false, favorite = item?.data?.userData?.isFavorite ?: false,
watched = item?.data?.userData?.played ?: false, watched = item?.data?.userData?.played ?: false,
@ -886,8 +1033,48 @@ fun PlaylistItem(
} }
} }
}, },
modifier = modifier, 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,
)
}
Button(
onClick = { onClickMove.invoke(MoveDirection.DOWN) },
enabled = moveDownAllowed,
contentHeight = contentHeight,
) {
Text(
text = stringResource(R.string.fa_caret_down),
fontFamily = FontAwesome,
)
}
}
Button(
onClick = onClickMore,
enabled = true,
contentHeight = contentHeight,
) {
Text(
text = stringResource(R.string.fa_ellipsis_vertical),
fontFamily = FontAwesome,
)
}
}
}
} }
@Composable @Composable

View file

@ -61,4 +61,5 @@
<string name="fa_compact_disc" translatable="false">&#xf51f;</string> <string name="fa_compact_disc" translatable="false">&#xf51f;</string>
<string name="fa_xmark" translatable="false">&#xf00d;</string> <string name="fa_xmark" translatable="false">&#xf00d;</string>
<string name="fa_dice" translatable="false">&#xf522;</string> <string name="fa_dice" translatable="false">&#xf522;</string>
<string name="fa_circle_xmark" translatable="false">&#xf057;</string>
</resources> </resources>

View file

@ -922,4 +922,5 @@
<string name="dim_screen">Dim screen</string> <string name="dim_screen">Dim screen</string>
<string name="photo_albums">Photo albums</string> <string name="photo_albums">Photo albums</string>
<string name="no_favorites_found">No favorites found!</string> <string name="no_favorites_found">No favorites found!</string>
<string name="remove_from_playlist">Remove from playlist</string>
</resources> </resources>