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
This commit is contained in:
Ray 2026-03-02 15:38:41 -05:00 committed by GitHub
parent 0b73b4cf64
commit 8935067c5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 597 additions and 53 deletions

View file

@ -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 {

View file

@ -72,6 +72,8 @@ data class BaseItem(
}
}
val canDelete: Boolean get() = data.canDelete == true
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }

View file

@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { it.number },
)
val ManageMedia =
AppSwitchPreference<AppPreferences>(
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<AppPreferences>(
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,

View file

@ -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<DeletedItem>(
replay = 1,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Listen for recently deleted items. Useful for ViewModels to react and refresh data
*/
val deletedItemFlow: SharedFlow<DeletedItem> = _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()
}
}
}

View file

@ -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<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()

View file

@ -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

View file

@ -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 =

View file

@ -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(

View file

@ -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<MediaSourceInfo>,

View file

@ -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,
)
}
}
}

View file

@ -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<DialogItem> =
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,

View file

@ -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<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(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

View file

@ -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<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(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()
}
}
}

View file

@ -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<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(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<DiscoverItem>,
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),

View file

@ -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<BaseItem?>(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

View file

@ -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<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(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 {

View file

@ -166,6 +166,22 @@ class ApiRequestPager<T>(
}
}
/**
* 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
}

View file

@ -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 {

View file

@ -692,6 +692,8 @@
<string name="no_subtitles_found">No remote subtitles were found</string>
<string name="series_continueing">Present</string>
<string name="in_app_screensaver">Use in-app screensaver</string>
<string name="delete_item">Are you sure you want to delete this item?</string>
<string name="show_media_management">Show media management options</string>
<string-array name="backdrop_style_options">
<item>@string/backdrop_style_dynamic</item>
<item>@string/backdrop_style_image</item>