From ce6461d23ba3ba52e915db2eb71b1427955a20b0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:11:09 -0500 Subject: [PATCH 1/3] Fixes for Jellyseerr login issues (#1031) ## Description Fixes some issues with Jellyseerr login. Also makes error message more clear by showing the URLs that were tested and the error that occurred. Also fixes an issue where you could not remove a Jellyseerr server if it was previously configured but can no longer connect. ### Related issues Fixes #1028 Should fix #1022 Related to #747 ### Testing Emulator, tested adding and removing valid & invalid IPs ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/SeerrServerDao.kt | 2 +- .../services/SeerrServerRepository.kt | 99 +++++++++-------- .../ui/preferences/PreferencesContent.kt | 1 + .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 16 ++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 13 ++- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 102 ++++++++++-------- .../damontecres/wholphin/test/TestSeerr.kt | 28 +++++ 7 files changed, 165 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt index 59f5d9ea..3a2c325c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -47,7 +47,7 @@ interface SeerrServerDao { suspend fun deleteUser( serverId: Int, jellyfinUserRowId: Int, - ) + ): Int suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 78ff7d3b..970e80c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer @@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject @@ -162,7 +165,7 @@ class SeerrServerRepository apiKey, okHttpClient .newBuilder() - .connectTimeout(5.seconds) + .connectTimeout(2.seconds) .readTimeout(6.seconds) .build(), ) @@ -170,10 +173,11 @@ class SeerrServerRepository return LoadingState.Success } - suspend fun removeServer() { - val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return - seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + suspend fun removeServerForCurrentUser(): Boolean { + val current = current.firstOrNull() ?: return false + val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) clear() + return rows > 0 } } @@ -266,47 +270,56 @@ class UserSwitchListener seerrServerRepository.clear() homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - // Check for home settings - launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set(server, seerrUser, userConfig) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server.url, ex) - } - } - } - } + switchUser(user) } } } } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server.url, ex) + } + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index d9f1fb4d..729e7085 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -554,6 +554,7 @@ fun PreferencesContent( currentUsername = currentUser?.name, status = status, onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 81e06fb8..5fa8c667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -66,15 +65,19 @@ fun AddSeerrServerApiKey( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) + val labelWidth = 90.dp Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = stringResource(R.string.url), - modifier = Modifier.padding(end = 8.dp), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = url, @@ -105,7 +108,10 @@ fun AddSeerrServerApiKey( ) { Text( text = stringResource(R.string.api_key), - modifier = Modifier.padding(end = 8.dp), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = apiKey, @@ -173,7 +179,7 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) val labelWidth = 90.dp Row( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 9566b88b..d84b98db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,12 +1,16 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog @@ -20,6 +24,7 @@ fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onResetStatus: () -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf(null) } @@ -34,6 +39,7 @@ fun AddSeerServerDialog( -> { BasicDialog( onDismissRequest = { authMethod = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( onSubmit = { url, username, password -> @@ -41,6 +47,7 @@ fun AddSeerServerDialog( }, username = currentUsername ?: "", status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -54,6 +61,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -61,7 +69,10 @@ fun AddSeerServerDialog( null -> { ChooseSeerrLoginType( onDismissRequest = onDismissRequest, - onChoose = { authMethod = it }, + onChoose = { + onResetStatus.invoke() + authMethod = it + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 4f93217a..0f62f12f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -50,63 +50,73 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } return@launchIO } - var result: LoadingState = LoadingState.Error("No url") + Timber.v("Urls to try: %s", urls) + val results = mutableMapOf() for (url in urls) { Timber.d("Trying %s", url) - result = - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in") - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - result = LoadingState.Error("Invalid credentials", ex) - break - } else { - LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception logging in") - LoadingState.Error(ex) - } - if (result is LoadingState.Success) { - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + results[url] = LoadingState.Success break + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in %s", url) + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + results[url] = LoadingState.Error("Invalid credentials", ex) + } else { + results[url] = LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "ClientException logging in %s", url) + results[url] = LoadingState.Error(ex) } } - if (result is LoadingState.Error) { - showToast(context, "Error: ${result.message}") + val result = results.filter { (url, state) -> state is LoadingState.Success } + if (result.isNotEmpty()) { + val url = result.keys.first() + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + } else { + val message = + results + .map { (url, state) -> + val s = state as? LoadingState.Error + "$url - ${s?.localizedMessage}" + }.joinToString("\n") + showToast(context, "Could not connect") + serverConnectionStatus.update { LoadingState.Error(message) } } - serverConnectionStatus.update { result } } } fun removeServer() { viewModelScope.launchIO { - seerrServerRepository.removeServer() + val result = seerrServerRepository.removeServerForCurrentUser() + if (!result) { + showToast(context, "Could not remove server") + } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt index 76893f8c..a4acf93f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -62,4 +62,32 @@ class TestSeerr { ) Assert.assertEquals(expected, urls) } + + @Test + fun testCreateUrls5() { + val urls = + createUrls("10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:443/api/v1", + "https://10.0.0.2/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls6() { + val urls = + createUrls("10.0.0.2:8080") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:8080/api/v1", + "https://10.0.0.2:8080/api/v1", + ) + Assert.assertEquals(expected, urls) + } } From 354577e2e89e6f950c2641f27eae120d62659e19 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:08 -0500 Subject: [PATCH 2/3] Delete media follow up (#1040) ## Description A follow up to #1014 to add the delete option in more context menus. Also improves the "reaction" logic which updates previous pages from deletions on later pages. ### Related issues Addresses https://github.com/damontecres/Wholphin/pull/1014#issuecomment-3987205355 Related to #104 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../services/MediaManagementService.kt | 14 +++- .../ui/components/CollectionFolderGrid.kt | 74 ++++++++++++++++--- .../wholphin/ui/components/PlayButtons.kt | 14 ++++ .../ui/components/RecommendedContent.kt | 39 +++++++++- .../ui/components/RecommendedMovie.kt | 3 + .../ui/components/RecommendedTvShow.kt | 3 + .../wholphin/ui/detail/DetailUtils.kt | 6 +- .../detail/discover/DiscoverMovieDetails.kt | 10 --- .../ui/detail/episode/EpisodeDetails.kt | 20 +++++ .../ui/detail/episode/EpisodeViewModel.kt | 13 ++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 6 ++ .../ui/detail/series/FocusedEpisodeFooter.kt | 4 + .../ui/detail/series/SeriesDetails.kt | 1 + .../ui/detail/series/SeriesOverview.kt | 2 + .../ui/detail/series/SeriesOverviewContent.kt | 4 + .../ui/detail/series/SeriesViewModel.kt | 23 ++++++ .../damontecres/wholphin/ui/main/HomePage.kt | 17 +++++ .../wholphin/ui/main/HomeViewModel.kt | 36 +++++++++ 18 files changed, 261 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index f3aff8c6..a13881a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,11 +41,16 @@ class MediaManagementService val deletedItemFlow: SharedFlow = _deletedItemFlow suspend fun canDelete(item: BaseItem): Boolean { + val appPreferences = userPreferencesService.getCurrent().appPreferences + return canDelete(item, appPreferences) + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean { Timber.v("canDelete %s: %s", item.id, item.canDelete) - val enabled = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.enableMediaManagement + val enabled = appPreferences.interfacePreferences.enableMediaManagement return enabled && item.canDelete && if (item.type == BaseItemKind.RECORDING) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 6eae4f26..8415c7a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -57,6 +57,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -65,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -77,6 +79,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -84,6 +87,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils @@ -100,6 +104,9 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -202,18 +209,34 @@ class CollectionFolderViewModel loading.setValueOnMain(DataLoadingState.Error(ex)) } } - viewModelScope.launchDefault { - mediaManagementService.deletedItemFlow.collect { deletedItem -> - val pager = - ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) - position.let { - Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id) - val item = pager?.get(it) - if (item?.id == deletedItem.item.id) { - pager.refreshPagesAfter(position) - } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) } } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", itemId) + showToast(context, "Error refreshing after item deleted") } } @@ -491,6 +514,22 @@ class CollectionFolderViewModel } } } + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } /** @@ -578,6 +617,7 @@ fun CollectionFolderGrid( var moreDialog by remember { mutableStateOf>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { @@ -713,6 +753,7 @@ fun CollectionFolderGrid( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -727,6 +768,9 @@ fun CollectionFolderGrid( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = PositionItem(position, item) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -751,6 +795,16 @@ fun CollectionFolderGrid( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 570d411e..75a3147c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -72,12 +72,14 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, trailers: List?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, + deleteOnClick: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -158,6 +160,16 @@ fun ExpandablePlayButtons( ) } } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } // More button item("more") { @@ -424,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() { buttonOnFocusChanged = {}, trailers = listOf(), trailerOnClick = {}, + canDelete = true, + deleteOnClick = {}, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 908574e3..c3d1a4b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn @@ -31,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -51,6 +55,7 @@ abstract class RecommendedViewModel( val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { abstract fun init() @@ -117,6 +122,25 @@ abstract class RecommendedViewModel( } update(title, row) } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } @Composable @@ -130,6 +154,7 @@ fun RecommendedContent( val context = LocalContext.current var moreDialog by remember { mutableStateOf>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { @@ -196,6 +221,7 @@ fun RecommendedContent( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigationManager.navigateTo(it) }, @@ -210,6 +236,7 @@ fun RecommendedContent( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -234,9 +261,19 @@ fun RecommendedContent( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } -private data class RowColumnItem( +data class RowColumnItem( val position: RowColumn, val item: BaseItem, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 403ff5fa..392c77a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -63,12 +64,14 @@ class RecommendedMovieViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 3258930c..db7f7b4f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -67,12 +68,14 @@ class RecommendedTvShowViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 645ba2ce..f34c0228 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -29,7 +29,7 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, - val onClickDelete: (BaseItem) -> Unit = {}, + val onClickDelete: (BaseItem) -> Unit, ) enum class ClearChosenStreams { @@ -63,7 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - canDelete: Boolean = false, + canDelete: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -236,7 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, - canDelete: Boolean = false, + canDelete: Boolean, actions: MoreDialogActions, ): List = buildList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 0c7d08b9..a366e788 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -102,15 +101,6 @@ fun DiscoverMovieDetails( val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> }, - onClickFavorite = { itemId, favorite -> }, - onClickAddPlaylist = { itemId -> }, - onSendMediaInfo = {}, - ) - when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 63d5ee5d..ee3cc4a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -82,6 +83,7 @@ fun EpisodeDetails( var moreDialog by remember { mutableStateOf(null) } var chooseVersion by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val moreActions = @@ -98,6 +100,7 @@ fun EpisodeDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -215,6 +218,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, + canDelete = viewModel.canDelete, ), ) }, @@ -224,6 +228,8 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) } @@ -276,6 +282,16 @@ fun EpisodeDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -290,6 +306,8 @@ fun EpisodeDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -347,6 +365,8 @@ fun EpisodeDetailsContent( }, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 7808141e..9b8bda5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -54,6 +56,7 @@ class EpisodeViewModel private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -65,6 +68,9 @@ class EpisodeViewModel val item = MutableLiveData(null) val chosenStreams = MutableLiveData(null) + var canDelete: Boolean = false + private set + init { init() } @@ -95,6 +101,7 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() + canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item @@ -199,4 +206,10 @@ class EpisodeViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index ada15158..a59ecde1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -315,6 +315,8 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) } @@ -410,6 +412,8 @@ fun MovieDetailsContent( onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -472,6 +476,8 @@ fun MovieDetailsContent( position = TRAILER_ROW trailerOnClick.invoke(it) }, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 9b6d81df..2aeac04e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -25,6 +25,8 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -47,6 +49,8 @@ fun FocusedEpisodeFooter( buttonOnFocusChanged = buttonOnFocusChanged, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 222f7f77..81beb82f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -584,6 +584,7 @@ fun SeriesDetailsContent( watched = item.played, favorite = item.favorite, actions = moreActions, + canDelete = false, ) moreDialog = DialogParams( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 79731e47..3ed615e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -383,6 +383,8 @@ fun SeriesOverview( ), ) }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + deleteOnClick = { showDeleteDialog = it }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index a0d120e1..8acc32f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -88,6 +88,8 @@ fun SeriesOverviewContent( moreOnClick: () -> Unit, overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, + canDelete: (BaseItem) -> Boolean, + deleteOnClick: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -294,6 +296,8 @@ fun SeriesOverviewContent( } } }, + canDelete = canDelete.invoke(ep), + deleteOnClick = { deleteOnClick.invoke(ep) }, modifier = Modifier .padding(top = 4.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 0e1b326b..53924431 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -56,6 +57,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -229,6 +233,20 @@ class SeriesViewModel discovered.update { results } } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + if (deletedItem.item.data.seriesId == seriesId) { + Timber.d( + "Item %s deleted from series %s", + deletedItem.item.id, + seriesId, + ) + val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) } } @@ -614,6 +632,11 @@ class SeriesViewModel } suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cf8738c5..3a7a457a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.RowColumnItem import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -116,6 +118,7 @@ fun HomePage( LoadingState.Success -> { var dialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var position by rememberPosition() HomePageContent( @@ -136,6 +139,7 @@ fun HomePage( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -150,6 +154,9 @@ fun HomePage( showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = RowColumnItem(position, item) + }, ), ) dialog = @@ -190,6 +197,16 @@ fun HomePage( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index a8ce7fd9..53417863 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService 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.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -52,6 +57,7 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow = _state @@ -189,6 +195,36 @@ class HomeViewModel backdropService.submit(item) } } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.homeRows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + _state.update { + val newRow = + row.items.toMutableList().apply { + removeAt(position.column) + } + it.copy( + homeRows = + it.homeRows.toMutableList().apply { + set(position.row, row.copy(items = newRow)) + }, + ) + } + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } data class HomeState( From 014bed1bf3cf89f7cadba4d6b051a1723aa8178a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:14 -0500 Subject: [PATCH 3/3] Better logic for choosing the display mode (#1039) ## Description This PR improves the logic used to choose a display mode when refresh rate and resolution switching are enabled. As before, matching the refresh rate is prioritized over picking the resolution. For example, with the display modes of 1080@30 & 720@60 for a 720@30 video, 1080@30 is used because the frame rate exactly matches. ### Related issues Fixes #1036 ### Testing Unit tests, added test cases for more situations ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/RefreshRateService.kt | 39 ++++- .../wholphin/test/TestDisplayModeChoice.kt | 164 +++++++++++++++++- 2 files changed, 188 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 2ca3d1b1..06d116a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -158,33 +158,58 @@ class RefreshRateService if (refreshRateSwitch) { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - .filter { - it.refreshRateRounded % streamRate == 0 || // Exact multiple - it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps - } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } } else { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } } // Timber.v("display modes candidates: %s", candidates.joinToString("\n")) return if (!resolutionSwitch) { - candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } else { + // Exact resolution & frame rate candidates.firstOrNull { it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate } - ?: candidates.firstOrNull { - it.physicalWidth == streamWidth && + // Next highest resolution, exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate } + // Exact resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Next highest resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && + it.physicalHeight >= streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Fall back to largest resolution, exact frame rate ?: candidates .filter { it.refreshRateRounded == streamRate } .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Finally, just the highest resolution + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } } + + fun frameRateMatches( + refreshRateRounded: Int, + streamRate: Int, + ): Boolean { + return refreshRateRounded % streamRate == 0 || // Exact multiple + refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt index 7af1bfce..d7e4abfd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -15,7 +15,25 @@ class TestDisplayModeChoice { val UHD_30 = DisplayMode(4, 3840, 2160, 30f) val UHD_24 = DisplayMode(5, 3840, 2160, 24f) - val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + val HD720_60 = DisplayMode(6, 1280, 720, 60f) + val HD720_30 = DisplayMode(7, 1280, 720, 30f) + val HD720_24 = DisplayMode(8, 1280, 720, 24f) + + val ALL_MODES = + listOf( + UHD_24, + UHD_30, + UHD_60, + HD_24, + HD_30, + HD_60, + HD720_60, + HD720_30, + HD720_24, + ).sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) } @Test @@ -32,7 +50,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(3, result?.modeId) + Assert.assertEquals(UHD_60.modeId, result?.modeId) } @Test @@ -49,7 +67,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = true, ) - Assert.assertEquals(0, result?.modeId) + Assert.assertEquals(HD_60.modeId, result?.modeId) } @Test @@ -66,7 +84,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(4, result?.modeId) + Assert.assertEquals(UHD_30.modeId, result?.modeId) } @Test @@ -83,14 +101,14 @@ class TestDisplayModeChoice { refreshRateSwitch = false, resolutionSwitch = true, ) - Assert.assertEquals(1, result?.modeId) + Assert.assertEquals(HD_30.modeId, result?.modeId) } @Test fun testFraction() { val streamWidth = 1920 val streamHeight = 1080 - val streamRealFrameRate = 30f + val streamRealFrameRate = 29.970f val displayModes = listOf( @@ -104,7 +122,7 @@ class TestDisplayModeChoice { displayModes = displayModes, streamWidth = streamWidth, streamHeight = streamHeight, - targetFrameRate = 29.970f, + targetFrameRate = streamRealFrameRate, refreshRateSwitch = true, resolutionSwitch = false, ) @@ -119,6 +137,136 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(1, result2?.modeId) + Assert.assertEquals(HD_30.modeId, result2?.modeId) + } + + private fun test( + expected: DisplayMode, + streamWidth: Int, + streamHeight: Int, + streamRealFrameRate: Float, + ) { + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals( + "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", + expected.modeId, + result?.modeId, + ) + } + + @Test + fun `Test choose best resolution`() { + test(HD720_30, 1280, 720, 30f) + test(HD720_30, 1280, 548, 30f) + test(HD720_30, 640, 480, 30f) + test(HD720_30, 960, 720, 30f) + test(HD720_24, 960, 720, 24f) + } + + @Test + fun `Test 60fps for 24 without resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + UHD_60, + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(UHD_60.modeId, result?.modeId) + } + + @Test + fun `Test 60fps for 24 with resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_60.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 24f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_24, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_24.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution2`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 30f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_30, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_30.modeId, result?.modeId) } }