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(
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<DialogItem> =
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,

View file

@ -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<BaseItem?>,
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<FilterValueOption>,
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<ItemFilterBy<*>>,
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<BaseItem?>,
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

View file

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

View file

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