Add option to remove item from Continue Watching and Next Up rows (#1140)

## Description
Adds context menu options for "Remove from continue watching" and
"Remove series from next up"

### Remove from continue watching

This marks the item as unplayed so it will not be shown on the Continue
Watching row anymore. It may still show on a Next Up or combined row
though.

### Remove series from next up

This first does the same as "Remove from continue watching" above. But
it also stores the series' ID in your display preferences as removed.
Wholphin will filter out this series from appearing in the Next Up row
until you watch _any_ episode in the series in the future.

This only works for Wholphin clients!

There's a dialog to view which series have been removed and to re-add
them manually. Settings->Customize Home->Settings->View removed next up

### Related issues
Closes #906

### Testing
Mostly emulator testing

## Screenshots

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-07 16:04:21 -04:00 • committed by GitHub
parent ade564d907
commit 4eabaa9a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 964 additions and 49 deletions

View file

@ -31,6 +31,7 @@ data class MoreDialogActions(
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
)
enum class ClearChosenStreams {
@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome(
favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
canRemoveContinueWatching: Boolean = false,
canRemoveNextUp: Boolean = false,
): List<DialogItem> =
buildList {
val itemId = item.id
@ -347,6 +350,26 @@ fun buildMoreDialogItemsForHome(
},
)
}
if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) {
add(
DialogItem(
text = R.string.remove_continue_watching,
iconStringRes = R.string.fa_eye,
) {
actions.onClickWatch.invoke(itemId, false)
},
)
}
if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
add(
DialogItem(
text = R.string.remove_next_up,
iconStringRes = R.string.fa_tag,
) {
actions.onClickRemoveFromNextUp.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -47,6 +46,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
@ -138,6 +138,12 @@ fun HomePage(
remember {
{ clickedPosition: RowColumn, item: BaseItem ->
position = clickedPosition
val row =
(homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success)
val canRemoveContinueWatching =
row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
val canRemoveNextUp =
row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
@ -147,6 +153,8 @@ fun HomePage(
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveNextUp = canRemoveNextUp,
canRemoveContinueWatching = canRemoveContinueWatching,
actions =
MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo,
@ -164,6 +172,7 @@ fun HomePage(
onClickDelete = {
showDeleteDialog = RowColumnItem(position, item)
},
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
),
)
dialog =
@ -260,7 +269,9 @@ fun HomePageContent(
) {
val focusedItem =
remember(homeRows, position) {
(homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(position.column)
(homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(
position.column,
)
}
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
@ -354,7 +365,13 @@ fun HomePageContent(
onClickItem =
remember(rowIndex, onClickItem) {
{ index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item)
onClickItem.invoke(
RowColumn(
rowIndex,
index,
),
item,
)
}
},
onLongClickItem =
@ -378,7 +395,12 @@ fun HomePageContent(
remember(rowIndex, index) {
{ isFocused: Boolean ->
if (isFocused) {
currentOnFocusPosition(RowColumn(rowIndex, index))
currentOnFocusPosition(
RowColumn(
rowIndex,
index,
),
)
}
}
}
@ -387,7 +409,10 @@ fun HomePageContent(
{ event: androidx.compose.ui.input.key.KeyEvent ->
if (isPlayKeyUp(event) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
currentOnClickPlay(currentPosition, item)
currentOnClickPlay(
currentPosition,
item,
)
true
} else {
false

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -58,6 +60,7 @@ class HomeViewModel
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
private val latestNextUpService: LatestNextUpService,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state
@ -231,6 +234,19 @@ class HomeViewModel
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun removeFromNextUp(item: BaseItem) {
if (item.type == BaseItemKind.EPISODE) {
viewModelScope.launchDefault {
serverRepository.currentUser.value?.id?.let { userId ->
latestNextUpService.removeFromNextUp(userId, item)
init()
}
}
} else {
Timber.w("Item is not an episode %s", item.id)
}
}
}
data class HomeState(

View file

@ -38,6 +38,7 @@ fun HomeSettingsGlobal(
onClickLoad: () -> Unit,
onClickLoadWeb: () -> Unit,
onClickReset: () -> Unit,
onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus: FocusRequester = remember { FocusRequester() }
@ -123,6 +124,20 @@ fun HomeSettingsGlobal(
)
}
item { HorizontalDivider() }
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.view_removed_next_up),
leadingContent = {
Text(
text = stringResource(R.string.fa_eye),
fontFamily = FontAwesome,
)
},
onClick = onClickViewNextUp,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,

View file

@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
@ -61,6 +62,7 @@ fun HomeSettingsPage(
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
var position by rememberPosition(0, 0)
@ -284,6 +286,9 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier,
)
}
@ -340,6 +345,15 @@ fun HomeSettingsPage(
},
)
}
if (showRemovedNextUpDialog) {
BasicDialog(
onDismissRequest = { showRemovedNextUpDialog = false },
) {
RemovedNextUpContent(
modifier = Modifier.padding(16.dp),
)
}
}
}
data class ShowConfirm(

View file

@ -0,0 +1,259 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.time.LocalDateTime
import javax.inject.Inject
@HiltViewModel
class RemovedNextUpContentViewModel
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(RemovedNextUpState())
val state: StateFlow<RemovedNextUpState> = _state
init {
viewModelScope.launchDefault {
serverRepository.currentUser.asFlow().collectLatest { user ->
_state.update { RemovedNextUpState() }
if (user == null) {
return@collectLatest
}
try {
val removed = latestNextUpService.getRemovedFromNextUp(user.id)
val series = mutableListOf<RemovedItem>()
removed.keys.chunked(50).forEach { ids ->
val results =
api.itemsApi
.getItems(
userId = user.id,
ids = ids,
).toBaseItems(api, false)
results.forEach {
val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY)
series.add(RemovedItem(it, imageUrl, removed[it.id]!!))
}
}
_state.update { it.copy(loading = DataLoadingState.Success(series)) }
} catch (ex: Exception) {
Timber.e(ex, "Error fetching removed series")
_state.update { it.copy(loading = DataLoadingState.Error(ex)) }
}
}
}
}
fun remove(item: RemovedItem) {
serverRepository.currentUser.value?.let { user ->
viewModelScope.launchDefault {
mutex.withLock {
_state.update { it.copy(removedEnabled = false) }
try {
latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id)
val newItems =
(_state.value.loading as? DataLoadingState.Success<List<RemovedItem>>)
?.data
?.toMutableList()
?.apply {
removeIf { it.series.id == item.series.id }
}
val loading =
if (newItems != null) {
DataLoadingState.Success(newItems)
} else {
DataLoadingState.Error("Error occurred")
}
_state.update {
it.copy(
loading = loading,
removedEnabled = true,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error removing %s from removed next up", item.series.id)
} finally {
_state.update { it.copy(removedEnabled = true) }
}
}
}
}
}
}
@Stable
data class RemovedItem(
val series: BaseItem,
val imageUrl: String?,
val datetime: LocalDateTime,
)
data class RemovedNextUpState(
val loading: DataLoadingState<List<RemovedItem>> = DataLoadingState.Pending,
val removedEnabled: Boolean = true,
)
@Composable
fun RemovedNextUpContent(
modifier: Modifier = Modifier,
viewModel: RemovedNextUpContentViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Column(
modifier = modifier,
) {
Text(
text = "Removed from next up",
style = MaterialTheme.typography.displaySmall,
)
when (val s = state.loading) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
LoadingPage(Modifier.fillMaxWidth())
}
is DataLoadingState.Error -> {
ErrorMessage(s, Modifier.fillMaxWidth())
}
is DataLoadingState.Success<List<RemovedItem>> -> {
if (s.data.isEmpty()) {
Text(
text = stringResource(R.string.no_results),
)
} else {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
LazyColumn(
contentPadding = PaddingValues(horizontal = 8.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
) {
items(s.data, key = { it.series.id }) { item ->
RemovedListItem(
item = item,
removedEnabled = state.removedEnabled,
onClickRemove =
remember {
{ viewModel.remove(item) }
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
}
@Composable
fun RemovedListItem(
item: RemovedItem,
removedEnabled: Boolean,
onClickRemove: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
ListItem(
selected = false,
onClick = {},
leadingContent = {
AsyncImage(
model = item.imageUrl,
contentDescription = item.series.title,
modifier = Modifier.height(80.dp),
)
},
headlineContent = {
Text(
text = item.series.title ?: item.series.id.toString(),
)
},
supportingContent = {
Text(
text = formatDateTime(item.datetime),
)
},
scale = ListItemDefaults.scale(focusedScale = 1f),
modifier = Modifier.weight(1f),
)
Button(
onClick = onClickRemove,
enabled = removedEnabled,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "delete",
modifier = Modifier,
)
}
}
}