mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
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:
parent
0b995b5404
commit
9c56965c82
4 changed files with 328 additions and 116 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -61,4 +61,5 @@
|
|||
<string name="fa_compact_disc" translatable="false"></string>
|
||||
<string name="fa_xmark" translatable="false"></string>
|
||||
<string name="fa_dice" translatable="false"></string>
|
||||
<string name="fa_circle_xmark" translatable="false"></string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue