From 40a8249469110f33b1f444d48a0fade9ea916532 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:27:26 -0500 Subject: [PATCH] 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. --- .../wholphin/data/model/Playlist.kt | 82 ++++++ .../wholphin/ui/data/AddPlaylistViewModel.kt | 61 ++++ .../wholphin/ui/detail/DetailUtils.kt | 18 ++ .../wholphin/ui/detail/PlaylistList.kt | 273 ++++++++++++++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 28 ++ .../ui/detail/series/SeriesOverview.kt | 30 ++ .../damontecres/wholphin/ui/main/HomePage.kt | 30 ++ .../wholphin/util/TransformList.kt | 11 + app/src/main/res/values/strings.xml | 7 + 9 files changed, 540 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 00e99902..c41a0bbd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -1,15 +1,31 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.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.GetItemsRequestHandler 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.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.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject 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 class PlaylistCreator @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, + private val serverRepository: ServerRepository, ) { suspend fun createFromEpisode( seriesId: UUID, @@ -92,4 +117,61 @@ class PlaylistCreator } return Playlist(baseItems, 0) } + + suspend fun getPlaylists( + mediaType: MediaType?, + scope: CoroutineScope, + ): List { + 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? = + 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()), + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt new file mode 100644 index 00000000..2287804c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -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.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) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 7a1705d2..7622436e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -48,6 +48,7 @@ fun buildMoreDialogItems( onClickFavorite: (Boolean) -> Unit, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, + onClickAddPlaylist: () -> Unit, ): List = buildList { add( @@ -113,6 +114,14 @@ fun buildMoreDialogItems( ) } } + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + onClickAddPlaylist.invoke() + }, + ) add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -173,6 +182,7 @@ fun buildMoreDialogItemsForHome( navigateTo: (Destination) -> Unit, onClickWatch: (UUID, Boolean) -> Unit, onClickFavorite: (UUID, Boolean) -> Unit, + onClickAddPlaylist: (UUID) -> Unit, ): List = buildList { 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( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt new file mode 100644 index 00000000..12ad6ac3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -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, + 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, + ) : 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(" - ") + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 5322a9e1..b88607e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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.chooseStream 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.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.isNotNullOrBlank 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 kotlinx.coroutines.launch 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.serializer.toUUID import kotlin.time.Duration @@ -86,6 +90,7 @@ fun MovieDetails( destination: Destination.MediaItem, modifier: Modifier = Modifier, viewModel: MovieViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current LaunchedEffect(Unit) { @@ -102,6 +107,8 @@ fun MovieDetails( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } var chooseVersion by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf(false) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { 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, ) } + 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index ef0a0eaf..52d17610 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -14,6 +14,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester 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.lifecycle.compose.LifecycleStartEffect 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.chooseStream 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.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.equalsNotNull 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.UseSerializers 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.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID @@ -72,6 +78,7 @@ fun SeriesOverview( destination: Destination.SeriesOverview, modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSeasonEpisode: SeasonEpisodeIds? = null, ) { val context = LocalContext.current @@ -127,6 +134,8 @@ fun SeriesOverview( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } var chooseVersion by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf(null) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) LaunchedEffect(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, ) } + 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, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 74f2849c..4e3ed0d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -44,6 +44,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences 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.ErrorMessage 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.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.ifElse 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 kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration data class HomeRow( @@ -92,6 +98,7 @@ fun HomePage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current var firstLoad by rememberSaveable { mutableStateOf(true) } @@ -124,6 +131,8 @@ fun HomePage( LoadingState.Success -> { var dialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf(null) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( homeRows, onClickItem = { @@ -149,6 +158,10 @@ fun HomePage( onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = itemId + }, ) dialog = DialogParams( @@ -166,6 +179,23 @@ fun HomePage( 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, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt new file mode 100644 index 00000000..61946d60 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util + +class TransformList( + private val source: List, + private val transform: (S) -> T, +) : AbstractList() { + override val size: Int + get() = source.size + + override fun get(index: Int): T = transform.invoke(source[index]) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1075cfb5..1e927476 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,8 @@ %1$d years old Play with transcoding Aired Episode Order + Create new playlist + Add to playlist %d downloads @@ -150,6 +152,11 @@ %d hour %d hours + + %d items + %d item + %d items + %d seconds %d second