mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
ade564d907
commit
4eabaa9a52
19 changed files with 964 additions and 49 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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),
|
||||
)
|
||||
displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
|
||||
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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 {
|
||||
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
|
||||
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||
): HomePageSettings? =
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(userId, displayPreferencesId)
|
||||
.customPrefs[CUSTOM_PREF_ID]
|
||||
?.let {
|
||||
val jsonElement = jsonParser.parseToJsonElement(it)
|
||||
decode(jsonElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ class TestHomeRowSamples {
|
|||
latestNextUpService = mockk(),
|
||||
imageUrlService = mockk(),
|
||||
suggestionService = mockk(),
|
||||
displayPreferencesService = mockk(),
|
||||
)
|
||||
|
||||
val str = """{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue