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() .penaltyDeathOnNetwork()
.build(), .build(),
) )
StrictMode.setVmPolicy( // StrictMode.setVmPolicy(
StrictMode.VmPolicy // StrictMode.VmPolicy
.Builder() // .Builder()
.detectAll() // .detectAll()
.penaltyLog() // .penaltyLog()
.build(), // .build(),
) // )
} }
OkHttp.initialize(this) OkHttp.initialize(this)
initAcra { initAcra {

View file

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

View file

@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { it.number }, 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 = val OneClickPause =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause, title = R.string.one_click_pause,
@ -1110,6 +1122,7 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext, AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // 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 com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -149,7 +151,9 @@ class NavDrawerService
val allItems = builtins + libraries val allItems = builtins + libraries
val navDrawerPins = val navDrawerPins =
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
val items = mutableListOf<NavDrawerItem>() val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>() val moreItems = mutableListOf<NavDrawerItem>()

View file

@ -185,7 +185,7 @@ object AppModule {
@Provides @Provides
@Singleton @Singleton
@DefaultCoroutineScope @DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Provides @Provides
@Singleton @Singleton

View file

@ -58,6 +58,7 @@ val DefaultItemFields =
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
) )
/** /**
@ -72,6 +73,7 @@ val SlimItemFields =
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
) )
val PhotoItemFields = 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -128,6 +130,7 @@ class CollectionFolderViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@Assisted itemId: String, @Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?, @Assisted initialSortAndDirection: SortAndDirection?,
@ -199,6 +202,19 @@ class CollectionFolderViewModel
loading.setValueOnMain(DataLoadingState.Error(ex)) 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( private fun saveLibraryDisplayInfo(

View file

@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope 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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow 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.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector 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.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream 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 com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -259,31 +271,65 @@ fun DialogPopupContent(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier, modifier = Modifier,
) { ) {
items(dialogItems) { itemsIndexed(dialogItems) { index, item ->
when (it) { when (item) {
is DialogItemDivider -> { is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp)) HorizontalDivider(Modifier.height(16.dp))
} }
is DialogItem -> { is DialogItem -> {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
selected = it.selected, selected = item.selected,
enabled = !waiting && it.enabled, enabled = !waiting && item.enabled,
onClick = { onClick = {
if (dismissOnClick) { if (dismissOnClick) {
onDismissRequest.invoke() onDismissRequest.invoke()
} }
it.onClick.invoke() item.onClick.invoke()
}, },
headlineContent = it.headlineContent, headlineContent = item.headlineContent,
overlineContent = it.overlineContent, overlineContent = item.overlineContent,
supportingContent = it.supportingContent, supportingContent = item.supportingContent,
leadingContent = it.leadingContent, leadingContent = item.leadingContent,
trailingContent = it.trailingContent, trailingContent = item.trailingContent,
modifier = Modifier, 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( fun chooseVersionParams(
context: Context, context: Context,
sources: List<MediaSourceInfo>, sources: List<MediaSourceInfo>,

View file

@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility 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.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.layout.size
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons 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.MoreVert
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -234,7 +241,7 @@ fun ExpandablePlayButton(
fun ExpandablePlayButton( fun ExpandablePlayButton(
@StringRes title: Int, @StringRes title: Int,
resume: Duration, resume: Duration,
icon: @Composable () -> Unit, icon: @Composable BoxScope.() -> Unit,
onClick: (position: Duration) -> Unit, onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@ -254,12 +261,13 @@ fun ExpandablePlayButton(
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Box( Box(
contentAlignment = Alignment.Center,
modifier = modifier =
Modifier Modifier
.padding(start = 2.dp, top = 2.dp) .padding(start = 2.dp)
.height(MinButtonSize), .height(MinButtonSize),
) { ) {
icon.invoke() icon.invoke(this)
} }
AnimatedVisibility(isFocused) { AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp)) 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 @PreviewTvSpec
@Composable @Composable
private fun ExpandablePlayButtonsPreview() { private fun ExpandablePlayButtonsPreview() {
@ -417,12 +467,19 @@ private fun ViewOptionsPreview() {
onClick = {}, onClick = {},
modifier = Modifier, modifier = Modifier,
) )
DeleteButton(
onClick = {},
)
SortByButton( SortByButton(
sortOptions = listOf(), sortOptions = listOf(),
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
onSortChange = {}, onSortChange = {},
) )
} }
DeleteButton(
onClick = {},
interactionSource = source,
)
} }
} }
} }

View file

@ -29,6 +29,7 @@ data class MoreDialogActions(
val onClickFavorite: (UUID, Boolean) -> Unit, val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit, val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit = {},
) )
enum class ClearChosenStreams { enum class ClearChosenStreams {
@ -62,6 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canClearChosenStreams: Boolean, canClearChosenStreams: Boolean,
canDelete: Boolean = false,
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
@ -139,6 +141,17 @@ fun buildMoreDialogItems(
actions.onClickAddPlaylist.invoke(item.id) 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( add(
DialogItem( DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration, playbackPosition: Duration,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean = false,
actions: MoreDialogActions, actions: MoreDialogActions,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome(
actions.onClickAddPlaylist.invoke(itemId) 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( add(
DialogItem( DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,

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.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -111,6 +112,7 @@ fun MovieDetails(
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
@ -126,6 +128,7 @@ fun MovieDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
) )
when (val state = loading) { when (val state = loading) {
@ -201,6 +204,7 @@ fun MovieDetails(
seriesId = null, seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -291,6 +295,7 @@ fun MovieDetails(
playbackPosition = similar.playbackPosition, playbackPosition = similar.playbackPosition,
watched = similar.played, watched = similar.played,
favorite = similar.favorite, favorite = similar.favorite,
canDelete = false,
actions = moreActions, actions = moreActions,
) )
moreDialog = moreDialog =
@ -362,6 +367,16 @@ fun MovieDetails(
elevation = 3.dp, 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 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.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -73,6 +75,7 @@ class MovieViewModel
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -90,6 +93,9 @@ class MovieViewModel
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
var canDelete: Boolean = false
private set
init { init {
init() init()
} }
@ -106,6 +112,7 @@ class MovieViewModel
api.userLibraryApi.getItem(itemId).content.let { api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api) BaseItem.from(it, api)
} }
canDelete = mediaManagementService.canDelete(item)
this@MovieViewModel.item.setValueOnMain(item) this@MovieViewModel.item.setValueOnMain(item)
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.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -24,12 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup 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.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
@ -102,21 +108,25 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val canDelete by viewModel.canDeleteSeries.collectAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState() val discovered by viewModel.discovered.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) } var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -150,6 +160,7 @@ fun SeriesDetails(
similar = similar, similar = similar,
played = played, played = played,
favorite = item.data.userData?.isFavorite ?: false, favorite = item.data.userData?.isFavorite ?: false,
canDelete = canDelete,
modifier = modifier, modifier = modifier,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigateTo(item.destination()) viewModel.navigateTo(item.destination())
@ -163,23 +174,29 @@ fun SeriesDetails(
) )
}, },
onLongClickItem = { index, season -> onLongClickItem = { index, season ->
seasonDialog = scope.launchDefault {
buildDialogForSeason( seasonDialog =
context = context, buildDialogForSeason(
s = season, context = context,
onClickItem = { viewModel.navigateTo(it.destination()) }, s = season,
markPlayed = { played -> canDelete = viewModel.canDelete(season),
viewModel.setSeasonWatched(season.id, played) onClickItem = { viewModel.navigateTo(it.destination()) },
}, markPlayed = { played ->
onClickPlay = { shuffle -> viewModel.setSeasonWatched(season.id, played)
viewModel.navigateTo( },
Destination.PlaybackList( onClickPlay = { shuffle ->
itemId = season.id, viewModel.navigateTo(
shuffle = shuffle, Destination.PlaybackList(
), itemId = season.id,
) shuffle = shuffle,
}, ),
) )
},
onClickDelete = {
showDeleteDialog = it
},
)
}
}, },
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
@ -231,6 +248,9 @@ fun SeriesDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
), ),
) )
if (showWatchConfirmation) { if (showWatchConfirmation) {
@ -283,6 +303,19 @@ fun SeriesDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -305,6 +338,7 @@ fun SeriesDetailsContent(
discovered: List<DiscoverItem>, discovered: List<DiscoverItem>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickItem: (Int, BaseItem) -> 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 { item {
@ -638,9 +689,11 @@ fun SeriesDetailsHeader(
fun buildDialogForSeason( fun buildDialogForSeason(
context: Context, context: Context,
s: BaseItem, s: BaseItem,
canDelete: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit, markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit,
onClickDelete: (BaseItem) -> Unit,
): DialogParams { ): DialogParams {
val items = val items =
buildList { buildList {
@ -679,6 +732,17 @@ fun buildDialogForSeason(
onClickPlay.invoke(true) 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( return DialogParams(
title = s.name ?: context.getString(R.string.tv_season), 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.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -89,6 +93,7 @@ fun SeriesOverview(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() }
@ -118,6 +123,7 @@ fun SeriesOverview(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt() var rowFocused by rememberInt()
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
LaunchedEffect(episodes) { LaunchedEffect(episodes) {
episodes?.let { episodes -> episodes?.let { episodes ->
@ -177,7 +183,7 @@ fun SeriesOverview(
} }
} }
fun buildMoreForEpisode( suspend fun buildMoreForEpisode(
ep: BaseItem, ep: BaseItem,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
fromLongClick: Boolean, fromLongClick: Boolean,
@ -194,6 +200,7 @@ fun SeriesOverview(
seriesId = series.id, seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete(ep),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -216,6 +223,9 @@ fun SeriesOverview(
showPlaylistDialog = it showPlaylistDialog = it
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
), ),
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -315,7 +325,9 @@ fun SeriesOverview(
) )
}, },
onLongClick = { ep -> onLongClick = { ep ->
moreDialog = buildMoreForEpisode(ep, chosenStreams, true) scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
}
}, },
playOnClick = { resume -> playOnClick = { resume ->
rowFocused = EPISODE_ROW rowFocused = EPISODE_ROW
@ -343,18 +355,22 @@ fun SeriesOverview(
}, },
moreOnClick = { moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = buildMoreForEpisode(ep, chosenStreams, false) scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
}
} }
}, },
overviewOnClick = { overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = scope.launchDefault {
ItemDetailsDialogInfo( overviewDialog =
title = it.name ?: context.getString(R.string.unknown), ItemDetailsDialogInfo(
overview = it.data.overview, title = it.name ?: context.getString(R.string.unknown),
genres = it.data.genres.orEmpty(), overview = it.data.overview,
files = it.data.mediaSources.orEmpty(), genres = it.data.genres.orEmpty(),
) files = it.data.mediaSources.orEmpty(),
)
}
} }
}, },
personOnClick = { personOnClick = {
@ -420,6 +436,17 @@ fun SeriesOverview(
elevation = 3.dp, 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 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.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt 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.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.lt
@ -90,6 +93,7 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID, @Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType, @Assisted val seriesPageType: SeriesPageType,
@ -111,6 +115,7 @@ class SeriesViewModel
val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
@ -127,6 +132,7 @@ class SeriesViewModel
Timber.v("Start") Timber.v("Start")
addCloseable { themeSongPlayer.stop() } addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
canDeleteSeries.update { mediaManagementService.canDelete(item) }
backdropService.submit(item) backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@ -259,9 +265,12 @@ class SeriesViewModel
if (seriesPageType == SeriesPageType.DETAILS) { if (seriesPageType == SeriesPageType.DETAILS) {
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
) )
} else { } else {
null listOf(
ItemFields.CAN_DELETE,
)
}, },
) )
val pager = val pager =
@ -300,6 +309,7 @@ class SeriesViewModel
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING, ItemFields.CUSTOM_RATING,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
), ),
) )
Timber.v( Timber.v(
@ -551,6 +561,59 @@ class SeriesViewModel
lookUpChosenTracks(item.id, item) 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 { 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 { companion object {
private const val DEBUG = false private const val DEBUG = false
} }

View file

@ -166,6 +166,7 @@ message InterfacePreferences {
BackdropStyle backdrop_style = 9; BackdropStyle backdrop_style = 9;
SubtitlePreferences hdr_subtitles_preferences = 10; SubtitlePreferences hdr_subtitles_preferences = 10;
ScreensaverPreferences screensaver_preference = 11; ScreensaverPreferences screensaver_preference = 11;
bool enable_media_management = 12;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -692,6 +692,8 @@
<string name="no_subtitles_found">No remote subtitles were found</string> <string name="no_subtitles_found">No remote subtitles were found</string>
<string name="series_continueing">Present</string> <string name="series_continueing">Present</string>
<string name="in_app_screensaver">Use in-app screensaver</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"> <string-array name="backdrop_style_options">
<item>@string/backdrop_style_dynamic</item> <item>@string/backdrop_style_dynamic</item>
<item>@string/backdrop_style_image</item> <item>@string/backdrop_style_image</item>