Add long click queue operations

This commit is contained in:
Damontecres 2026-03-03 22:27:39 -05:00
parent 6471b198b2
commit 73b626facd
No known key found for this signature in database
10 changed files with 176 additions and 22 deletions

View file

@ -9,6 +9,7 @@ import kotlin.time.Duration
data class AudioItem( data class AudioItem(
val id: UUID, val id: UUID,
val albumId: UUID?, val albumId: UUID?,
val artistId: UUID?,
val title: String?, val title: String?,
val albumTitle: String?, val albumTitle: String?,
val artistNames: String?, val artistNames: String?,
@ -24,6 +25,10 @@ data class AudioItem(
AudioItem( AudioItem(
id = item.id, id = item.id,
albumId = item.data.albumId, albumId = item.data.albumId,
artistId =
item.data.artistItems
?.firstOrNull()
?.id,
title = item.title, title = item.title,
albumTitle = item.data.album, albumTitle = item.data.album,
artistNames = item.data.albumArtist, artistNames = item.data.albumArtist,

View file

@ -244,12 +244,30 @@ class MusicService
updateQueueSize() updateQueueSize()
} }
suspend fun moveQueue(
index: Int,
newIndex: Int,
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, newIndex)
updateQueueSize()
}
suspend fun playIndex(index: Int) {
onMain { player.seekTo(index, 0L) }
// MusicPlayerListener will update state
}
suspend fun playNext(song: BaseItem) { suspend fun playNext(song: BaseItem) {
val mediaItem = convert(song) val mediaItem = convert(song)
onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) } onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) }
updateQueueSize() updateQueueSize()
} }
suspend fun removeFromQueue(index: Int) {
onMain { player.removeMediaItem(index) }
updateQueueSize()
}
private suspend fun <T> loading(block: suspend () -> T): T { private suspend fun <T> loading(block: suspend () -> T): T {
_state.update { it.copy(loadingState = LoadingState.Loading) } _state.update { it.copy(loadingState = LoadingState.Loading) }
val result = block.invoke() val result = block.invoke()
@ -336,17 +354,6 @@ private class MusicPlayerListener(
} }
} }
data class PlayerMediaItemList(
private val player: Player,
) {
val size: Int get() = player.mediaItemCount
operator fun get(index: Int): AudioItem {
// Timber.v("get %s", index)
return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
}
}
@Composable @Composable
fun rememberQueue( fun rememberQueue(
player: Player, player: Player,

View file

@ -282,6 +282,7 @@ fun AlbumDetailsPage(
playlistViewModel.loadPlaylists(MediaType.AUDIO) playlistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onClickRemoveFromQueue = {},
) )
} }
@ -362,6 +363,7 @@ fun AlbumDetailsPage(
actions = moreDialogActions, actions = moreDialogActions,
item = album, item = album,
index = 0, index = 0,
canRemove = false,
), ),
) )
}, },
@ -404,6 +406,7 @@ fun AlbumDetailsPage(
actions = moreDialogActions, actions = moreDialogActions,
item = song, item = song,
index = index, index = index,
canRemove = false,
), ),
) )
} }

View file

@ -300,6 +300,7 @@ fun ArtistDetailsPage(
playlistViewModel.loadPlaylists(MediaType.AUDIO) playlistViewModel.loadPlaylists(MediaType.AUDIO)
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onClickRemoveFromQueue = {},
) )
} }
@ -369,6 +370,7 @@ fun ArtistDetailsPage(
actions = moreDialogActions, actions = moreDialogActions,
item = artist, item = artist,
index = 0, index = 0,
canRemove = false,
), ),
) )
}, },
@ -408,6 +410,7 @@ fun ArtistDetailsPage(
actions = moreDialogActions, actions = moreDialogActions,
item = song, item = song,
index = index, index = index,
canRemove = false,
), ),
) )
} }

View file

@ -59,6 +59,8 @@ fun NowPlayingOverlay(
current: AudioItem?, current: AudioItem?,
queue: List<AudioItem>, queue: List<AudioItem>,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onClickSong: (Int, AudioItem) -> Unit,
onLongClickSong: (Int, AudioItem) -> Unit,
onClickMore: () -> Unit, onClickMore: () -> Unit,
onMoveQueue: (Int, MoveDirection) -> Unit, onMoveQueue: (Int, MoveDirection) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -159,10 +161,8 @@ fun NowPlayingOverlay(
runtime = song.runtime?.roundSeconds, runtime = song.runtime?.roundSeconds,
showArtist = true, showArtist = true,
isPlaying = current?.id == song.id, isPlaying = current?.id == song.id,
onClick = { onClick = { onClickSong.invoke(index, song) },
player.seekTo(index, 0L) onLongClick = { onLongClickSong.invoke(index, song) },
},
onLongClick = {},
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -42,6 +43,8 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.playback.BottomDialog import com.github.damontecres.wholphin.ui.playback.BottomDialog
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
@ -59,6 +62,7 @@ fun NowPlayingPage(
creationCallback = { it.create() }, creationCallback = { it.create() },
), ),
) { ) {
val context = LocalContext.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val player = viewModel.player val player = viewModel.player
val queue = rememberQueue(player, state.musicServiceState.queueSize) val queue = rememberQueue(player, state.musicServiceState.queueSize)
@ -97,9 +101,20 @@ fun NowPlayingPage(
) )
} }
val actions =
remember {
MusicQueueDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(index) },
onClickPlayNext = { index, _ -> viewModel.playNext(index) },
onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
)
}
var showMoreDialog by remember { mutableStateOf(false) } var showMoreDialog by remember { mutableStateOf(false) }
var lyricsActive by remember { mutableStateOf(true) } var lyricsActive by remember { mutableStateOf(true) }
var showDebugInfo by remember { mutableStateOf(true) } var showDebugInfo by remember { mutableStateOf(true) }
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@ -189,6 +204,22 @@ fun NowPlayingPage(
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
onClickMore = { showMoreDialog = true }, onClickMore = { showMoreDialog = true },
onClickSong = { index, _ -> viewModel.play(index) },
onLongClickSong = { index, song ->
itemMoreDialog =
DialogParams(
title = song.title ?: "",
fromLongClick = true,
items =
buildMoreDialogForMusicQueue(
context = context,
actions = actions,
item = song,
index = index,
canRemove = true,
),
)
},
modifier = modifier =
Modifier Modifier
.background(AppColors.TransparentBlack50) .background(AppColors.TransparentBlack50)
@ -199,6 +230,16 @@ fun NowPlayingPage(
LoadingPage(focusEnabled = false) LoadingPage(focusEnabled = false)
} }
} }
itemMoreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { itemMoreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
if (showMoreDialog) { if (showMoreDialog) {
NowPlayingBottomDialog( NowPlayingBottomDialog(
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,

View file

@ -44,7 +44,7 @@ class NowPlayingViewModel
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
@ -127,7 +127,7 @@ class NowPlayingViewModel
} else { } else {
null null
} }
Timber.v("lyricIndex=$lyricIndex") // Timber.v("lyricIndex=$lyricIndex")
state.update { state.update {
it.copy( it.copy(
lyrics = lyrics, lyrics = lyrics,
@ -178,6 +178,12 @@ class NowPlayingViewModel
direction: MoveDirection, direction: MoveDirection,
) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) } ) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) }
fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) }
fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) }
fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) }
fun stop() { fun stop() {
player.stop() player.stop()
navigationManager.goBack() navigationManager.goBack()

View file

@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
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.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
@ -68,11 +69,13 @@ fun SongListItem(
artist = if (showArtist) song?.data?.albumArtist else null, artist = if (showArtist) song?.data?.albumArtist else null,
indexNumber = song?.data?.indexNumber, indexNumber = song?.data?.indexNumber,
runtime = runtime =
remember(song) {
song song
?.data ?.data
?.runTimeTicks ?.runTimeTicks
?.ticks ?.ticks
?.roundMinutes, ?.roundSeconds
},
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier, modifier = modifier,

View file

@ -4,11 +4,13 @@ import android.content.Context
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
@ -34,6 +36,7 @@ data class MusicMoreDialogActions(
val onClickGoToArtist: (UUID) -> Unit = { val onClickGoToArtist: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
}, },
val onClickRemoveFromQueue: (Int) -> Unit,
) )
fun buildMoreDialogForMusic( fun buildMoreDialogForMusic(
@ -41,6 +44,7 @@ fun buildMoreDialogForMusic(
actions: MusicMoreDialogActions, actions: MusicMoreDialogActions,
item: BaseItem, item: BaseItem,
index: Int, index: Int,
canRemove: Boolean,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
add( add(
@ -61,6 +65,16 @@ fun buildMoreDialogForMusic(
actions.onClickPlayNext(index, item) actions.onClickPlayNext(index, item)
}, },
) )
if (canRemove) {
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
) {
actions.onClickRemoveFromQueue(index)
},
)
}
add( add(
DialogItem( DialogItem(
context.getString(R.string.add_to_queue), context.getString(R.string.add_to_queue),
@ -112,6 +126,77 @@ fun buildMoreDialogForMusic(
} }
} }
data class MusicQueueDialogActions(
val onNavigate: (Destination) -> Unit,
val onClickPlay: (Int, AudioItem) -> Unit,
val onClickPlayNext: (Int, AudioItem) -> Unit,
val onClickGoToAlbum: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
},
val onClickGoToArtist: (UUID) -> Unit = {
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
)
fun buildMoreDialogForMusicQueue(
context: Context,
actions: MusicQueueDialogActions,
item: AudioItem,
index: Int,
canRemove: Boolean,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlay(index, item)
},
)
add(
DialogItem(
context.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.onClickPlayNext(index, item)
},
)
if (canRemove) {
add(
DialogItem(
context.getString(R.string.remove_from_queue),
Icons.Default.Delete,
) {
actions.onClickRemoveFromQueue(index, item)
},
)
}
if (item.albumId != null) {
add(
DialogItem(
context.getString(R.string.go_to_album),
Icons.Default.ArrowForward,
) {
actions.onClickGoToAlbum.invoke(item.albumId)
},
)
}
if (item.artistId != null) {
add(
DialogItem(
context.getString(R.string.go_to_artist),
Icons.Default.ArrowForward,
) {
actions.onClickGoToArtist.invoke(item.artistId)
},
)
}
}
suspend fun ViewModel.getPagerForAlbum( suspend fun ViewModel.getPagerForAlbum(
api: ApiClient, api: ApiClient,
albumId: UUID, albumId: UUID,

View file

@ -737,6 +737,7 @@
<string name="hide_lyrics">Hide lyrics</string> <string name="hide_lyrics">Hide lyrics</string>
<string name="show_lyrics">Show lyrics</string> <string name="show_lyrics">Show lyrics</string>
<string name="song_has_lyrics">Song has lyrics</string> <string name="song_has_lyrics">Song has lyrics</string>
<string name="remove_from_queue">Remove from queue</string>
</resources> </resources>