mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Play all or shuffle libraries, shows, collections, etc (#278)
Adds ability to play all (or shuffle play) most libraries, collections, folders, TV series, TV seasons, etc This adds buttons to play all or shuffle play at the top of grid pages that support. Additionally, a shuffle button is added to TV Series details. And play all or shuffle play is added when long clicking TV seasons. Note: Until #42 is completed, only the first 100 items will play back-to-back Closes #197
This commit is contained in:
parent
9a3a33fdd3
commit
50dc4b9a35
18 changed files with 467 additions and 88 deletions
|
|
@ -8,8 +8,12 @@ import androidx.compose.animation.slideInVertically
|
|||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -18,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
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.FocusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -54,6 +59,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
|
|
@ -80,6 +86,7 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
|||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
@HiltViewModel
|
||||
class CollectionFolderViewModel
|
||||
|
|
@ -182,22 +189,11 @@ class CollectionFolderViewModel
|
|||
return when (filter.override) {
|
||||
GetItemsFilterOverride.NONE -> {
|
||||
val includeItemTypes =
|
||||
when (item?.data?.collectionType) {
|
||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
||||
CollectionType.MUSIC ->
|
||||
listOf(
|
||||
BaseItemKind.AUDIO,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
)
|
||||
|
||||
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
|
||||
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
|
||||
|
||||
else -> listOf()
|
||||
}
|
||||
item
|
||||
?.data
|
||||
?.collectionType
|
||||
?.baseItemKinds
|
||||
.orEmpty()
|
||||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
|
|
@ -317,6 +313,7 @@ fun CollectionFolderGrid(
|
|||
recursive: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
|
|
@ -329,6 +326,7 @@ fun CollectionFolderGrid(
|
|||
recursive,
|
||||
onClickItem,
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
showTitle = showTitle,
|
||||
|
|
@ -344,6 +342,7 @@ fun CollectionFolderGrid(
|
|||
recursive: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
|
|
@ -398,6 +397,21 @@ fun CollectionFolderGrid(
|
|||
positionCallback = positionCallback,
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
params = params,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +478,8 @@ fun CollectionFolderGridContent(
|
|||
onSortChange: (SortAndDirection) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
|
|
@ -499,13 +515,43 @@ fun CollectionFolderGridContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (sortOptions.isNotEmpty()) {
|
||||
SortByButton(
|
||||
sortOptions = sortOptions,
|
||||
current = sortAndDirection,
|
||||
onSortChange = onSortChange,
|
||||
modifier = Modifier,
|
||||
)
|
||||
val endPadding =
|
||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, end = endPadding)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (sortOptions.isNotEmpty()) {
|
||||
SortByButton(
|
||||
sortOptions = sortOptions,
|
||||
current = sortAndDirection,
|
||||
onSortChange = onSortChange,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (playEnabled) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
ExpandablePlayButton(
|
||||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = { onClickPlay.invoke(false) },
|
||||
)
|
||||
ExpandableFaButton(
|
||||
title = R.string.shuffle,
|
||||
iconStringRes = R.string.fa_shuffle,
|
||||
onClick = { onClickPlay.invoke(true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -601,3 +647,22 @@ data class CollectionFolderGridParameters(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||
get() =
|
||||
when (this) {
|
||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
||||
CollectionType.MUSIC ->
|
||||
listOf(
|
||||
BaseItemKind.AUDIO,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
)
|
||||
|
||||
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
|
||||
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
|
||||
|
||||
else -> listOf()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package com.github.damontecres.wholphin.ui.data
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.damontecres.wholphin.R
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
|
||||
@Serializable
|
||||
data class SortAndDirection(
|
||||
val sort: ItemSortBy,
|
||||
val direction: SortOrder,
|
||||
|
|
|
|||
|
|
@ -44,5 +44,6 @@ fun CollectionFolderBoxSet(
|
|||
showHeader = position < columns
|
||||
},
|
||||
params = CollectionFolderGridParameters.POSTER,
|
||||
playEnabled = true,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ fun CollectionFolderGeneric(
|
|||
itemId: UUID,
|
||||
usePosters: Boolean,
|
||||
recursive: Boolean,
|
||||
playEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: GetItemsFilter = GetItemsFilter(),
|
||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||
|
|
@ -51,5 +52,6 @@ fun CollectionFolderGeneric(
|
|||
showHeader = position < columns
|
||||
},
|
||||
params = params,
|
||||
playEnabled = playEnabled,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ fun CollectionFolderLiveTv(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
} else {
|
||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ fun CollectionFolderMovie(
|
|||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
// Recommended
|
||||
0 -> {
|
||||
RecommendedMovie(
|
||||
preferences = preferences,
|
||||
|
|
@ -102,6 +103,7 @@ fun CollectionFolderMovie(
|
|||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
// Library
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -124,8 +126,10 @@ fun CollectionFolderMovie(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = true,
|
||||
)
|
||||
}
|
||||
// Collections
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -148,9 +152,10 @@ fun CollectionFolderMovie(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
// Genres
|
||||
3 -> {
|
||||
GenreCardGrid(
|
||||
itemId = destination.itemId,
|
||||
|
|
|
|||
|
|
@ -44,5 +44,6 @@ fun CollectionFolderPlaylist(
|
|||
showHeader = position < columns
|
||||
},
|
||||
params = CollectionFolderGridParameters.SQUARE,
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@ fun CollectionFolderRecordings(
|
|||
showHeader = position < columns
|
||||
},
|
||||
params = CollectionFolderGridParameters.POSTER,
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ fun CollectionFolderTv(
|
|||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
// Recommended
|
||||
0 -> {
|
||||
RecommendedTvShow(
|
||||
preferences = preferences,
|
||||
|
|
@ -105,6 +106,7 @@ fun CollectionFolderTv(
|
|||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
// Library
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -127,8 +129,10 @@ fun CollectionFolderTv(
|
|||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
// Genres
|
||||
2 -> {
|
||||
GenreCardGrid(
|
||||
itemId = destination.itemId,
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ fun FavoritesPage(
|
|||
onClick = { selectedTabIndex = it },
|
||||
)
|
||||
}
|
||||
// TODO playEnabled = true for movies & episodes
|
||||
when (selectedTabIndex) {
|
||||
// Movies
|
||||
0 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -126,8 +128,10 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
// TV
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -149,8 +153,10 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
// Episodes
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -174,8 +180,10 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
// Videos
|
||||
3 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -198,8 +206,10 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
// Playlists
|
||||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -222,9 +232,10 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
// People
|
||||
5 -> {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
|
|
@ -251,6 +262,7 @@ fun FavoritesPage(
|
|||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
)
|
||||
}
|
||||
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
|
|
|
|||
|
|
@ -148,18 +148,17 @@ fun PlaylistDetails(
|
|||
focusRequester = focusRequester,
|
||||
onClickIndex = { index, _ ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
Destination.PlaybackList(
|
||||
itemId = it.id,
|
||||
positionMs = 0L,
|
||||
startIndex = index,
|
||||
shuffle = false,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
Destination.PlaybackList(
|
||||
itemId = it.id,
|
||||
positionMs = 0L,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
),
|
||||
|
|
@ -183,10 +182,10 @@ fun PlaylistDetails(
|
|||
Icons.Default.PlayArrow,
|
||||
) {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
Destination.PlaybackList(
|
||||
itemId = it.id,
|
||||
positionMs = it.resumeMs ?: 0L,
|
||||
startIndex = index,
|
||||
shuffle = false,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ fun SeriesDetails(
|
|||
markPlayed = { played ->
|
||||
viewModel.setSeasonWatched(season.id, played)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = season.id,
|
||||
shuffle = shuffle,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
overviewOnClick = {
|
||||
|
|
@ -177,7 +185,18 @@ fun SeriesDetails(
|
|||
files = listOf(),
|
||||
)
|
||||
},
|
||||
playOnClick = { viewModel.playNextUp() },
|
||||
playOnClick = { shuffle ->
|
||||
if (shuffle) {
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = item.id,
|
||||
shuffle = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
},
|
||||
watchOnClick = { showWatchConfirmation = true },
|
||||
favoriteOnClick = {
|
||||
val favorite = item.data.userData?.isFavorite ?: false
|
||||
|
|
@ -278,7 +297,7 @@ fun SeriesDetailsContent(
|
|||
onClickPerson: (Person) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
playOnClick: () -> Unit,
|
||||
playOnClick: (Boolean) -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
|
|
@ -344,16 +363,7 @@ fun SeriesDetailsContent(
|
|||
item {
|
||||
SeriesDetailsHeader(
|
||||
series = series,
|
||||
played = played,
|
||||
favorite = favorite,
|
||||
overviewOnClick = overviewOnClick,
|
||||
playOnClick = {
|
||||
position = HEADER_ROW
|
||||
playOnClick.invoke()
|
||||
},
|
||||
watchOnClick = watchOnClick,
|
||||
favoriteOnClick = favoriteOnClick,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.7f)
|
||||
|
|
@ -374,7 +384,23 @@ fun SeriesDetailsContent(
|
|||
icon = Icons.Default.PlayArrow,
|
||||
onClick = {
|
||||
position = HEADER_ROW
|
||||
playOnClick.invoke()
|
||||
playOnClick.invoke(false)
|
||||
},
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ExpandableFaButton(
|
||||
title = R.string.shuffle,
|
||||
iconStringRes = R.string.fa_shuffle,
|
||||
onClick = {
|
||||
position = HEADER_ROW
|
||||
playOnClick.invoke(true)
|
||||
},
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
|
|
@ -568,13 +594,7 @@ fun SeriesDetailsContent(
|
|||
@Composable
|
||||
fun SeriesDetailsHeader(
|
||||
series: BaseItem,
|
||||
played: Boolean,
|
||||
favorite: Boolean,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
overviewOnClick: () -> Unit,
|
||||
playOnClick: () -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -632,6 +652,7 @@ fun buildDialogForSeason(
|
|||
s: BaseItem,
|
||||
onClickItem: (BaseItem) -> Unit,
|
||||
markPlayed: (Boolean) -> Unit,
|
||||
onClickPlay: (Boolean) -> Unit,
|
||||
): DialogParams {
|
||||
val items =
|
||||
buildList {
|
||||
|
|
@ -653,6 +674,23 @@ fun buildDialogForSeason(
|
|||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
onClickPlay.invoke(false)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.shuffle),
|
||||
R.string.fa_shuffle,
|
||||
) {
|
||||
onClickPlay.invoke(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
return DialogParams(
|
||||
title = s.name ?: context.getString(R.string.tv_season),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
|
|
@ -79,14 +80,24 @@ sealed class Destination(
|
|||
val itemId: UUID,
|
||||
val positionMs: Long,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val startIndex: Int? = null,
|
||||
val shuffle: Boolean = false,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val forceTranscoding: Boolean = false,
|
||||
) : Destination(true) {
|
||||
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
||||
|
||||
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
|
||||
constructor(item: BaseItem) : this(item.id, item.resumeMs, item)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PlaybackList(
|
||||
val itemId: UUID,
|
||||
val filter: GetItemsFilter = GetItemsFilter(),
|
||||
val startIndex: Int? = null,
|
||||
val shuffle: Boolean = false,
|
||||
val recursive: Boolean = false,
|
||||
val sortAndDirection: SortAndDirection? = null,
|
||||
) : Destination(true) {
|
||||
override fun toString(): String = "PlaybackList(itemId=$itemId)"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -48,8 +48,9 @@ fun DestinationContent(
|
|||
preferences = preferences,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
is Destination.Playback ->
|
||||
is Destination.PlaybackList,
|
||||
is Destination.Playback,
|
||||
->
|
||||
PlaybackPage(
|
||||
preferences = preferences,
|
||||
deviceProfile = deviceProfile,
|
||||
|
|
@ -164,6 +165,7 @@ fun DestinationContent(
|
|||
filter = destination.filter,
|
||||
recursive = destination.recursive,
|
||||
usePosters = true,
|
||||
playEnabled = true, // TODO only genres use this currently, so might need to change in future
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
|
|
@ -261,6 +263,7 @@ fun CollectionFolder(
|
|||
destination.itemId,
|
||||
usePosters = usePostersOverride ?: false,
|
||||
recursive = recursiveOverride ?: false,
|
||||
playEnabled = true,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
|
|
@ -274,6 +277,7 @@ fun CollectionFolder(
|
|||
destination.itemId,
|
||||
usePosters = usePostersOverride ?: false,
|
||||
recursive = recursiveOverride ?: false,
|
||||
playEnabled = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback
|
|||
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
|
||||
|
||||
|
|
@ -27,3 +28,53 @@ val PrefContentScale.scale: ContentScale
|
|||
PrefContentScale.FILL_HEIGHT -> ContentScale.FillHeight
|
||||
PrefContentScale.UNRECOGNIZED -> ContentScale.Fit
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the type can be played as-is
|
||||
*
|
||||
* For example, a video file is playable as-is, but a playlist requires fetching the items first
|
||||
*/
|
||||
val BaseItemKind.playable: Boolean
|
||||
get() =
|
||||
when (this) {
|
||||
BaseItemKind.AUDIO_BOOK,
|
||||
BaseItemKind.AUDIO,
|
||||
BaseItemKind.CHANNEL,
|
||||
BaseItemKind.EPISODE,
|
||||
BaseItemKind.MOVIE,
|
||||
BaseItemKind.LIVE_TV_CHANNEL,
|
||||
BaseItemKind.LIVE_TV_PROGRAM,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
BaseItemKind.PROGRAM,
|
||||
BaseItemKind.RECORDING,
|
||||
BaseItemKind.TRAILER,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.TV_PROGRAM,
|
||||
BaseItemKind.VIDEO,
|
||||
-> true
|
||||
|
||||
BaseItemKind.AGGREGATE_FOLDER,
|
||||
BaseItemKind.BASE_PLUGIN_FOLDER,
|
||||
BaseItemKind.BOOK,
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.CHANNEL_FOLDER_ITEM,
|
||||
BaseItemKind.COLLECTION_FOLDER,
|
||||
BaseItemKind.FOLDER,
|
||||
BaseItemKind.GENRE,
|
||||
BaseItemKind.MANUAL_PLAYLISTS_FOLDER,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.MUSIC_GENRE,
|
||||
BaseItemKind.PERSON,
|
||||
BaseItemKind.PHOTO,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
BaseItemKind.PLAYLIST,
|
||||
BaseItemKind.PLAYLISTS_FOLDER,
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.SERIES,
|
||||
BaseItemKind.STUDIO,
|
||||
BaseItemKind.USER_ROOT_FOLDER,
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.YEAR,
|
||||
-> false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,16 +94,16 @@ import kotlin.time.Duration.Companion.seconds
|
|||
fun PlaybackPage(
|
||||
preferences: UserPreferences,
|
||||
deviceProfile: DeviceProfile,
|
||||
destination: Destination.Playback,
|
||||
destination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
||||
) {
|
||||
LifecycleStartEffect(destination.itemId) {
|
||||
LifecycleStartEffect(destination) {
|
||||
onStopOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(destination.itemId) {
|
||||
LaunchedEffect(destination) {
|
||||
viewModel.init(destination, deviceProfile, preferences)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -171,55 +172,93 @@ class PlaybackViewModel
|
|||
* Initialize from the UI to start playback
|
||||
*/
|
||||
fun init(
|
||||
destination: Destination.Playback,
|
||||
destination: Destination,
|
||||
deviceProfile: DeviceProfile,
|
||||
preferences: UserPreferences,
|
||||
) {
|
||||
nextUp.value = null
|
||||
this.preferences = preferences
|
||||
this.deviceProfile = deviceProfile
|
||||
this.forceTranscoding = destination.forceTranscoding
|
||||
val itemId = destination.itemId
|
||||
this.forceTranscoding =
|
||||
(destination as? Destination.Playback)?.forceTranscoding ?: false
|
||||
val positionMs: Long
|
||||
val itemPlayback: ItemPlayback?
|
||||
val forceTranscoding: Boolean
|
||||
|
||||
val itemId =
|
||||
when (val d = destination) {
|
||||
is Destination.Playback -> {
|
||||
positionMs = d.positionMs
|
||||
itemPlayback = d.itemPlayback
|
||||
forceTranscoding = d.forceTranscoding
|
||||
d.itemId
|
||||
}
|
||||
|
||||
is Destination.PlaybackList -> {
|
||||
positionMs = 0
|
||||
itemPlayback = null
|
||||
forceTranscoding = false
|
||||
d.itemId
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Destination not supported: $destination")
|
||||
}
|
||||
this.itemId = itemId
|
||||
val item = destination.item
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error preparing for playback for ${destination.itemId}",
|
||||
"Error preparing for playback for $itemId",
|
||||
),
|
||||
) {
|
||||
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||
val queriedItem =
|
||||
(destination as? Destination.Playback)?.item?.data
|
||||
?: api.userLibraryApi.getItem(itemId).content
|
||||
val base =
|
||||
if (queriedItem.type == BaseItemKind.PLAYLIST) {
|
||||
isPlaylist = true
|
||||
val playlist =
|
||||
playlistCreator.createFromPlaylistId(
|
||||
queriedItem.id,
|
||||
destination.startIndex,
|
||||
destination.shuffle,
|
||||
)
|
||||
if (playlist.items.isEmpty()) {
|
||||
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||
navigationManager.goBack()
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = playlist
|
||||
}
|
||||
// TODO start index
|
||||
playlist.items.first().data
|
||||
} else {
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
} else if (destination is Destination.PlaybackList) {
|
||||
isPlaylist = true
|
||||
val playlistResult =
|
||||
playlistCreator.createFrom(
|
||||
item = queriedItem,
|
||||
startIndex = destination.startIndex ?: 0,
|
||||
sortAndDirection = destination.sortAndDirection,
|
||||
shuffled = destination.shuffle,
|
||||
recursive = destination.recursive,
|
||||
filter = destination.filter,
|
||||
)
|
||||
when (val r = playlistResult) {
|
||||
is PlaylistCreationResult.Error -> {
|
||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
||||
return@launch
|
||||
}
|
||||
|
||||
is PlaylistCreationResult.Success -> {
|
||||
if (r.playlist.items.isEmpty()) {
|
||||
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||
navigationManager.goBack()
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = r.playlist
|
||||
}
|
||||
r.playlist.items
|
||||
.first()
|
||||
.data
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Item is not playable and not PlaybackList")
|
||||
}
|
||||
val item = BaseItem.from(base, api)
|
||||
|
||||
val played =
|
||||
play(
|
||||
item,
|
||||
destination.positionMs,
|
||||
destination.itemPlayback,
|
||||
destination.forceTranscoding,
|
||||
positionMs,
|
||||
itemPlayback,
|
||||
forceTranscoding,
|
||||
)
|
||||
if (!played) {
|
||||
playNextUp()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue