mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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
|
|
@ -4,9 +4,12 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
||||||
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
|
@ -18,10 +21,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CreatePlaylistDto
|
import org.jellyfin.sdk.model.api.CreatePlaylistDto
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
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.PlaylistUserPermissions
|
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
||||||
|
|
@ -43,19 +50,22 @@ class PlaylistCreator
|
||||||
*/
|
*/
|
||||||
suspend fun createFromEpisode(
|
suspend fun createFromEpisode(
|
||||||
seriesId: UUID,
|
seriesId: UUID,
|
||||||
episodeId: UUID,
|
episodeId: UUID?,
|
||||||
|
seasonId: UUID? = null,
|
||||||
|
shuffled: Boolean = false,
|
||||||
): Playlist {
|
): Playlist {
|
||||||
val request =
|
val request =
|
||||||
GetEpisodesRequest(
|
GetEpisodesRequest(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
seasonId = seasonId,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
startItemId = episodeId,
|
startItemId = episodeId,
|
||||||
|
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
|
||||||
limit = Playlist.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
)
|
)
|
||||||
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
||||||
val startIndex =
|
val startIndex =
|
||||||
episodes.indexOfFirstOrNull { it.id == episodeId }
|
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
|
||||||
?: throw IllegalStateException("Episode $episodeId was not returned")
|
|
||||||
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
|
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +89,128 @@ class PlaylistCreator
|
||||||
return Playlist(baseItems, 0)
|
return Playlist(baseItems, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun createFromCollection(
|
||||||
|
item: BaseItemDto,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
sortAndDirection: SortAndDirection?,
|
||||||
|
recursive: Boolean,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
): Playlist {
|
||||||
|
val includeItemTypes =
|
||||||
|
item.collectionType?.baseItemKinds?.takeIf { it.isNotEmpty() }
|
||||||
|
?: listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO)
|
||||||
|
val request =
|
||||||
|
filter.applyTo(
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = item.id,
|
||||||
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
recursive = true,
|
||||||
|
excludeItemIds = listOf(item.id),
|
||||||
|
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
|
||||||
|
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = Playlist.MAX_SIZE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val items =
|
||||||
|
GetItemsRequestHandler.execute(api, request).content.items.map {
|
||||||
|
BaseItem.from(it, api)
|
||||||
|
}
|
||||||
|
return Playlist(items, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createFrom(
|
||||||
|
item: BaseItemDto,
|
||||||
|
startIndex: Int = 0,
|
||||||
|
sortAndDirection: SortAndDirection?,
|
||||||
|
shuffled: Boolean,
|
||||||
|
recursive: Boolean,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
): PlaylistCreationResult =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.BOX_SET,
|
||||||
|
BaseItemKind.COLLECTION_FOLDER,
|
||||||
|
BaseItemKind.USER_VIEW,
|
||||||
|
->
|
||||||
|
PlaylistCreationResult.Success(
|
||||||
|
createFromCollection(
|
||||||
|
item = item,
|
||||||
|
startIndex = startIndex,
|
||||||
|
sortAndDirection =
|
||||||
|
if (shuffled) {
|
||||||
|
SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING)
|
||||||
|
} else {
|
||||||
|
sortAndDirection
|
||||||
|
},
|
||||||
|
recursive = recursive,
|
||||||
|
filter = filter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.EPISODE -> {
|
||||||
|
val seriesId = item.seriesId
|
||||||
|
if (seriesId != null) {
|
||||||
|
PlaylistCreationResult.Success(
|
||||||
|
createFromEpisode(
|
||||||
|
seriesId = seriesId,
|
||||||
|
seasonId = null,
|
||||||
|
episodeId = item.id,
|
||||||
|
shuffled = shuffled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlaylistCreationResult.Error(null, "Episode has not seriesId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.SEASON -> {
|
||||||
|
val seriesId = item.seriesId
|
||||||
|
if (seriesId != null) {
|
||||||
|
PlaylistCreationResult.Success(
|
||||||
|
createFromEpisode(
|
||||||
|
seriesId = seriesId,
|
||||||
|
seasonId = item.id,
|
||||||
|
episodeId = null,
|
||||||
|
shuffled = shuffled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlaylistCreationResult.Error(null, "Episode has not seriesId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.SERIES ->
|
||||||
|
PlaylistCreationResult.Success(
|
||||||
|
createFromEpisode(
|
||||||
|
seriesId = item.id,
|
||||||
|
seasonId = null,
|
||||||
|
episodeId = null,
|
||||||
|
shuffled = shuffled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.PLAYLIST ->
|
||||||
|
PlaylistCreationResult.Success(
|
||||||
|
createFromPlaylistId(
|
||||||
|
item.id,
|
||||||
|
startIndex,
|
||||||
|
shuffled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not support yet
|
||||||
|
// BaseItemKind.AGGREGATE_FOLDER -> TODO()
|
||||||
|
// BaseItemKind.FOLDER -> TODO()
|
||||||
|
// BaseItemKind.GENRE -> TODO()
|
||||||
|
// BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
|
||||||
|
// BaseItemKind.MUSIC_ALBUM -> TODO()
|
||||||
|
// BaseItemKind.MUSIC_ARTIST -> TODO()
|
||||||
|
|
||||||
|
else -> PlaylistCreationResult.Error(null, "Unsupported type: ${item.type}")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getPlaylists(
|
suspend fun getPlaylists(
|
||||||
mediaType: MediaType?,
|
mediaType: MediaType?,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
|
@ -136,3 +268,14 @@ class PlaylistCreator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface PlaylistCreationResult {
|
||||||
|
data class Success(
|
||||||
|
val playlist: Playlist,
|
||||||
|
) : PlaylistCreationResult
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val ex: Exception?,
|
||||||
|
val message: String?,
|
||||||
|
) : PlaylistCreationResult
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
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.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -18,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
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.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
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 org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CollectionFolderViewModel
|
class CollectionFolderViewModel
|
||||||
|
|
@ -182,22 +189,11 @@ class CollectionFolderViewModel
|
||||||
return when (filter.override) {
|
return when (filter.override) {
|
||||||
GetItemsFilterOverride.NONE -> {
|
GetItemsFilterOverride.NONE -> {
|
||||||
val includeItemTypes =
|
val includeItemTypes =
|
||||||
when (item?.data?.collectionType) {
|
item
|
||||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
?.data
|
||||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
?.collectionType
|
||||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
?.baseItemKinds
|
||||||
CollectionType.MUSIC ->
|
.orEmpty()
|
||||||
listOf(
|
|
||||||
BaseItemKind.AUDIO,
|
|
||||||
BaseItemKind.MUSIC_ARTIST,
|
|
||||||
BaseItemKind.MUSIC_ALBUM,
|
|
||||||
)
|
|
||||||
|
|
||||||
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
|
|
||||||
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
|
|
||||||
|
|
||||||
else -> listOf()
|
|
||||||
}
|
|
||||||
val request =
|
val request =
|
||||||
filter.applyTo(
|
filter.applyTo(
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -317,6 +313,7 @@ fun CollectionFolderGrid(
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
sortOptions: List<ItemSortBy>,
|
sortOptions: List<ItemSortBy>,
|
||||||
|
playEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
|
|
@ -329,6 +326,7 @@ fun CollectionFolderGrid(
|
||||||
recursive,
|
recursive,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
|
playEnabled,
|
||||||
modifier,
|
modifier,
|
||||||
initialSortAndDirection = initialSortAndDirection,
|
initialSortAndDirection = initialSortAndDirection,
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
|
|
@ -344,6 +342,7 @@ fun CollectionFolderGrid(
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
sortOptions: List<ItemSortBy>,
|
sortOptions: List<ItemSortBy>,
|
||||||
|
playEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
|
|
@ -398,6 +397,21 @@ fun CollectionFolderGrid(
|
||||||
positionCallback = positionCallback,
|
positionCallback = positionCallback,
|
||||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||||
params = params,
|
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,
|
onSortChange: (SortAndDirection) -> Unit,
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
sortOptions: List<ItemSortBy>,
|
sortOptions: List<ItemSortBy>,
|
||||||
|
playEnabled: Boolean,
|
||||||
|
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
|
|
@ -499,6 +515,16 @@ fun CollectionFolderGridContent(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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()) {
|
if (sortOptions.isNotEmpty()) {
|
||||||
SortByButton(
|
SortByButton(
|
||||||
sortOptions = sortOptions,
|
sortOptions = sortOptions,
|
||||||
|
|
@ -507,6 +533,26 @@ fun CollectionFolderGridContent(
|
||||||
modifier = Modifier,
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CardGrid(
|
CardGrid(
|
||||||
|
|
@ -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 androidx.annotation.StringRes
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class SortAndDirection(
|
data class SortAndDirection(
|
||||||
val sort: ItemSortBy,
|
val sort: ItemSortBy,
|
||||||
val direction: SortOrder,
|
val direction: SortOrder,
|
||||||
|
|
|
||||||
|
|
@ -44,5 +44,6 @@ fun CollectionFolderBoxSet(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
params = CollectionFolderGridParameters.POSTER,
|
params = CollectionFolderGridParameters.POSTER,
|
||||||
|
playEnabled = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ fun CollectionFolderGeneric(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
usePosters: Boolean,
|
usePosters: Boolean,
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
|
playEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
filter: GetItemsFilter = GetItemsFilter(),
|
filter: GetItemsFilter = GetItemsFilter(),
|
||||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
|
|
@ -51,5 +52,6 @@ fun CollectionFolderGeneric(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
params = params,
|
params = params,
|
||||||
|
playEnabled = playEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ fun CollectionFolderLiveTv(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ fun CollectionFolderMovie(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
// Recommended
|
||||||
0 -> {
|
0 -> {
|
||||||
RecommendedMovie(
|
RecommendedMovie(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -102,6 +103,7 @@ fun CollectionFolderMovie(
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Library
|
||||||
1 -> {
|
1 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -124,8 +126,10 @@ fun CollectionFolderMovie(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Collections
|
||||||
2 -> {
|
2 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -148,9 +152,10 @@ fun CollectionFolderMovie(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Genres
|
||||||
3 -> {
|
3 -> {
|
||||||
GenreCardGrid(
|
GenreCardGrid(
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
|
|
||||||
|
|
@ -44,5 +44,6 @@ fun CollectionFolderPlaylist(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
params = CollectionFolderGridParameters.SQUARE,
|
params = CollectionFolderGridParameters.SQUARE,
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,5 +42,6 @@ fun CollectionFolderRecordings(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
params = CollectionFolderGridParameters.POSTER,
|
params = CollectionFolderGridParameters.POSTER,
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ fun CollectionFolderTv(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
// Recommended
|
||||||
0 -> {
|
0 -> {
|
||||||
RecommendedTvShow(
|
RecommendedTvShow(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -105,6 +106,7 @@ fun CollectionFolderTv(
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Library
|
||||||
1 -> {
|
1 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -127,8 +129,10 @@ fun CollectionFolderTv(
|
||||||
onClickItem = { _, item ->
|
onClickItem = { _, item ->
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Genres
|
||||||
2 -> {
|
2 -> {
|
||||||
GenreCardGrid(
|
GenreCardGrid(
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ fun FavoritesPage(
|
||||||
onClick = { selectedTabIndex = it },
|
onClick = { selectedTabIndex = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// TODO playEnabled = true for movies & episodes
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
// Movies
|
||||||
0 -> {
|
0 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -126,8 +128,10 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// TV
|
||||||
1 -> {
|
1 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -149,8 +153,10 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Episodes
|
||||||
2 -> {
|
2 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -174,8 +180,10 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Videos
|
||||||
3 -> {
|
3 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -198,8 +206,10 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Playlists
|
||||||
4 -> {
|
4 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -222,9 +232,10 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// People
|
||||||
5 -> {
|
5 -> {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -251,6 +262,7 @@ fun FavoritesPage(
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
|
playEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||||
|
|
|
||||||
|
|
@ -148,18 +148,17 @@ fun PlaylistDetails(
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
onClickIndex = { index, _ ->
|
onClickIndex = { index, _ ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.PlaybackList(
|
||||||
itemId = it.id,
|
itemId = it.id,
|
||||||
positionMs = 0L,
|
|
||||||
startIndex = index,
|
startIndex = index,
|
||||||
|
shuffle = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClickPlay = { shuffle ->
|
onClickPlay = { shuffle ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.PlaybackList(
|
||||||
itemId = it.id,
|
itemId = it.id,
|
||||||
positionMs = 0L,
|
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
shuffle = shuffle,
|
shuffle = shuffle,
|
||||||
),
|
),
|
||||||
|
|
@ -183,10 +182,10 @@ fun PlaylistDetails(
|
||||||
Icons.Default.PlayArrow,
|
Icons.Default.PlayArrow,
|
||||||
) {
|
) {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.PlaybackList(
|
||||||
itemId = it.id,
|
itemId = it.id,
|
||||||
positionMs = it.resumeMs ?: 0L,
|
|
||||||
startIndex = index,
|
startIndex = index,
|
||||||
|
shuffle = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,14 @@ fun SeriesDetails(
|
||||||
markPlayed = { played ->
|
markPlayed = { played ->
|
||||||
viewModel.setSeasonWatched(season.id, played)
|
viewModel.setSeasonWatched(season.id, played)
|
||||||
},
|
},
|
||||||
|
onClickPlay = { shuffle ->
|
||||||
|
viewModel.navigateTo(
|
||||||
|
Destination.PlaybackList(
|
||||||
|
itemId = season.id,
|
||||||
|
shuffle = shuffle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
|
|
@ -177,7 +185,18 @@ fun SeriesDetails(
|
||||||
files = listOf(),
|
files = listOf(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
playOnClick = { viewModel.playNextUp() },
|
playOnClick = { shuffle ->
|
||||||
|
if (shuffle) {
|
||||||
|
viewModel.navigateTo(
|
||||||
|
Destination.PlaybackList(
|
||||||
|
itemId = item.id,
|
||||||
|
shuffle = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
viewModel.playNextUp()
|
||||||
|
}
|
||||||
|
},
|
||||||
watchOnClick = { showWatchConfirmation = true },
|
watchOnClick = { showWatchConfirmation = true },
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
val favorite = item.data.userData?.isFavorite ?: false
|
val favorite = item.data.userData?.isFavorite ?: false
|
||||||
|
|
@ -278,7 +297,7 @@ fun SeriesDetailsContent(
|
||||||
onClickPerson: (Person) -> Unit,
|
onClickPerson: (Person) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
playOnClick: () -> Unit,
|
playOnClick: (Boolean) -> Unit,
|
||||||
watchOnClick: () -> Unit,
|
watchOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
|
|
@ -344,16 +363,7 @@ fun SeriesDetailsContent(
|
||||||
item {
|
item {
|
||||||
SeriesDetailsHeader(
|
SeriesDetailsHeader(
|
||||||
series = series,
|
series = series,
|
||||||
played = played,
|
|
||||||
favorite = favorite,
|
|
||||||
overviewOnClick = overviewOnClick,
|
overviewOnClick = overviewOnClick,
|
||||||
playOnClick = {
|
|
||||||
position = HEADER_ROW
|
|
||||||
playOnClick.invoke()
|
|
||||||
},
|
|
||||||
watchOnClick = watchOnClick,
|
|
||||||
favoriteOnClick = favoriteOnClick,
|
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(.7f)
|
.fillMaxWidth(.7f)
|
||||||
|
|
@ -374,7 +384,23 @@ fun SeriesDetailsContent(
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
onClick = {
|
onClick = {
|
||||||
position = HEADER_ROW
|
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 =
|
||||||
Modifier.onFocusChanged {
|
Modifier.onFocusChanged {
|
||||||
|
|
@ -568,13 +594,7 @@ fun SeriesDetailsContent(
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesDetailsHeader(
|
fun SeriesDetailsHeader(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
played: Boolean,
|
|
||||||
favorite: Boolean,
|
|
||||||
bringIntoViewRequester: BringIntoViewRequester,
|
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
playOnClick: () -> Unit,
|
|
||||||
watchOnClick: () -> Unit,
|
|
||||||
favoriteOnClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -632,6 +652,7 @@ fun buildDialogForSeason(
|
||||||
s: BaseItem,
|
s: BaseItem,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
markPlayed: (Boolean) -> Unit,
|
markPlayed: (Boolean) -> Unit,
|
||||||
|
onClickPlay: (Boolean) -> Unit,
|
||||||
): DialogParams {
|
): DialogParams {
|
||||||
val items =
|
val items =
|
||||||
buildList {
|
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(
|
return DialogParams(
|
||||||
title = s.name ?: context.getString(R.string.tv_season),
|
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.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
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.SeasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
|
|
@ -79,14 +80,24 @@ sealed class Destination(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
val startIndex: Int? = null,
|
|
||||||
val shuffle: Boolean = false,
|
|
||||||
val itemPlayback: ItemPlayback? = null,
|
val itemPlayback: ItemPlayback? = null,
|
||||||
val forceTranscoding: Boolean = false,
|
val forceTranscoding: Boolean = false,
|
||||||
) : Destination(true) {
|
) : Destination(true) {
|
||||||
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
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
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,9 @@ fun DestinationContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
is Destination.PlaybackList,
|
||||||
is Destination.Playback ->
|
is Destination.Playback,
|
||||||
|
->
|
||||||
PlaybackPage(
|
PlaybackPage(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
deviceProfile = deviceProfile,
|
deviceProfile = deviceProfile,
|
||||||
|
|
@ -164,6 +165,7 @@ fun DestinationContent(
|
||||||
filter = destination.filter,
|
filter = destination.filter,
|
||||||
recursive = destination.recursive,
|
recursive = destination.recursive,
|
||||||
usePosters = true,
|
usePosters = true,
|
||||||
|
playEnabled = true, // TODO only genres use this currently, so might need to change in future
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -261,6 +263,7 @@ fun CollectionFolder(
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
usePosters = usePostersOverride ?: false,
|
usePosters = usePostersOverride ?: false,
|
||||||
recursive = recursiveOverride ?: false,
|
recursive = recursiveOverride ?: false,
|
||||||
|
playEnabled = true,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -274,6 +277,7 @@ fun CollectionFolder(
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
usePosters = usePostersOverride ?: false,
|
usePosters = usePostersOverride ?: false,
|
||||||
recursive = recursiveOverride ?: false,
|
recursive = recursiveOverride ?: false,
|
||||||
|
playEnabled = false,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
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")
|
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.FILL_HEIGHT -> ContentScale.FillHeight
|
||||||
PrefContentScale.UNRECOGNIZED -> ContentScale.Fit
|
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(
|
fun PlaybackPage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
destination: Destination.Playback,
|
destination: Destination,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaybackViewModel = hiltViewModel(),
|
viewModel: PlaybackViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LifecycleStartEffect(destination.itemId) {
|
LifecycleStartEffect(destination) {
|
||||||
onStopOrDispose {
|
onStopOrDispose {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(destination.itemId) {
|
LaunchedEffect(destination) {
|
||||||
viewModel.init(destination, deviceProfile, preferences)
|
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.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
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.services.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -171,55 +172,93 @@ class PlaybackViewModel
|
||||||
* Initialize from the UI to start playback
|
* Initialize from the UI to start playback
|
||||||
*/
|
*/
|
||||||
fun init(
|
fun init(
|
||||||
destination: Destination.Playback,
|
destination: Destination,
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
) {
|
) {
|
||||||
nextUp.value = null
|
nextUp.value = null
|
||||||
this.preferences = preferences
|
this.preferences = preferences
|
||||||
this.deviceProfile = deviceProfile
|
this.deviceProfile = deviceProfile
|
||||||
this.forceTranscoding = destination.forceTranscoding
|
this.forceTranscoding =
|
||||||
val itemId = destination.itemId
|
(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
|
this.itemId = itemId
|
||||||
val item = destination.item
|
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
Dispatchers.IO +
|
Dispatchers.IO +
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
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 =
|
val base =
|
||||||
if (queriedItem.type == BaseItemKind.PLAYLIST) {
|
if (queriedItem.type.playable) {
|
||||||
|
queriedItem
|
||||||
|
} else if (destination is Destination.PlaybackList) {
|
||||||
isPlaylist = true
|
isPlaylist = true
|
||||||
val playlist =
|
val playlistResult =
|
||||||
playlistCreator.createFromPlaylistId(
|
playlistCreator.createFrom(
|
||||||
queriedItem.id,
|
item = queriedItem,
|
||||||
destination.startIndex,
|
startIndex = destination.startIndex ?: 0,
|
||||||
destination.shuffle,
|
sortAndDirection = destination.sortAndDirection,
|
||||||
|
shuffled = destination.shuffle,
|
||||||
|
recursive = destination.recursive,
|
||||||
|
filter = destination.filter,
|
||||||
)
|
)
|
||||||
if (playlist.items.isEmpty()) {
|
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)
|
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.playlist.value = playlist
|
this@PlaybackViewModel.playlist.value = r.playlist
|
||||||
|
}
|
||||||
|
r.playlist.items
|
||||||
|
.first()
|
||||||
|
.data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO start index
|
|
||||||
playlist.items.first().data
|
|
||||||
} else {
|
} else {
|
||||||
queriedItem
|
throw IllegalArgumentException("Item is not playable and not PlaybackList")
|
||||||
}
|
}
|
||||||
val item = BaseItem.from(base, api)
|
val item = BaseItem.from(base, api)
|
||||||
|
|
||||||
val played =
|
val played =
|
||||||
play(
|
play(
|
||||||
item,
|
item,
|
||||||
destination.positionMs,
|
positionMs,
|
||||||
destination.itemPlayback,
|
itemPlayback,
|
||||||
destination.forceTranscoding,
|
forceTranscoding,
|
||||||
)
|
)
|
||||||
if (!played) {
|
if (!played) {
|
||||||
playNextUp()
|
playNextUp()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue