Play or shuffle series, season, collections, & playlists from context menu (#1544)

## Description
Adds options to the context menu to play or shuffle items in a series,
season, collection, or playlist. So you can long click a TV show and
play it directly.

Playing a TV show will play the next up episode (same as the play button
on the series detail page).

Playing a season, collection, or playlist will start from the beginning.

Also, removes the green play arrows and uses the same neutral color as
other icons.

### Related issues
Closes #1451
Closes #1507

### Testing
Emulator from library pages & series detail pages

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-10 19:30:07 -04:00 • committed by GitHub
parent f7f0ea9cca
commit 38b4a8c4de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 107 additions and 233 deletions

View file

@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes
import com.github.damontecres.wholphin.util.supportedShufflableTypes
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@ -355,7 +356,6 @@ private fun buildContextMenuItems(
DialogItem(
resources.getString(R.string.resume),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
@ -385,7 +385,6 @@ private fun buildContextMenuItems(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.navigateTo(
@ -398,6 +397,22 @@ private fun buildContextMenuItems(
)
}
}
if (item.type in supportedShufflableTypes) {
add(
DialogItem(
resources.getString(R.string.shuffle),
R.string.fa_shuffle,
dismissOnClick = true,
) {
actions.navigateTo(
Destination.PlaybackList(
itemId = item.id,
shuffle = true,
),
)
},
)
}
if (showStreamChoices) {
item.data.mediaSources?.letNotEmpty { sources ->
val source =
@ -734,7 +749,6 @@ fun buildContextForMusic(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
@ -744,7 +758,6 @@ fun buildContextForMusic(
DialogItem(
resources.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)
@ -867,7 +880,6 @@ fun buildContextForMusicQueue(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlay(index, item)
@ -877,7 +889,6 @@ fun buildContextForMusicQueue(
DialogItem(
resources.getString(R.string.play_next),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
actions.onClickPlayNext(index, item)

View file

@ -1,193 +0,0 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.res.Resources
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
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.Refresh
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.supportedPlayableTypes
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class MoreDialogActions(
val navigateTo: (Destination) -> Unit,
val onClickWatch: (UUID, Boolean) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
val onClickAddToQueue: (BaseItem) -> Unit = {},
)
fun buildMoreDialogItemsForHome(
resources: Resources,
item: BaseItem,
seriesId: UUID?,
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
canRemoveContinueWatching: Boolean = false,
canRemoveNextUp: Boolean = false,
): List<DialogItem> =
buildList {
val itemId = item.id
add(
DialogItem(
resources.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.onClickGoTo(item)
},
)
if (item.type in supportedPlayableTypes) {
if (playbackPosition >= 1.seconds) {
add(
DialogItem(
resources.getString(R.string.resume),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.navigateTo(
Destination.Playback(
itemId,
playbackPosition.inWholeMilliseconds,
),
)
},
)
add(
DialogItem(
resources.getString(R.string.restart),
Icons.Default.Refresh,
// iconColor = Color.Green.copy(alpha = .8f),
) {
actions.navigateTo(
Destination.Playback(
itemId,
0L,
),
)
},
)
} else {
add(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
actions.navigateTo(
Destination.Playback(
itemId,
0L,
),
)
},
)
}
}
if (item.type == BaseItemKind.MUSIC_ALBUM) {
add(
DialogItem(
resources.getString(R.string.add_to_queue),
Icons.Default.Add,
) {
actions.onClickAddToQueue(item)
},
)
}
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(itemId)
},
)
if (canDelete) {
add(
DialogItem(
resources.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) {
add(
DialogItem(
text = R.string.remove_continue_watching,
iconStringRes = R.string.fa_eye,
) {
actions.onClickWatch.invoke(itemId, false)
},
)
}
if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
add(
DialogItem(
text = R.string.remove_next_up,
iconStringRes = R.string.fa_tag,
) {
actions.onClickRemoveFromNextUp.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
) {
actions.onClickWatch.invoke(itemId, !watched)
},
)
add(
DialogItem(
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(itemId, !favorite)
},
)
seriesId?.let {
add(
DialogItem(
resources.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward,
) {
actions.navigateTo(
Destination.MediaItem(
it,
BaseItemKind.SERIES,
null,
),
)
},
)
}
add(
DialogItem(
text = R.string.send_media_info_log_to_server,
iconStringRes = R.string.fa_file_video,
) {
actions.onSendMediaInfo.invoke(itemId)
},
)
}

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@ -113,7 +112,6 @@ fun PlaylistList(
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(.7f),
contentDescription = "Add",
)
},

View file

@ -24,7 +24,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
@ -518,7 +517,6 @@ fun buildDialogForSeason(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
onClickPlay.invoke(false)
},

View file

@ -159,7 +159,6 @@ fun ProgramDialog(
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.delete),
tint = Color.Green.copy(alpha = .75f),
)
Text(
text = stringResource(R.string.watch_live),

View file

@ -259,7 +259,6 @@ fun TvGuideGrid(
DialogItem(
watchLiveStr,
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
dismissOnClick = true,
) {
viewModel.navigationManager.navigateTo(

View file

@ -742,7 +742,6 @@ fun buildDialogForSeason(
DialogItem(
resources.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
onClickPlay.invoke(false)
},

View file

@ -315,16 +315,24 @@ class PlaybackViewModel
val playlistItem =
if (queriedItem.type.playable) {
PlaylistItem.Media(BaseItem(queriedItem, false))
} else if (destination is Destination.PlaybackList) {
} else {
val playlistResult =
playlistCreator.createFrom(
item = queriedItem,
startIndex = destination.startIndex ?: 0,
sortAndDirection = destination.sortAndDirection,
shuffled = destination.shuffle,
recursive = destination.recursive,
filter = destination.filter,
)
if (destination is Destination.PlaybackList) {
playlistCreator.createFrom(
item = queriedItem,
startIndex = destination.startIndex ?: 0,
sortAndDirection = destination.sortAndDirection,
shuffled = destination.shuffle,
recursive = destination.recursive,
filter = destination.filter,
)
} else {
// Try to create a playlist
playlistCreator.createFrom(
item = queriedItem,
recursive = true,
)
}
when (val r = playlistResult) {
is PlaylistCreationResult.Error -> {
_state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) }
@ -343,8 +351,6 @@ class PlaybackViewModel
r.playlist.items.first()
}
}
} else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }