mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Support basic adding to playlists (#157)
Closes #143 Adds option in the "More" menu to add the item to a playlist. Can also create a playlist if needed.
This commit is contained in:
parent
86977f846c
commit
40a8249469
9 changed files with 540 additions and 0 deletions
|
|
@ -1,15 +1,31 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
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.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.TransformList
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
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.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CreatePlaylistDto
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
|
||||||
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.GetPlaylistItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -49,11 +65,20 @@ class Playlist(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PlaylistInfo(
|
||||||
|
val id: UUID,
|
||||||
|
val name: String,
|
||||||
|
val count: Int,
|
||||||
|
val mediaType: MediaType,
|
||||||
|
)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class PlaylistCreator
|
class PlaylistCreator
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun createFromEpisode(
|
suspend fun createFromEpisode(
|
||||||
seriesId: UUID,
|
seriesId: UUID,
|
||||||
|
|
@ -92,4 +117,61 @@ class PlaylistCreator
|
||||||
}
|
}
|
||||||
return Playlist(baseItems, 0)
|
return Playlist(baseItems, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getPlaylists(
|
||||||
|
mediaType: MediaType?,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
): List<PlaylistInfo?> {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
||||||
|
mediaTypes = mediaType?.let { listOf(mediaType) },
|
||||||
|
recursive = true,
|
||||||
|
)
|
||||||
|
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||||
|
return TransformList(pager) {
|
||||||
|
it?.let {
|
||||||
|
PlaylistInfo(
|
||||||
|
id = it.id,
|
||||||
|
name = it.name ?: context.getString(R.string.unknown),
|
||||||
|
count = it.data.childCount ?: 0,
|
||||||
|
mediaType = it.data.mediaType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createPlaylist(
|
||||||
|
name: String,
|
||||||
|
initialItems: List<UUID>,
|
||||||
|
): UUID? =
|
||||||
|
serverRepository.currentUser?.let { user ->
|
||||||
|
api.playlistsApi
|
||||||
|
.createPlaylist(
|
||||||
|
CreatePlaylistDto(
|
||||||
|
name = name,
|
||||||
|
ids = initialItems,
|
||||||
|
users = listOf(PlaylistUserPermissions(user.id, true)),
|
||||||
|
isPublic = false,
|
||||||
|
),
|
||||||
|
).content.id
|
||||||
|
.toUUIDOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addToPlaylist(
|
||||||
|
playlistId: UUID,
|
||||||
|
itemId: UUID,
|
||||||
|
) {
|
||||||
|
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeFromPlaylist(
|
||||||
|
playlistId: UUID,
|
||||||
|
itemId: UUID,
|
||||||
|
) {
|
||||||
|
api.playlistsApi.removeItemFromPlaylist(
|
||||||
|
playlistId.toServerString(),
|
||||||
|
listOf(itemId.toServerString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistCreator
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AddPlaylistViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val playlistCreator: PlaylistCreator,
|
||||||
|
) : ViewModel() {
|
||||||
|
val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
|
fun loadPlaylists(mediaType: MediaType?) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading)
|
||||||
|
try {
|
||||||
|
val playlists = playlistCreator.getPlaylists(mediaType, viewModelScope)
|
||||||
|
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists))
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
playlistState.setValueOnMain(PlaylistLoadingState.Error(ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToPlaylist(
|
||||||
|
playlistId: UUID,
|
||||||
|
itemId: UUID,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
|
playlistCreator.addToPlaylist(playlistId, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlaylistAndAddItem(
|
||||||
|
playlistName: String,
|
||||||
|
itemId: UUID,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
|
val playlistId = playlistCreator.createPlaylist(playlistName, listOf(itemId))
|
||||||
|
if (playlistId == null) {
|
||||||
|
showToast(context, "Error creating playlist", Toast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ fun buildMoreDialogItems(
|
||||||
onClickFavorite: (Boolean) -> Unit,
|
onClickFavorite: (Boolean) -> Unit,
|
||||||
onChooseVersion: () -> Unit,
|
onChooseVersion: () -> Unit,
|
||||||
onChooseTracks: (MediaStreamType) -> Unit,
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
|
onClickAddPlaylist: () -> Unit,
|
||||||
): List<DialogItem> =
|
): List<DialogItem> =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
|
|
@ -113,6 +114,14 @@ fun buildMoreDialogItems(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.add_to_playlist,
|
||||||
|
iconStringRes = R.string.fa_list_ul,
|
||||||
|
) {
|
||||||
|
onClickAddPlaylist.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
|
@ -173,6 +182,7 @@ fun buildMoreDialogItemsForHome(
|
||||||
navigateTo: (Destination) -> Unit,
|
navigateTo: (Destination) -> Unit,
|
||||||
onClickWatch: (UUID, Boolean) -> Unit,
|
onClickWatch: (UUID, Boolean) -> Unit,
|
||||||
onClickFavorite: (UUID, Boolean) -> Unit,
|
onClickFavorite: (UUID, Boolean) -> Unit,
|
||||||
|
onClickAddPlaylist: (UUID) -> Unit,
|
||||||
): List<DialogItem> =
|
): List<DialogItem> =
|
||||||
buildList {
|
buildList {
|
||||||
val itemId = item.id
|
val itemId = item.id
|
||||||
|
|
@ -231,6 +241,14 @@ fun buildMoreDialogItemsForHome(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.add_to_playlist,
|
||||||
|
iconStringRes = R.string.fa_list_ul,
|
||||||
|
) {
|
||||||
|
onClickAddPlaylist.invoke(itemId)
|
||||||
|
},
|
||||||
|
)
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistList(
|
||||||
|
playlists: List<PlaylistInfo?>,
|
||||||
|
onClick: (PlaylistInfo) -> Unit,
|
||||||
|
createEnabled: Boolean,
|
||||||
|
onCreatePlaylist: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
items(playlists) { playlist ->
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
enabled = playlist != null,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = playlist?.name ?: stringResource(R.string.loading),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (playlist != null) {
|
||||||
|
Text(
|
||||||
|
text = playlist.mediaType.serialName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (playlist != null) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.items,
|
||||||
|
playlist.count,
|
||||||
|
playlist.count,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (playlist != null) onClick.invoke(playlist)
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (createEnabled) {
|
||||||
|
item {
|
||||||
|
HorizontalDivider()
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.create_playlist),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
tint = Color.Green.copy(.7f),
|
||||||
|
contentDescription = "Add",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showCreateDialog = true
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateDialog) {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showCreateDialog = false
|
||||||
|
},
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
elevation = 10.dp,
|
||||||
|
) {
|
||||||
|
var playlistName by remember { mutableStateOf("") }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(.4f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.name),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = playlistName,
|
||||||
|
onValueChange = {
|
||||||
|
playlistName = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
autoCorrectEnabled = true,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
showCreateDialog = false
|
||||||
|
onCreatePlaylist.invoke(playlistName)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showCreateDialog = false
|
||||||
|
onCreatePlaylist.invoke(playlistName)
|
||||||
|
},
|
||||||
|
enabled = playlistName.isNotNullOrBlank(),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistDialog(
|
||||||
|
title: String,
|
||||||
|
state: PlaylistLoadingState,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onClick: (PlaylistInfo) -> Unit,
|
||||||
|
createEnabled: Boolean,
|
||||||
|
onCreatePlaylist: (String) -> Unit,
|
||||||
|
elevation: Dp = 3.dp,
|
||||||
|
) {
|
||||||
|
val elevatedContainerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
this.clip = true
|
||||||
|
this.shape = RoundedCornerShape(24.0.dp)
|
||||||
|
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||||
|
.padding(PaddingValues(16.dp)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
when (val s = state) {
|
||||||
|
PlaylistLoadingState.Pending,
|
||||||
|
PlaylistLoadingState.Loading,
|
||||||
|
->
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.border,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.size(48.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
is PlaylistLoadingState.Error ->
|
||||||
|
ErrorMessage(s.message, s.exception)
|
||||||
|
|
||||||
|
is PlaylistLoadingState.Success -> {
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
PlaylistList(
|
||||||
|
playlists = s.items,
|
||||||
|
onClick = onClick,
|
||||||
|
createEnabled = createEnabled,
|
||||||
|
onCreatePlaylist = onCreatePlaylist,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PlaylistLoadingState {
|
||||||
|
data object Pending : PlaylistLoadingState
|
||||||
|
|
||||||
|
data object Loading : PlaylistLoadingState
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val items: List<PlaylistInfo?>,
|
||||||
|
) : PlaylistLoadingState
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String? = null,
|
||||||
|
val exception: Throwable? = null,
|
||||||
|
) : PlaylistLoadingState {
|
||||||
|
constructor(exception: Throwable) : this(null, exception)
|
||||||
|
|
||||||
|
val localizedMessage: String =
|
||||||
|
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,8 +65,11 @@ import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -76,6 +79,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -86,6 +90,7 @@ fun MovieDetails(
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MovieViewModel = hiltViewModel(),
|
viewModel: MovieViewModel = hiltViewModel(),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -102,6 +107,8 @@ fun MovieDetails(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var showPlaylistDialog by remember { mutableStateOf(false) }
|
||||||
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> ErrorMessage(state)
|
is LoadingState.Error -> ErrorMessage(state)
|
||||||
|
|
@ -206,6 +213,10 @@ fun MovieDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClickAddPlaylist = {
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog = true
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -267,6 +278,23 @@ fun MovieDetails(
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showPlaylistDialog) {
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog = false },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, destination.itemId)
|
||||||
|
showPlaylistDialog = false
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, destination.itemId)
|
||||||
|
showPlaylistDialog = false
|
||||||
|
},
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import androidx.compose.runtime.setValue
|
||||||
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.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -27,8 +29,11 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
|
|
@ -39,6 +44,7 @@ import com.github.damontecres.wholphin.util.seasonEpisode
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
|
@ -72,6 +78,7 @@ fun SeriesOverview(
|
||||||
destination: Destination.SeriesOverview,
|
destination: Destination.SeriesOverview,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel = hiltViewModel(),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
initialSeasonEpisode: SeasonEpisodeIds? = null,
|
initialSeasonEpisode: SeasonEpisodeIds? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -127,6 +134,8 @@ fun SeriesOverview(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
LaunchedEffect(episodes) {
|
LaunchedEffect(episodes) {
|
||||||
episodes?.let { episodes ->
|
episodes?.let { episodes ->
|
||||||
|
|
@ -245,6 +254,10 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClickAddPlaylist = {
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog = ep.id
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -363,4 +376,21 @@ fun SeriesOverview(
|
||||||
waitToLoad = params.fromLongClick,
|
waitToLoad = params.fromLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showPlaylistDialog?.let { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog = null },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog = null
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog = null
|
||||||
|
},
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
|
@ -55,8 +56,11 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
|
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
|
||||||
|
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.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -68,7 +72,9 @@ import com.github.damontecres.wholphin.util.formatDateTime
|
||||||
import com.github.damontecres.wholphin.util.seasonEpisode
|
import com.github.damontecres.wholphin.util.seasonEpisode
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class HomeRow(
|
data class HomeRow(
|
||||||
|
|
@ -92,6 +98,7 @@ fun HomePage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: HomeViewModel = hiltViewModel(),
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var firstLoad by rememberSaveable { mutableStateOf(true) }
|
var firstLoad by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
@ -124,6 +131,8 @@ fun HomePage(
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
HomePageContent(
|
HomePageContent(
|
||||||
homeRows,
|
homeRows,
|
||||||
onClickItem = {
|
onClickItem = {
|
||||||
|
|
@ -149,6 +158,10 @@ fun HomePage(
|
||||||
onClickFavorite = { itemId, favorite ->
|
onClickFavorite = { itemId, favorite ->
|
||||||
viewModel.setFavorite(itemId, favorite)
|
viewModel.setFavorite(itemId, favorite)
|
||||||
},
|
},
|
||||||
|
onClickAddPlaylist = { itemId ->
|
||||||
|
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||||
|
showPlaylistDialog = itemId
|
||||||
|
},
|
||||||
)
|
)
|
||||||
dialog =
|
dialog =
|
||||||
DialogParams(
|
DialogParams(
|
||||||
|
|
@ -166,6 +179,23 @@ fun HomePage(
|
||||||
onDismissRequest = { dialog = null },
|
onDismissRequest = { dialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showPlaylistDialog?.let { itemId ->
|
||||||
|
PlaylistDialog(
|
||||||
|
title = stringResource(R.string.add_to_playlist),
|
||||||
|
state = playlistState,
|
||||||
|
onDismissRequest = { showPlaylistDialog = null },
|
||||||
|
onClick = {
|
||||||
|
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||||
|
showPlaylistDialog = null
|
||||||
|
},
|
||||||
|
createEnabled = true,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||||
|
showPlaylistDialog = null
|
||||||
|
},
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
class TransformList<S, T>(
|
||||||
|
private val source: List<S>,
|
||||||
|
private val transform: (S) -> T,
|
||||||
|
) : AbstractList<T>() {
|
||||||
|
override val size: Int
|
||||||
|
get() = source.size
|
||||||
|
|
||||||
|
override fun get(index: Int): T = transform.invoke(source[index])
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,8 @@
|
||||||
<string name="years_old">%1$d years old</string>
|
<string name="years_old">%1$d years old</string>
|
||||||
<string name="play_with_transcoding">Play with transcoding</string>
|
<string name="play_with_transcoding">Play with transcoding</string>
|
||||||
<string name="aired_episode_order">Aired Episode Order</string>
|
<string name="aired_episode_order">Aired Episode Order</string>
|
||||||
|
<string name="create_playlist">Create new playlist</string>
|
||||||
|
<string name="add_to_playlist">Add to playlist</string>
|
||||||
|
|
||||||
<plurals name="downloads">
|
<plurals name="downloads">
|
||||||
<item quantity="zero">%d downloads</item>
|
<item quantity="zero">%d downloads</item>
|
||||||
|
|
@ -150,6 +152,11 @@
|
||||||
<item quantity="one">%d hour</item>
|
<item quantity="one">%d hour</item>
|
||||||
<item quantity="other">%d hours</item>
|
<item quantity="other">%d hours</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="items">
|
||||||
|
<item quantity="zero">%d items</item>
|
||||||
|
<item quantity="one">%d item</item>
|
||||||
|
<item quantity="other">%d items</item>
|
||||||
|
</plurals>
|
||||||
<plurals name="seconds">
|
<plurals name="seconds">
|
||||||
<item quantity="zero">%d seconds</item>
|
<item quantity="zero">%d seconds</item>
|
||||||
<item quantity="one">%d second</item>
|
<item quantity="one">%d second</item>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue