mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-07 16:06:35 +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(
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,5 @@
|
||||||
<string name="fa_compact_disc" translatable="false"></string>
|
<string name="fa_compact_disc" translatable="false"></string>
|
||||||
<string name="fa_xmark" translatable="false"></string>
|
<string name="fa_xmark" translatable="false"></string>
|
||||||
<string name="fa_dice" translatable="false"></string>
|
<string name="fa_dice" translatable="false"></string>
|
||||||
|
<string name="fa_circle_xmark" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue