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:
parent
f7f0ea9cca
commit
38b4a8c4de
10 changed files with 107 additions and 233 deletions
|
|
@ -23,6 +23,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -202,28 +203,72 @@ class PlaylistCreator
|
|||
BaseItemKind.SEASON -> {
|
||||
val seriesId = item.seriesId
|
||||
if (seriesId != null) {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = item.id,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
if (shuffled) {
|
||||
api.tvShowsApi
|
||||
.getEpisodes(
|
||||
seriesId = seriesId,
|
||||
seasonId = item.id,
|
||||
limit = Playlist.MAX_SIZE,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
fields = DefaultItemFields,
|
||||
).content.items
|
||||
.convertAndAddParts()
|
||||
.let {
|
||||
PlaylistCreationResult.Success(Playlist(it))
|
||||
}
|
||||
} else {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = item.id,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PlaylistCreationResult.Error(null, "Episode has no seriesId")
|
||||
}
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES -> {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = item.id,
|
||||
seasonId = null,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
if (shuffled) {
|
||||
api.tvShowsApi
|
||||
.getEpisodes(
|
||||
seriesId = item.id,
|
||||
limit = Playlist.MAX_SIZE,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
fields = DefaultItemFields,
|
||||
).content.items
|
||||
.convertAndAddParts()
|
||||
.let {
|
||||
PlaylistCreationResult.Success(Playlist(it))
|
||||
}
|
||||
} else {
|
||||
val result by api.tvShowsApi.getNextUp(seriesId = item.id)
|
||||
val nextUp =
|
||||
result.items.firstOrNull() ?: api.tvShowsApi
|
||||
.getEpisodes(
|
||||
item.id,
|
||||
limit = 1,
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (nextUp != null) {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = item.id,
|
||||
seasonId = null,
|
||||
episodeId = nextUp.id,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
PlaylistCreationResult.Error(
|
||||
null,
|
||||
"Could not determine next up episode for series: " + item.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BaseItemKind.PLAYLIST -> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -259,7 +259,6 @@ fun TvGuideGrid(
|
|||
DialogItem(
|
||||
watchLiveStr,
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
dismissOnClick = true,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
|
|
|
|||
|
|
@ -742,7 +742,6 @@ fun buildDialogForSeason(
|
|||
DialogItem(
|
||||
resources.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
onClickPlay.invoke(false)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -53,4 +53,16 @@ val supportedPlayableTypes =
|
|||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.TV_PROGRAM,
|
||||
BaseItemKind.RECORDING,
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.SERIES,
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.PLAYLIST,
|
||||
)
|
||||
|
||||
val supportedShufflableTypes =
|
||||
setOf(
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.SERIES,
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.PLAYLIST,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue