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

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
@ -115,6 +116,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
@Inject
lateinit var backdropService: BackdropService

View file

@ -79,8 +79,7 @@ data class BaseItem(
val canDelete: Boolean get() = data.canDelete == true
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val indexNumber get() = data.indexNumber
@ -92,8 +91,7 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false
@Transient
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
/**
* Contains pre computed UI elements that would be expensive to create on the main thread

View file

@ -0,0 +1,59 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DisplayPreferencesService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
private val mutex = Mutex()
suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
client: String = DEFAULT_CLIENT,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = client,
).content
suspend fun updateDisplayPreferences(
userId: UUID,
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
client: String = DEFAULT_CLIENT,
block: MutableMap<String, String?>.() -> Unit,
) {
mutex.withLock {
val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
block.invoke(this)
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = client,
data = current.copy(customPrefs = customPrefs),
)
}
}
companion object {
const val DEFAULT_DISPLAY_PREF_ID = "default"
val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin"
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -39,4 +40,7 @@ class FavoriteWatchManager
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
suspend fun removeContinueWatching(item: BaseItem) {
}
}

View file

@ -42,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -81,6 +80,7 @@ class HomeSettingsService
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService,
) {
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
@ -103,19 +103,11 @@ class HomeSettingsService
suspend fun saveToServer(
userId: UUID,
settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID,
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
) {
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = context.getString(R.string.app_name),
data = current.copy(customPrefs = customPrefs),
)
}
/**
@ -127,24 +119,15 @@ class HomeSettingsService
*/
suspend fun loadFromServer(
userId: UUID,
displayPreferencesId: String = DISPLAY_PREF_ID,
): HomePageSettings? {
val current = getDisplayPreferences(userId, displayPreferencesId)
return current.customPrefs[CUSTOM_PREF_ID]?.let {
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
): HomePageSettings? =
displayPreferencesService
.getDisplayPreferences(userId, displayPreferencesId)
.customPrefs[CUSTOM_PREF_ID]
?.let {
val jsonElement = jsonParser.parseToJsonElement(it)
decode(jsonElement)
}
}
private suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = context.getString(R.string.app_name),
).content
/**
* Computes the filename for locally saved [HomePageSettings]
@ -334,12 +317,12 @@ class HomeSettingsService
*/
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs =
api.displayPreferencesApi
displayPreferencesService
.getDisplayPreferences(
displayPreferencesId = "usersettings",
userId = userId,
client = "emby",
).content.customPrefs
).customPrefs
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
@ -604,6 +587,7 @@ class HomeSettingsService
title = context.getString(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
)
}
@ -622,6 +606,7 @@ class HomeSettingsService
title = context.getString(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
)
}
@ -651,6 +636,7 @@ class HomeSettingsService
nextUp,
),
viewOptions = row.viewOptions,
rowType = row,
)
}
@ -710,6 +696,7 @@ class HomeSettingsService
title,
genres,
viewOptions = row.viewOptions,
rowType = row,
)
}
@ -788,6 +775,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
rowType = row,
)
}
latest
@ -820,6 +808,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
rowType = row,
)
}
}
@ -848,6 +837,7 @@ class HomeSettingsService
name ?: context.getString(R.string.collection),
it,
row.viewOptions,
rowType = row,
)
}
}
@ -875,6 +865,7 @@ class HomeSettingsService
row.name,
it,
row.viewOptions,
rowType = row,
)
}
}
@ -925,6 +916,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
rowType = row,
)
}
}
@ -949,6 +941,7 @@ class HomeSettingsService
context.getString(R.string.active_recordings),
it,
row.viewOptions,
rowType = row,
)
}
}
@ -973,6 +966,7 @@ class HomeSettingsService
context.getString(R.string.live_tv),
it,
row.viewOptions,
rowType = row,
)
}
}
@ -990,6 +984,7 @@ class HomeSettingsService
context.getString(R.string.channels),
it,
row.viewOptions,
rowType = row,
)
}
}
@ -1012,12 +1007,14 @@ class HomeSettingsService
title,
suggestions.items,
row.viewOptions,
rowType = row,
)
} else if (suggestions is SuggestionsResource.Empty) {
Success(
title,
listOf(),
row.viewOptions,
rowType = row,
)
} else {
Error(
@ -1029,7 +1026,6 @@ class HomeSettingsService
}
companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings"
}
}

View file

@ -1,22 +1,32 @@
@file:UseSerializers(
UUIDSerializer::class,
LocalDateTimeSerializer::class,
)
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds
class LatestNextUpService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
private val displayPreferencesService: DisplayPreferencesService,
private val favoriteWatchManager: FavoriteWatchManager,
) {
/**
* Get resume (continue watching) items for a user
@ -80,6 +91,7 @@ class LatestNextUpService
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val removedSeries = getRemovedFromNextUp(userId)
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request =
@ -100,6 +112,23 @@ class LatestNextUpService
.content
.items
.map { BaseItem.from(it, api, useSeriesForPrimary) }
.filter {
val seriesId = it.data.seriesId
if (seriesId != null && seriesId in removedSeries) {
// User has previously removed the series
val lastPlayedDate = it.data.userData?.lastPlayedDate
if (lastPlayedDate != null) {
// If item played it after it was removed, should include it
lastPlayedDate > removedSeries[seriesId]
} else {
// If unknown last played, filter out
false
}
} else {
true
}
}
return nextUp
}
@ -140,4 +169,112 @@ class LatestNextUpService
Timber.v("buildCombined took %s", duration)
return@withContext result
}
/**
* Remove a series from next up
*/
suspend fun removeFromNextUp(
userId: UUID,
episode: BaseItem,
) {
favoriteWatchManager.setWatched(episode.id, false)
episode.data.seriesId?.let { seriesId ->
displayPreferencesService.updateDisplayPreferences(userId) {
val removedIds =
get(REMOVED_KEY)
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
.toMutableMap()
removedIds[seriesId] = LocalDateTime.now()
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(removedIds)),
)
}
}
}
/**
* Get when series were removed from next up
*/
suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> =
displayPreferencesService
.getDisplayPreferences(userId)
.customPrefs[REMOVED_KEY]
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
suspend fun allowSeriesRemovedFromNextUp(
userId: UUID,
seriesId: UUID,
) {
displayPreferencesService.updateDisplayPreferences(userId) {
val ids =
get(REMOVED_KEY)
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
.toMutableMap()
ids.remove(seriesId)
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(ids)),
)
}
}
/**
* Check if user has watched a series since removing it
*/
suspend fun updateRemovedFromNextUp(userId: UUID) {
val removed = getRemovedFromNextUp(userId)
val newRemoved = removed.toMutableMap()
var changed = false
removed.forEach { (seriesId, timestamp) ->
val item =
api.itemsApi
.getItems(
userId = userId,
parentId = seriesId,
recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
limit = 1,
).content.items
.firstOrNull()
if (item != null) {
val lastPlayed = item.userData?.lastPlayedDate
if (lastPlayed != null && lastPlayed > timestamp) {
Timber.v("Updating removed next up for series %s", seriesId)
newRemoved.remove(seriesId)
changed = true
}
} else {
// Series doesn't exist anymore
Timber.v("Updating removed next up for missing series %s", seriesId)
newRemoved.remove(seriesId)
changed = true
}
}
if (changed) {
displayPreferencesService.updateDisplayPreferences(userId) {
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(newRemoved)),
)
}
}
}
companion object {
const val REMOVED_KEY = "removeNextUp"
}
}
@Serializable
data class RemovedSeriesIds(
val value: Map<UUID, LocalDateTime>,
)

View file

@ -0,0 +1,139 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@HiltWorker
class LatestNextUpWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val api: ApiClient,
private val latestNextUpService: LatestNextUpService,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId =
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId =
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
latestNextUpService.updateRemovedFromNextUp(userId)
return Result.success()
} catch (ex: Exception) {
Timber.e(ex, "Error during updateRemovedFromNextUp")
return Result.retry()
}
}
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
}
}
@ActivityScoped
class LatestNextUpSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) {
private val activity =
(context as? AppCompatActivity)
?: throw IllegalStateException(
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init {
serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
}
}
}
}
private suspend fun scheduleWork(
userId: UUID,
serverId: UUID,
) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<LatestNextUpWorker>(
repeatInterval = 4.hours.toJavaDuration(),
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setConstraints(constraints)
.setInputData(
workDataOf(
LatestNextUpWorker.PARAM_USER_ID to userId.toString(),
LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(),
),
)
Timber.i("Scheduling periodic LatestNextUpWorker")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = LatestNextUpWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE,
request = periodicWorkRequestBuilder.build(),
)
}
}

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
/**
@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState {
override val title: String,
val items: List<BaseItem?>,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
val rowType: HomeRowConfig? = null,
) : HomeRowLoadingState
data class Error(

View file

@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object LocalDateSerializer : KSerializer<LocalDate> {
@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer<LocalDate> {
override fun deserialize(decoder: Decoder): LocalDate =
decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
}
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor)
override fun serialize(
encoder: Encoder,
value: LocalDateTime,
) {
encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value))
}
override fun deserialize(decoder: Decoder): LocalDateTime =
decoder
.decodeString()
.let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
}

View file

@ -749,6 +749,9 @@
<string name="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wholphin updated to %s</string>
<string name="remove_continue_watching">Remove from continue watching</string>
<string name="remove_next_up">Remove series from next up</string>
<string name="view_removed_next_up">View series removed from next up</string>
<string name="view_more">View more</string>
<string name="discover_tv">Discover TV Shows</string>
<string name="discover_movies">Discover Movies</string>

View file

@ -0,0 +1,187 @@
@file:UseSerializers(
UUIDSerializer::class,
DateTimeSerializer::class,
)
package com.github.damontecres.wholphin.services
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.DisplayPreferencesDto
import org.jellyfin.sdk.model.api.ScrollDirection
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserItemDataDto
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import java.time.LocalDateTime
import java.util.UUID
class LatestNextUpServiceTests {
private val testDispatcher = StandardTestDispatcher()
private val userId = UUID.randomUUID()
private val seriesId1 = UUID.randomUUID()
private val seriesId2 = UUID.randomUUID()
private val seriesId3 = UUID.randomUUID()
private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockTvShowsApi = mockk<TvShowsApi>()
private val mockDatePlayedService = mockk<DatePlayedService>()
private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>()
private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true)
private val latestNextUpService =
LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager)
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { mockApi.tvShowsApi } returns mockTvShowsApi
}
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `Test nothing is filtered out`() =
runTest {
coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns
buildRemoved()
val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
Assert.assertEquals(3, result.size)
val seriesIds = result.map { it.data.seriesId }
Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3)))
}
@Test
fun `Test seriesId1 is filtered out`() =
runTest {
coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1))
val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
Assert.assertEquals(2, result.size)
val seriesIds = result.map { it.data.seriesId }
Assert.assertTrue(seriesId1 !in seriesIds)
Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3)))
}
@Test
fun `Test seriesId2 is filtered out`() =
runTest {
coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1))
val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
Assert.assertEquals(2, result.size)
val seriesIds = result.map { it.data.seriesId }
Assert.assertTrue(seriesId2 !in seriesIds)
Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3)))
}
@Test
fun `Test seriesId1 and seriesId2 are filtered out`() =
runTest {
coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
buildRemoved(
seriesId1 to LocalDateTime.now().minusDays(1),
seriesId2 to LocalDateTime.now().minusDays(1),
)
val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
Assert.assertEquals(1, result.size)
val seriesIds = result.map { it.data.seriesId }
Assert.assertTrue(seriesId1 !in seriesIds)
Assert.assertTrue(seriesId2 !in seriesIds)
Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3)))
}
fun buildRemoved(vararg values: Pair<UUID, LocalDateTime>): DisplayPreferencesDto =
testDisplayPreferencesDto.copy(
customPrefs =
mutableMapOf<String, String?>().apply {
val str = Json.encodeToString(RemovedSeriesIds(values.toMap()))
put(LatestNextUpService.REMOVED_KEY, str)
},
)
private val testUserItemDataDto =
UserItemDataDto(
playbackPositionTicks = 0L,
playCount = 0,
isFavorite = false,
lastPlayedDate = null,
played = false,
key = "",
itemId = UUID.randomUUID(),
)
private val mockResponse: Response<BaseItemDtoQueryResult> =
Response<BaseItemDtoQueryResult>(
content =
BaseItemDtoQueryResult(
items =
listOf(
mockk<BaseItemDto>(relaxed = true) {
every { seriesId } returns seriesId1
every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7))
},
mockk<BaseItemDto>(relaxed = true) {
every { seriesId } returns seriesId2
every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null)
},
mockk<BaseItemDto>(relaxed = true) {
every { seriesId } returns seriesId3
every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7))
},
),
totalRecordCount = 3,
startIndex = 0,
),
status = 200,
headers = mapOf(),
)
}
val testDisplayPreferencesDto =
DisplayPreferencesDto(
id = "default",
viewType = null,
sortBy = null,
indexBy = null,
rememberIndexing = false,
primaryImageHeight = 0,
primaryImageWidth = 0,
customPrefs = mapOf(),
scrollDirection = ScrollDirection.VERTICAL,
showBackdrop = false,
rememberSorting = false,
sortOrder = SortOrder.ASCENDING,
showSidebar = false,
client = null,
)

View file

@ -1,13 +1,15 @@
package com.github.damontecres.wholphin.test
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.DisplayPreferencesService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.mockQueryResult
import com.github.damontecres.wholphin.services.testDisplayPreferencesDto
import io.mockk.CapturingSlot
import io.mockk.coEvery
import io.mockk.every
@ -33,15 +35,28 @@ class NextUpTest {
private val mockTvShowsApi = mockk<TvShowsApi>()
private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockContext = mockk<Context>()
private val mockDatePlayedService = mockk<DatePlayedService>()
private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>()
private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true)
private val latestNextUpService =
LatestNextUpService(mockContext, mockApi, mockDatePlayedService)
LatestNextUpService(
mockApi,
mockDatePlayedService,
mockDisplayPreferencesService,
mockFavoriteWatchManager,
)
@Before
fun setUp() {
every { mockApi.tvShowsApi } returns mockTvShowsApi
coEvery {
mockDisplayPreferencesService.getDisplayPreferences(
any(),
any(),
any(),
)
} returns testDisplayPreferencesDto
}
@Test

View file

@ -131,6 +131,7 @@ class TestHomeRowSamples {
latestNextUpService = mockk(),
imageUrlService = mockk(),
suggestionService = mockk(),
displayPreferencesService = mockk(),
)
val str = """{