From 8935067c5a966abdc4085bf2de0d17b1433774d8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:38:41 -0500 Subject: [PATCH] Add option to delete media (#1014) ## Description Adds a setting to enable "Media management" which adds a context menu item to delete media. This is accessible using either the More button or long clicking. You can delete episodes, seasons, series and movies/videos. If order for the option to appear the current user must have server-side settings for "Allow media deletion from" enabled for the library and the in-app setting under Settings->Advanced Settings->"Show media management options". ### Related issues Closes #104 ### Testing Emulator ## Screenshots ### Context menu ![delete_context_2 Large](https://github.com/user-attachments/assets/cabc3c24-1b43-4f88-8d71-b7275f9eadc4) ### Confirmation ![delete_confirm Large](https://github.com/user-attachments/assets/8036dde1-8a51-424b-bbdd-f2826dd3e0ab) ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 14 +- .../wholphin/data/model/BaseItem.kt | 2 + .../wholphin/preferences/AppPreference.kt | 13 ++ .../services/MediaManagementService.kt | 104 +++++++++++++ .../wholphin/services/NavDrawerService.kt | 6 +- .../wholphin/services/hilt/AppModule.kt | 2 +- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../ui/components/CollectionFolderGrid.kt | 16 ++ .../wholphin/ui/components/Dialogs.kt | 144 ++++++++++++++++-- .../wholphin/ui/components/PlayButtons.kt | 63 +++++++- .../wholphin/ui/detail/DetailUtils.kt | 25 +++ .../wholphin/ui/detail/movie/MovieDetails.kt | 15 ++ .../ui/detail/movie/MovieViewModel.kt | 13 ++ .../ui/detail/series/SeriesDetails.kt | 100 +++++++++--- .../ui/detail/series/SeriesOverview.kt | 47 ++++-- .../ui/detail/series/SeriesViewModel.kt | 65 +++++++- .../wholphin/util/ApiRequestPager.kt | 16 ++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 19 files changed, 597 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 900fc4c3..ceb7264e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -62,13 +62,13 @@ class WholphinApplication : .penaltyDeathOnNetwork() .build(), ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy - .Builder() - .detectAll() - .penaltyLog() - .build(), - ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) } OkHttp.initialize(this) initAcra { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index e88673c1..3c34851b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -72,6 +72,8 @@ data class BaseItem( } } + val canDelete: Boolean get() = data.canDelete == true + @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 379b782b..ef3f14e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -741,6 +741,18 @@ sealed interface AppPreference { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference( title = R.string.one_click_pause, @@ -1110,6 +1122,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ManageMedia, AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt new file mode 100644 index 00000000..f3aff8c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + // TODO enable + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 6b6111e4..b83f0bd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -149,7 +151,9 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + withContext(Dispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } val items = mutableListOf() val moreItems = mutableListOf() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 546636e5..e3e42efa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -185,7 +185,7 @@ object AppModule { @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @Provides @Singleton diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 2d9b6d5e..ab0f69f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -58,6 +58,7 @@ val DefaultItemFields = ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) /** @@ -72,6 +73,7 @@ val SlimItemFields = ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) val PhotoItemFields = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 1a33118d..6eae4f26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -60,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer @@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination @@ -128,6 +130,7 @@ class CollectionFolderViewModel private val navigationManager: NavigationManager, private val themeSongPlayer: ThemeSongPlayer, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -199,6 +202,19 @@ class CollectionFolderViewModel loading.setValueOnMain(DataLoadingState.Error(ex)) } } + viewModelScope.launchDefault { + mediaManagementService.deletedItemFlow.collect { deletedItem -> + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id) + val item = pager?.get(it) + if (item?.id == deletedItem.item.id) { + pager.refreshPagesAfter(position) + } + } + } + } } private fun saveLibraryDisplayInfo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 579d9a97..a1f2362a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.draw.shadow +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.graphics.vector.ImageVector @@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,31 +271,65 @@ fun DialogPopupContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { if (dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == 0, + Modifier.onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(dialogItems.lastIndex) + focusRequesters[dialogItems.lastIndex].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + index == dialogItems.lastIndex, + Modifier.onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + focusRequesters[0].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ), ) } } @@ -471,6 +517,80 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + fun chooseVersionParams( context: Context, sources: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 17b84dee..570d411e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource @@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -234,7 +241,7 @@ fun ExpandablePlayButton( fun ExpandablePlayButton( @StringRes title: Int, resume: Duration, - icon: @Composable () -> Unit, + icon: @Composable BoxScope.() -> Unit, onClick: (position: Duration) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -254,12 +261,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - icon.invoke() + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -359,6 +367,48 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { onClick.invoke() }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -417,12 +467,19 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onClick = {}, + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onClick = {}, + interactionSource = source, + ) } } } 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 cebfff70..645ba2ce 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 @@ -29,6 +29,7 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, + val onClickDelete: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -62,6 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -139,6 +141,17 @@ fun buildMoreDialogItems( actions.onClickAddPlaylist.invoke(item.id) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, ): List = buildList { @@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome( actions.onClickAddPlaylist.invoke(itemId) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } 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/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 2a40a399..ada15158 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 @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -111,6 +112,7 @@ fun MovieDetails( var chooseVersion by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf(null) } val moreActions = MoreDialogActions( @@ -126,6 +128,7 @@ fun MovieDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -201,6 +204,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -291,6 +295,7 @@ fun MovieDetails( playbackPosition = similar.playbackPosition, watched = similar.played, favorite = similar.favorite, + canDelete = false, actions = moreActions, ) moreDialog = @@ -362,6 +367,16 @@ fun MovieDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index ed46656c..993260dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -73,6 +75,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -90,6 +93,9 @@ class MovieViewModel val chosenStreams = MutableLiveData(null) val discovered = MutableStateFlow>(listOf()) + var canDelete: Boolean = false + private set + init { init() } @@ -106,6 +112,7 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } + canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } @@ -274,4 +281,10 @@ class MovieViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 579a3866..222f7f77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -24,12 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.DeleteButton import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -76,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -102,21 +108,25 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() + val canDelete by viewModel.canDeleteSeries.collectAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf(null) } when (val state = loading) { is LoadingState.Error -> { @@ -150,6 +160,7 @@ fun SeriesDetails( similar = similar, played = played, favorite = item.data.userData?.isFavorite ?: false, + canDelete = canDelete, modifier = modifier, onClickItem = { index, item -> viewModel.navigateTo(item.destination()) @@ -163,23 +174,29 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + scope.launchDefault { + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + canDelete = viewModel.canDelete(season), + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + onClickDelete = { + showDeleteDialog = it + }, + ) + } }, overviewOnClick = { overviewDialog = @@ -231,6 +248,9 @@ fun SeriesDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), ) if (showWatchConfirmation) { @@ -283,6 +303,19 @@ fun SeriesDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + if (seasons?.lastOrNull()?.id == item.id) { + focusManager.moveFocus(FocusDirection.Previous) + } + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -305,6 +338,7 @@ fun SeriesDetailsContent( discovered: List, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -436,6 +470,23 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + onClick = { + position = HEADER_ROW + moreActions.onClickDelete.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -638,9 +689,11 @@ fun SeriesDetailsHeader( fun buildDialogForSeason( context: Context, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { @@ -679,6 +732,17 @@ fun buildDialogForSeason( onClickPlay.invoke(true) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), 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 8fba8ed6..79731e47 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable @@ -89,6 +93,7 @@ fun SeriesOverview( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } @@ -118,6 +123,7 @@ fun SeriesOverview( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() + var showDeleteDialog by remember { mutableStateOf(null) } LaunchedEffect(episodes) { episodes?.let { episodes -> @@ -177,7 +183,7 @@ fun SeriesOverview( } } - fun buildMoreForEpisode( + suspend fun buildMoreForEpisode( ep: BaseItem, chosenStreams: ChosenStreams?, fromLongClick: Boolean, @@ -194,6 +200,7 @@ fun SeriesOverview( seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete(ep), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -216,6 +223,9 @@ fun SeriesOverview( showPlaylistDialog = it }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), onChooseVersion = { chooseVersion = @@ -315,7 +325,9 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + } }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -343,18 +355,22 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + } } }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) + scope.launchDefault { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + genres = it.data.genres.orEmpty(), + files = it.data.mediaSources.orEmpty(), + ) + } } }, personOnClick = { @@ -420,6 +436,17 @@ fun SeriesOverview( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.subtitle ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + episodeRowFocusRequester.tryRequestFocus() + showDeleteDialog = null + }, + ) + } } private const val EPISODE_ROW = 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index a299fe3c..0e1b326b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -24,10 +25,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt @@ -90,6 +93,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -111,6 +115,7 @@ class SeriesViewModel val extras = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val similar = MutableLiveData>() + val canDeleteSeries = MutableStateFlow(false) val peopleInEpisode = MutableLiveData(PeopleInItem()) val discovered = MutableStateFlow>(listOf()) @@ -127,6 +132,7 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) + canDeleteSeries.update { mediaManagementService.canDelete(item) } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -259,9 +265,12 @@ class SeriesViewModel if (seriesPageType == SeriesPageType.DETAILS) { listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -300,6 +309,7 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ), ) Timber.v( @@ -551,6 +561,59 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + this@SeriesViewModel.item.value?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = episodes.value as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index b03ce9f9..4b30c418 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -166,6 +166,22 @@ class ApiRequestPager( } } + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) + } + } + } + fetchPageBlocking(position, true) + } + companion object { private const val DEBUG = false } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 8d6034b1..081b25e4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -166,6 +166,7 @@ message InterfacePreferences { BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 967d6922..cc1ebf9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,6 +692,8 @@ No remote subtitles were found Present Use in-app screensaver + Are you sure you want to delete this item? + Show media management options @string/backdrop_style_dynamic @string/backdrop_style_image