From cc052453a4ab1a1b2ae91570df831ef61e486862 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:30:16 -0500 Subject: [PATCH 001/118] Add basic music video support (#948) ## Description Just enables showing and playing music videos as if they were regular videos. Basic metadata is shown as well. Navigation isn't great because a music video library goes by folders which don't necessary have the best UI choices. ### Related issues Related to #267 ### Testing Checked playback on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../github/damontecres/wholphin/ui/nav/DestinationContent.kt | 4 +++- .../java/com/github/damontecres/wholphin/util/Constants.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index eccfd6de..0888e721 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -121,7 +121,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index da246f73..fa0476cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -9,6 +9,7 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -47,6 +48,7 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, From e8c9b4e208feae611672acfee0d906996078d70c Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:27:05 +1100 Subject: [PATCH 002/118] SeriesOverview Alignment (#930) ## Description This PR changes the padding on the SeriesOverview to align it with the updated layout on the Home, MovieDetails and SeriesDetails pages. There is also a small change to fix an incorrect spacing on the SeriesDetails, and start padding added to the ItemRow to bring it into alignment with other text elements on the page and ensure consistent spacing from the navdrawer ### Related issues I believe this is the final PR to close out [704](https://github.com/damontecres/Wholphin/discussions/704) ### Testing Tested changes on both the emulator and physical tv ## Screenshots Home screen showing the ItemRow alignment home SeriesOverview Screenshot_20260221_200450 ## AI or LLM usage AI was used to identify the current padding and spacing values --- .../com/github/damontecres/wholphin/ui/cards/ItemRow.kt | 3 ++- .../wholphin/ui/detail/series/FocusedEpisodeHeader.kt | 7 ++++--- .../wholphin/ui/detail/series/SeriesDetails.kt | 2 +- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 9 +++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 7ebf0faf..314a7e1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,7 +58,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index ff0ddade..aab6e056 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusState @@ -31,17 +32,17 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier) + EpisodeName(dto, modifier = Modifier.padding(start = 8.dp)) ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime) + QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp)) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } OverviewText( 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 f0c74be3..579a3866 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 @@ -621,7 +621,7 @@ fun SeriesDetailsHeader( ) { QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( 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 86e2b398..114454c1 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 @@ -134,7 +134,7 @@ fun SeriesOverviewContent( .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() @@ -159,9 +159,10 @@ fun SeriesOverviewContent( Modifier .focusRequester(tabRowFocusRequester) .padding(paddingValues) + .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier) + SeriesName(series.name, Modifier.padding(start = 8.dp)) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, @@ -294,8 +295,8 @@ fun SeriesOverviewContent( }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } From a51d9e9c65ec1f93674c3e0b085135e5d4018b91 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:18:18 -0500 Subject: [PATCH 003/118] Fix clicking genre cards on home page (#953) ## Description Fixes clicking on a genre card added to the home page ### Related issues Fixes #952 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 28 ++++++++++++++++ .../wholphin/services/HomeSettingsService.kt | 33 ++++++++++++++----- .../wholphin/ui/components/GenreCardGrid.kt | 27 ++++----------- .../wholphin/ui/playback/PlaybackConstants.kt | 18 ++++++++++ 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 172c6ef5..950e9b40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale +import java.util.UUID import kotlin.time.Duration @Serializable @@ -33,6 +34,7 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, val imageUrlOverride: String? = null, + val destinationOverride: Destination? = null, ) : CardGridItem { val id get() = data.id @@ -166,6 +168,7 @@ data class BaseItem( }?.toIntOrNull() fun destination(index: Int? = null): Destination { + if (destinationOverride != null) return destinationOverride val result = // Redirect episodes & seasons to their series if possible when (type) { @@ -234,3 +237,28 @@ data class BaseItemUi( val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) + +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 93dc32d2..2fe5a6cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions +import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -654,18 +656,33 @@ class HomeSettingsService includeItemTypes = null, cardWidthPx = null, ) - val genres = - items.map { - BaseItem(it, false, genreImages[it.id]) - } - - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name + val title = - name?.let { context.getString(R.string.genres_in, it) } + library?.name?.let { context.getString(R.string.genres_in, it) } ?: context.getString(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } Success( title, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 84f36f02..e64f1824 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -21,8 +21,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.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -30,7 +29,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -256,23 +254,12 @@ fun GenreCardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( - Destination.FilteredCollection( - itemId = itemId, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genre.name, - item?.title, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genre.id), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..fa56bb84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") @@ -81,3 +82,20 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } + +fun getTypeFor(collectionType: CollectionType): BaseItemKind? = + when (collectionType) { + CollectionType.UNKNOWN -> null + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + CollectionType.MUSIC -> BaseItemKind.AUDIO + CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO + CollectionType.TRAILERS -> BaseItemKind.TRAILER + CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO + CollectionType.BOXSETS -> BaseItemKind.BOX_SET + CollectionType.BOOKS -> BaseItemKind.BOOK + CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM + CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL + CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST + CollectionType.FOLDERS -> BaseItemKind.FOLDER + } From a44fad890f13285a4618397da5523162166f91fd Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:56:22 -0500 Subject: [PATCH 004/118] Add preset for home page episode thumbnails (#954) ## Description Adds a preset for "Episode thumbnails" which shows episodes using their Primary image (ie a thumbnail or screenshot of the episode). The previous "Thumbnails" preset is renamed "Series Thumbs" because it uses the episode's Series Thumb image. Also fixes a bug where toggling "Use series" might not update the home page preview automatically. ### Related issues Fixes #940 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/cards/BannerCard.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 2 + .../ui/main/settings/HomeRowPresets.kt | 69 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 ++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 294df8f6..51d003fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -68,6 +68,7 @@ fun BannerCard( interactionSource: MutableInteractionSource? = null, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = true, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -82,7 +83,7 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight, imageType) { + remember(item, fillHeight, imageType, useSeriesForPrimary) { if (item != null) { item.imageUrlOverride ?: imageUrlService.getItemImageUrl( @@ -90,6 +91,7 @@ fun BannerCard( imageType, fillWidth = null, fillHeight = fillHeight, + useSeriesForPrimary = useSeriesForPrimary, ) } else { null @@ -208,6 +210,7 @@ fun BannerCardWithTitle( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) @@ -234,6 +237,7 @@ fun BannerCardWithTitle( interactionSource = interactionSource, imageType = imageType, imageContentScale = imageContentScale, + useSeriesForPrimary = useSeriesForPrimary, ) Column( verticalArrangement = Arrangement.spacedBy(0.dp), 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 b9a7cbd7..cf8738c5 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 @@ -524,6 +524,7 @@ fun HomePageCardContent( onLongClick = onLongClick, modifier = modifier, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } else { BannerCard( @@ -543,6 +544,7 @@ fun HomePageCardContent( modifier = modifier, interactionSource = null, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index ff4cfe0a..815c343a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -123,7 +123,7 @@ data class HomeRowPresets( ) } - val Thumbnails by lazy { + val SeriesThumbs by lazy { val height = 148 val epHeight = 100 HomeRowPresets( @@ -132,6 +132,7 @@ data class HomeRowPresets( heightDp = epHeight, imageType = ViewOptionImageType.THUMB, aspectRatio = AspectRatio.WIDE, + useSeries = true, episodeImageType = ViewOptionImageType.THUMB, episodeAspectRatio = AspectRatio.WIDE, ), @@ -164,6 +165,50 @@ data class HomeRowPresets( genreSize = epHeight, ) } + + val EpisodeThumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + showTitles = true, + useSeries = false, + episodeImageType = ViewOptionImageType.PRIMARY, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } } } @@ -173,13 +218,13 @@ fun HomeRowPresetsContent( modifier: Modifier = Modifier, ) { val presets = - remember { - listOf( - "Wholphin Default", - "Wholphin Compact", - "Thumbnails", - ) - } + listOf( + stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, + stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, + stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, + stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, + ) + val focusRequesters = remember { List(presets.size) { FocusRequester() } } LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { @@ -192,16 +237,12 @@ fun HomeRowPresetsContent( .fillMaxHeight() .focusRestorer(focusRequesters[0]), ) { - itemsIndexed(presets) { index, title -> + itemsIndexed(presets) { index, (title, preset) -> HomeSettingsListItem( selected = false, headlineText = title, onClick = { - when (index) { - 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) - 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) - 2 -> onApply.invoke(HomeRowPresets.Thumbnails) - } + onApply.invoke(preset) }, modifier = Modifier.focusRequester(focusRequesters[index]), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f3db4f..3baee433 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,6 +522,10 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Wholphin Default + Wholphin Compact + Series Thumb images + Episode Thumbnail images Disabled From 2a371e7d6bb3d3021459c1585bdb00915b368d5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:17:57 -0500 Subject: [PATCH 005/118] Fix strings missing from translations (#959) ## Description Weblate's Android string parser (aka https://translate.codeberg.org/projects/wholphin/) doesn't support direct translations of string arrays. Many of Wholphin's preference options are defined in string arrays and therefore could not be translated. So this PR implements the [suggested workaround](https://docs.weblate.org/en/latest/formats/android.html) to move the string into individual entries and reference them in arrays. Also moves some hardcoded strings into `strings.xml` so they can be translated. Note: virtually all error messages are still hardcoded, but they are only for exceptional cases, so it's probably not a huge problem. A minor bug where the backdrop would show briefly after switching users is fixed. ### Related issues Fixes #957 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 6 + .../damontecres/wholphin/ui/Formatting.kt | 3 +- .../ui/components/CollectionFolderGrid.kt | 2 +- .../ui/playback/DownloadSubtitlesDialog.kt | 6 +- .../wholphin/ui/playback/PlaybackConstants.kt | 13 +- .../wholphin/ui/playback/PlaybackDialog.kt | 6 +- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 6 +- .../ui/setup/seerr/AddSeerrServerDialog.kt | 41 ++--- app/src/main/res/values/strings.xml | 166 ++++++++++++------ 9 files changed, 156 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 34bd871c..b7f4b1d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -123,6 +123,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() { } is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } val current = key.current ProvideLocalClock { if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 1842620a..8f4066bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber @@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String? append(productionYear.toString()) if (status == "Continuing") { append(" - ") - append("Present") + append(WholphinApplication.instance.getString(R.string.series_continueing)) } else if (status == "Ended") { endDate?.let { if (it.year != productionYear) { 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 4c672998..9110802d 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 @@ -857,7 +857,7 @@ fun CollectionFolderGridContent( DataLoadingState.Loading, -> { // This shouldn't happen, so just show placeholder - Text("Loading") + Text(stringResource(R.string.loading)) } is DataLoadingState.Error -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index b3813ed5..e1a291e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -102,7 +102,7 @@ fun DownloadSubtitlesContent( .padding(PaddingValues(24.dp)), ) { Text( - text = "Search & download subtitles", + text = stringResource(R.string.search_and_download_subtitles), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -113,7 +113,7 @@ fun DownloadSubtitlesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Language", + text = stringResource(R.string.language), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -137,7 +137,7 @@ fun DownloadSubtitlesContent( } if (dialogItems.isEmpty()) { Text( - text = "No remote subtitles were found", + text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index fa56bb84..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -9,13 +10,13 @@ val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.7 val playbackScaleOptions = mapOf( - ContentScale.Fit to "Fit", - ContentScale.None to "None", - ContentScale.Crop to "Crop", + ContentScale.Fit to R.string.content_scale_fit, + ContentScale.None to R.string.none, + ContentScale.Crop to R.string.content_scale_crop, // ContentScale.Inside to "Inside", - ContentScale.FillBounds to "Fill", - ContentScale.FillWidth to "Fill Width", - ContentScale.FillHeight to "Fill Height", + ContentScale.FillBounds to R.string.content_scale_fill, + ContentScale.FillWidth to R.string.content_scale_fill_width, + ContentScale.FillHeight to R.string.content_scale_fill_height, ) val PrefContentScale.scale: ContentScale diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 04ff620a..c6d7b622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -144,7 +144,9 @@ fun PlaybackDialog( BottomDialogItem( data = PlaybackDialogType.VIDEO_SCALE, headline = stringResource(R.string.video_scale), - supporting = playbackScaleOptions[settings.contentScale], + supporting = + playbackScaleOptions[settings.contentScale] + ?.let { stringResource(it) }, ), ) } @@ -220,7 +222,7 @@ fun PlaybackDialog( playbackScaleOptions.map { (scale, name) -> BottomDialogItem( data = scale, - headline = name, + headline = stringResource(name), supporting = null, ) } 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 d2b48c35..81e06fb8 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 @@ -62,7 +62,7 @@ fun AddSeerrServerApiKey( val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( - text = "Enter URL & API Key", + text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, @@ -73,7 +73,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", + text = stringResource(R.string.url), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( @@ -104,7 +104,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", + text = stringResource(R.string.api_key), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( 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 3601fdd7..9566b88b 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 @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.DialogItem @@ -71,27 +73,26 @@ fun ChooseSeerrLoginType( onChoose: (SeerrAuthMethod) -> Unit, ) { val params = - remember { - DialogParams( - fromLongClick = false, - title = "Login to Seerr server", - items = - listOf( - DialogItem( - text = "API Key", - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - DialogItem( - text = "Jellyfin user", - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = "Local user", - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, ), - ) - } + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = stringResource(R.string.seerr_local_user), + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3baee433..2967ada1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,109 +522,161 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Fit + Crop + Fill + Fill width + Fill height Wholphin Default Wholphin Compact Series Thumb images Episode Thumbnail images + Lowest + Low + Medium + High + Full - Disabled - Lowest - Low - Medium - High - Full + @string/disabled + @string/volume_lowest + @string/volume_low + @string/volume_medium + @string/volume_high + @string/volume_full + Purple + Orange + Bold Blue + Black - Purple - Blue - Green - Orange - Bold Blue - Black + @string/purple + @string/blue + @string/green + @string/orange + @string/bold_blue + @string/black + Ignore + Skip automatically + Ask to skip - Ignore - Skip automatically - Ask to skip + @string/skip_ignore + @string/skip_automatically + @string/skip_ask - Fit - None - Crop - Fill - Fill Width - Fill Height + @string/content_scale_fit + @string/none + @string/content_scale_crop + @string/content_scale_fill + @string/content_scale_fill_width + @string/content_scale_fill_height + Only use FFmpeg if no built-in decoder exists + Prefer to use FFmpeg over built-in decoders + Never use FFmpeg decoders - Only use FFmpeg if no built-in decoder exists - Prefer to use FFmpeg over built-in decoders - Never use FFmpeg decoders + @string/ffmpeg_fallback + @string/ffmpeg_prefer + @string/ffmpeg_never + At the end of playback + During end credits/outro - At the end of playback - During end credits/outro + @string/next_up_playback_end + @string/next_up_outro + White + Light gray + Dark gray + Yellow + Cyan + Magenta - White - Black - Light Gray - Dark Gray - Red - Yellow - Green - Cyan - Blue - Magenta + @string/white + @string/black + @string/light_gray + @string/dark_gray + @string/red + @string/yellow + @string/green + @string/cyan + @string/blue + @string/magenta + Outline + Shadow - None - Outline - Shadow + @string/none + @string/subtitle_edge_outline + @string/subtitle_edge_shadow + Wrap + Boxed - None - Wrap - Boxed + @string/none + @string/background_style_wrap + @string/background_style_boxed + ExoPlayer + MPV + Prefer MPV - ExoPlayer - MPV - Prefer MPV + @string/exoplayer + @string/mpv + @string/prefer_mpv + Use ExoPlayer for HDR playback - - - Use ExoPlayer for HDR playback + + + @string/player_backend_options_subtitles_prefer_mpv + Poster (2:3) + 16:9 + 4:3 + Square (1:1) - Poster (2:3) - 16:9 - 4:3 - Square (1:1) + @string/aspect_ratios_poster + @string/aspect_ratios_16_9 + @string/aspect_ratios_4_3 + @string/aspect_ratios_square + Primary + Thumbary - Primary - Thumb + @string/image_type_primary + @string/image_type_thumb + Image with dynamic color + Image only + + API Key + Login to Seerr server + Jellyfin user + Local user + + No remote subtitles were found + Present - Image with dynamic color - Image only - None + @string/backdrop_style_dynamic + @string/backdrop_style_image + @string/none From e1c66dfb1d8dc7644afa091b9881eba697460fa7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 23 Feb 2026 12:34:46 -0500 Subject: [PATCH 006/118] Fix series overview episode images --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 1 + 1 file changed, 1 insertion(+) 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 114454c1..a0d120e1 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 @@ -267,6 +267,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } From 3b162a2f98f6b53023fdfad84bdacc63504db217 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:38:02 -0500 Subject: [PATCH 007/118] Play theme songs for collections (#962) ## Description Play theme songs for collections if available ### Related issues Closes #711 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 105 ++++++++++++------ .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 73 insertions(+), 36 deletions(-) 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 9110802d..c5dc912d 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -60,6 +61,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -120,7 +123,9 @@ class CollectionFolderViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -157,9 +162,10 @@ class CollectionFolderViewModel viewModelScope.launchIO { super.itemId = itemId try { - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } val libraryDisplayInfo = serverRepository.currentUser.value?.let { user -> @@ -184,6 +190,8 @@ class CollectionFolderViewModel } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") loading.setValueOnMain(DataLoadingState.Error(ex)) @@ -254,34 +262,32 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) } } } @@ -443,6 +449,30 @@ class CollectionFolderViewModel backdropService.submit(item) } } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + item.value?.let { + Timber.v("onResumePage: %s", loading.value!!::class) + if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + val volume = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(it.id, volume) + } + } + } + } } /** @@ -548,6 +578,13 @@ fun CollectionFolderGrid( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Box(modifier = modifier) { + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } CollectionFolderGridContent( preferences = preferences, initialPosition = viewModel.position, @@ -598,7 +635,7 @@ fun CollectionFolderGrid( } else { Destination.Playback(item) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { @@ -622,7 +659,7 @@ fun CollectionFolderGrid( filter = filter, ) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) } }, ) @@ -660,7 +697,7 @@ fun CollectionFolderGrid( favorite = item.favorite, actions = MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, + navigateTo = { viewModel.navigateTo(it) }, onClickWatch = { itemId, watched -> viewModel.setWatched(position, itemId, watched) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index fd4d46d3..4ddc41c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum( } else { item.destination(index) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, itemId = itemId.toServerString(), initialFilter = filter, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2967ada1..93476b74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -656,7 +656,7 @@ Primary - Thumbary + Thumb @string/image_type_primary @string/image_type_thumb From 348a3022d6420a8ed4b821d9bdd07a17c4298607 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:20:25 -0500 Subject: [PATCH 008/118] Fix cut off cards on grids with show details enabled (#963) ## Description Fixes the previous row of cards showing the bottom cut off when the "Show details" view option is enabled. ### Related issues Fixes #404 Fixes #877 Related to #882 ### Testing Emulator ## Screenshots ![grid_show_details Large](https://github.com/user-attachments/assets/3376275e-261e-4e5a-b874-18be7606a9bf) ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 20 ++- .../wholphin/ui/detail/CardGrid.kt | 166 +++++++++--------- 2 files changed, 104 insertions(+), 82 deletions(-) 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 c5dc912d..4a71404a 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 @@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -84,6 +87,7 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -735,6 +739,7 @@ fun CollectionFolderGrid( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CollectionFolderGridContent( preferences: UserPreferences, @@ -879,14 +884,16 @@ fun CollectionFolderGridContent( } } } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { HomePageHeader( item = focusedItem, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .padding(16.dp), + .height(200.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp), ) } when (val state = loadingState) { @@ -932,6 +939,15 @@ fun CollectionFolderGridContent( }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, + bringIntoViewSpec = + remember(viewOptions) { + val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } + if (viewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, ) AnimatedVisibility(showViewOptions) { ViewOptionsDialog( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 8893246c..ac72a144 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -112,6 +115,7 @@ fun CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position // focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition } - } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } } From d483ebf7354f800da2a053417ad7acf62f3f9918 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:31 -0500 Subject: [PATCH 009/118] Update Gradle to v9.3.1 (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | [gradle](https://gradle.org) ([source](https://redirect.github.com/gradle/gradle)) | minor | `9.2.1` → `9.3.1` | --- ### Release Notes
gradle/gradle (gradle) ### [`v9.3.1`](https://redirect.github.com/gradle/gradle/releases/tag/v9.3.1): 9.3.1 [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.3.0...v9.3.1) This is a patch release for 9.3.0. We recommend using 9.3.1 instead of 9.3.0. The following issues were resolved: - [Cannot find testcases from Android Screenshot Test plugin since Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36320) - [Excluding dependencies from included builds doesn't work in Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36331) - [ExternalDependency and DependencyConstraint cannot be passed to DependencyResolveDetails#useTarget](https://redirect.github.com/gradle/gradle/issues/36359) - [Gradle 9.3.0 generate JUnit test result files with wrong name](https://redirect.github.com/gradle/gradle/issues/36379) - [Build cache cannot handle outputs with non-BMP characters in the filename](https://redirect.github.com/gradle/gradle/issues/36387) - [Emojis in test names should not break build caching](https://redirect.github.com/gradle/gradle/issues/36395) - [Non utf-8 c code is no longer buildable](https://redirect.github.com/gradle/gradle/issues/36399) - [Breaking change in 9.3.0 regarding cross-project dependency manipulation](https://redirect.github.com/gradle/gradle/issues/36428) - [JUnit3 tests cannot be run with Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36451) - [Test.setScanForTestClasses(false) causes all junit4 tests to be skipped](https://redirect.github.com/gradle/gradle/issues/36508) [Read the Release Notes](https://docs.gradle.org/9.3.1/release-notes.html) #### Upgrade instructions Switch your build to use Gradle 9.3.1 by updating your wrapper: ``` ./gradlew wrapper --gradle-version=9.3.1 && ./gradlew wrapper ``` See the Gradle [9.x upgrade guide](https://docs.gradle.org/9.3.1/userguide/upgrading_version_9.html) to learn about deprecations, breaking changes and other considerations when upgrading. For Java, Groovy, Kotlin and Android compatibility, see the [full compatibility notes](https://docs.gradle.org/9.3.1/userguide/compatibility.html). #### Reporting problems If you find a problem with this release, please file a bug on [GitHub Issues](https://redirect.github.com/gradle/gradle/issues) adhering to our issue guidelines. If you're not sure you're encountering a bug, please use the [forum](https://discuss.gradle.org/c/help-discuss). We hope you will build happiness with Gradle, and we look forward to your feedback via [Twitter](https://twitter.com/gradle) or on [GitHub](https://redirect.github.com/gradle). ### [`v9.3.0`](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0) [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +---- gradlew.bat | 3 +-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 37988 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 xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍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 delta 35498 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 Date: Mon, 23 Feb 2026 18:00:42 -0500 Subject: [PATCH 010/118] Update Dependencies to v2.59.2 (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger:hilt-android-compiler](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android-compiler/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android-compiler/2.59.1/2.59.2?slim=true) | | [com.google.dagger:hilt-android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bf860b..2c2234f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.59.1" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" From 790bb4b535fa2216d459d8dc75d9013bc6611957 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:53 -0500 Subject: [PATCH 011/118] Update dependency com.google.dagger.hilt.android to v2.59.2 (#937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger.hilt.android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From 3e2a1869ab3d84f6d1e6d31e5210fbba0bf9a5c0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:52:38 -0500 Subject: [PATCH 012/118] Scroll to current chapter on playback overlay (#964) ## Description This PR makes it so when you open the playback overlay and move down to the "Chapters" row, the first focused chapter will be the current one instead of always the first one. ### Related issues Closes #695 Closes #951 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Chapter.kt | 3 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 1bcacb3f..3444ac54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -31,6 +31,7 @@ data class Chapter( ) }, ) - }.orEmpty() + }?.sortedBy { it.position } + .orEmpty() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2b311e71..b1500b60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged @@ -254,8 +258,34 @@ fun PlaybackOverlay( exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val chapterIndex = + remember { + val position = playerControls.currentPosition.milliseconds + val index = + chapters + .indexOfFirst { it.position > position } + .minus(1) + .let { + if (it < 0) { + // Didn't find a chapter, so it's either the first or last + if (position < chapters.first().position) { + 0 + } else { + chapters.lastIndex + } + } else { + it + } + }.coerceIn(0, chapters.lastIndex) + index + } + val listState = rememberLazyListState(chapterIndex) val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + focusRequester.tryRequestFocus() + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -276,6 +306,7 @@ fun PlaybackOverlay( style = MaterialTheme.typography.titleLarge, ) LazyRow( + state = listState, contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = @@ -305,10 +336,19 @@ fun PlaybackOverlay( }, interactionSource = interactionSource, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester), + ).ifElse( + index == 0, + Modifier.focusProperties { + // Prevent scrolling left on first card to prevent moving down + left = FocusRequester.Cancel + }, + ), ) } } @@ -448,8 +488,10 @@ fun PlaybackOverlay( style = MaterialTheme.typography.labelLarge, modifier = Modifier - .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), ) } } From 7424f812d8510add906b4f3a86727f687b4a733c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:04:08 -0500 Subject: [PATCH 013/118] Dev: add CI to build app store releases (#468) Adding GHA workflows to build the app store releases Also cleans up gradle build script a bit --- .github/workflows/pr.yml | 27 ++++---- .github/workflows/release.yml | 30 +++++++-- app/build.gradle.kts | 103 ++++++++++++++----------------- app/src/patches/play_store.patch | 40 ++++++++++++ 4 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 app/src/patches/play_store.patch diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..b4489867 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,19 +44,18 @@ jobs: id: buildapp run: | ./gradlew clean assembleDebug testDebugUnitTest --no-daemon - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - name: Tar build dirs + + test-patch: + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Checkout the code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Test applying patch run: | - tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v6 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v6 - with: - name: APKs - path: "${{ steps.buildapp.outputs.apks }}" - compression-level: 0 + git apply app/src/patches/play_store.patch + git diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..86c17c4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,24 @@ jobs: SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" run: | ./gradlew clean assembleRelease --no-daemon + + - name: Build app + id: buildaab + env: + KEY_ALIAS: "${{ secrets.KEY_ALIAS }}" + KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" + KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" + SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + run: | + git apply app/src/patches/play_store.patch + ./gradlew bundleRelease --no-daemon + aab=$(find app/build/outputs -name '*.aab') + echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | - echo "Verify APK signatures" - find app/build/outputs/apk -name '*.apk' - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + echo "Verify APK/AAB signatures" + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | @@ -59,11 +72,18 @@ jobs: - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum + - name: Upload AAB + uses: actions/upload-artifact@v6 + with: + name: AAB + path: | + app/build/outputs/bundle/**/*.aab + compression-level: 0 - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ "app/build/outputs/apk/**/*.apk" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ab4a9cc..366dfcd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,20 +47,64 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + if (shouldSign) { + create("ci") { + file("ci.keystore").writeBytes( + Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), + ) + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + storePassword = System.getenv("KEY_STORE_PASSWORD") + storeFile = file("ci.keystore") + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } + } + buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) isDebuggable = false + if (shouldSign) { + signingConfig = signingConfigs.getByName("ci") + } else { + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + val properties = Properties() + properties.load(localPropertiesFile.inputStream()) + val signingConfigName = properties["release.signing.config"]?.toString() + if (signingConfigName != null) { + signingConfig = signingConfigs.getByName(signingConfigName) + } + } + } } + debug { isMinifyEnabled = false isDebuggable = true applicationIdSuffix = ".debug" } + + applicationVariants.all { + val variant = this + variant.outputs + .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } + val outputFileName = + "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" + output.outputFileName = outputFileName + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -81,63 +125,6 @@ android { room { schemaDirectory("$projectDir/schemas") } - signingConfigs { - if (shouldSign) { - create("ci") { - file("ci.keystore").writeBytes( - Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), - ) - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") - storePassword = System.getenv("KEY_STORE_PASSWORD") - storeFile = file("ci.keystore") - enableV1Signing = true - enableV2Signing = true - enableV3Signing = true - enableV4Signing = true - } - } - } - buildTypes { - release { - isMinifyEnabled = false - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } else { - val localPropertiesFile = project.rootProject.file("local.properties") - if (localPropertiesFile.exists()) { - val properties = Properties() - properties.load(localPropertiesFile.inputStream()) - val signingConfigName = properties["release.signing.config"]?.toString() - if (signingConfigName != null) { - signingConfig = signingConfigs.getByName(signingConfigName) - } - } - } - } - debug { - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } - } - - applicationVariants.all { - val variant = this - variant.outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } - val outputFileName = - "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" - output.outputFileName = outputFileName - } - } - } splits { abi { diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch new file mode 100644 index 00000000..ef5fb96d --- /dev/null +++ b/app/src/patches/play_store.patch @@ -0,0 +1,40 @@ +commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc +Author: Damontecres +Date: Sat Nov 22 13:00:55 2025 -0500 + + Setup for play store + +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +index 6d84299..12576af 100644 +--- a/app/src/main/AndroidManifest.xml ++++ b/app/src/main/AndroidManifest.xml +@@ -4,7 +4,6 @@ + + + +- + +@@ -17,7 +16,7 @@ + android:required="false" /> + ++ android:required="true" /> + +diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +index c7ac435..fa42fe1 100644 +--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt ++++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +@@ -62,7 +62,7 @@ class UpdateChecker + + private val NOTE_REGEX = Regex("") + +- val ACTIVE = true ++ val ACTIVE = false + } + + suspend fun maybeShowUpdateToast( From c4cd1fbfd3ee9f53feb6b4811c61e35b78580eeb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:24:26 -0500 Subject: [PATCH 014/118] Update actions/checkout action to v6 (#965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` → `v6` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b4489867..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,7 +52,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 - name: Test applying patch From a14fdac85232b3f4a5f2e8b319461f02031fdf65 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:37:21 -0500 Subject: [PATCH 015/118] A few more home page fixes (#967) ## Description A few more home page fixes * Focus issue between settings & preset buttons * Fix default genre card size for "Wholphin Default" preset * Cache genre image urls for 2 hours so that they don't change on every refresh ### Related issues Related to #399 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 3 +++ .../wholphin/ui/components/GenreCardGrid.kt | 26 +++++++++++++++++++ .../wholphin/ui/main/HomeViewModel.kt | 3 +++ .../ui/main/settings/HomeRowPresets.kt | 3 +-- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 2fe5a6cc..b8a850ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -569,6 +569,7 @@ class HomeSettingsService userDto: UserDto, libraries: List, limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, ): HomeRowLoadingState = when (row) { is HomeRowConfig.ContinueWatching -> { @@ -649,12 +650,14 @@ class HomeSettingsService val genreImages = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = scope, imageUrlService = imageUrlService, genres = genreIds, parentId = row.parentId, includeItemTypes = null, cardWidthPx = null, + useCache = isRefresh, ) val library = libraries diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e64f1824..e1e28f0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -55,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours @HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel @@ -110,6 +113,7 @@ class GenreViewModel val genreToUrl = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = viewModelScope, imageUrlService = imageUrlService, genres = genres.map { it.id }, @@ -141,15 +145,35 @@ class GenreViewModel } } +data class GenreCacheKey( + val userId: UUID?, + val parentId: UUID, +) + +private val genreCache by lazy { + InMemoryKache>(8) { + expireAfterWriteDuration = 2.hours + } +} + suspend fun getGenreImageMap( api: ApiClient, + userId: UUID?, scope: CoroutineScope, imageUrlService: ImageUrlService, genres: List, parentId: UUID, includeItemTypes: List?, cardWidthPx: Int?, + useCache: Boolean = true, ): Map { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres @@ -161,6 +185,7 @@ suspend fun getGenreImageMap( .execute( api, GetItemsRequest( + userId = userId, parentId = parentId, recursive = true, limit = 1, @@ -189,6 +214,7 @@ suspend fun getGenreImageMap( } } }.awaitAll() + genreCache.put(key, genreToUrl) return genreToUrl } 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 4471194e..a8ce7fd9 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 @@ -78,6 +78,8 @@ class HomeViewModel // Refreshing if a load has already occurred and the rows haven't significantly changed val refresh = state.loadingState == LoadingState.Success && state.settings == settings + Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}") + _state.update { it.copy(settings = settings) } val semaphore = Semaphore(4) @@ -102,6 +104,7 @@ class HomeViewModel userDto = userDto, libraries = libraries, limit = prefs.maxItemsPerRow, + isRefresh = refresh, ) } catch (ex: Exception) { Timber.e(ex, "Error on row %s", row) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index 815c343a..eaf44d81 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.CollectionType @@ -81,7 +80,7 @@ data class HomeRowPresets( contentScale = PrefContentScale.FIT, ), liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = Cards.HEIGHT_2X3_DP, + genreSize = HomeRowViewOptions.genreDefault.heightDp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 5a0ac965..9eadc615 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -137,7 +137,7 @@ fun HomeSettingsRowList( position = 2 onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[2]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 9110af12..67b57e28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -133,6 +133,7 @@ class HomeSettingsViewModel userDto = userDto, libraries = state.libraries, limit = limit, + isRefresh = false, ) } } From 89bdae9cd78d1c2e56a0f38ff073608e85934342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 20 Feb 2026 14:34:52 +0000 Subject: [PATCH 016/118] Translated using Weblate (Estonian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index d4d5d17f..10b611eb 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -446,8 +446,8 @@ Rakenda kõikidele ridadele Kohanda kodulehte Kodulehe/avalehe read - Laadi serverist - Salvesta serverisse + Laadi kasutajaprofiil serverist + Salvesta kasutajaprofiil serverisse Laadi veebikliendist Kasuta sarja pilti Lisa rida: %1$s @@ -462,4 +462,5 @@ Kuvamise eelseadistused Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks Vali kodulehe/avalehe read ja pildid + Märgi %s lemmikuks From 5d4219f9337747592f92e3a6b9f5cc14bed5f288 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 20 Feb 2026 07:43:20 +0000 Subject: [PATCH 017/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 49bab214..28875ea9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -429,8 +429,8 @@ 应用到所有行 自定义首页 首页行 - 从服务器加载 - 保存到服务器 + 从服务器用户配置加载 + 保存到服务器用户配置 从网络客户端加载 使用剧集图片 为 %1$s 添加行 @@ -445,4 +445,5 @@ 显示预设 可快速设置所有行样式的内置预设 选择首页上的行和图片 + 收藏的 %s From 6ffc2903f340734d39c8cee577ddfd41eb3bfc04 Mon Sep 17 00:00:00 2001 From: idezentas Date: Fri, 20 Feb 2026 12:00:52 +0000 Subject: [PATCH 018/118] Translated using Weblate (Turkish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 58 +++++++++++++------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 117d6e2f..c65d4b5e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -65,7 +65,7 @@ Benzerleri Diğer - Filmler + Film Filmler Ad @@ -80,7 +80,7 @@ Kapanış jeneriği Yol - Kişiler + Kişi Kişiler Oynatma Sayısı @@ -151,7 +151,7 @@ İzlenmemiş En İyiler Fragman - Fragmanlar + Fragman Fragmanlar Kayıt Takvimi @@ -159,7 +159,7 @@ Sezon Sezonlar - Diziler + Dizi Diziler Arayüz @@ -187,48 +187,48 @@ Süre Ekstralar - Diğer Ekstralar - + Diğer Ekstra + Diğer Ekstralar Sahne Arkası - + Sahne Arkaları - Tema şarkıları - + Tema şarkısı + Tema şarkıları - Tema Videoları - + Tema Videosu + Tema Videoları - Klipler - + Klip + Klipler - Silinmiş Sahneler - + Silinmiş Sahne + Silinmiş Sahneler - Röportajlar - + Röportaj + Röportajlar - Sahneler - + Sahne + Sahneler - Örnekler - + Örnek + Örnekler - Tanıtımlar - + Tanıtım + Tanıtımlar - Kısa Videolar - + Kısa Video + Kısa Videolar %s indirme @@ -240,7 +240,7 @@ %d öğe - %d öğe + %d öğeler %d saniye @@ -455,8 +455,8 @@ Tüm satırlara uygula Ana ekranı özelleştir Ana ekran satırları - Sunucudan yükle - Sunucuya kaydet + Kullanıcı profilinden yükle + Kullanıcı profiline kaydet Web istemcisinden yükle Dizi görselini kullan %1$s için satır ekle @@ -470,4 +470,6 @@ Ayarlar kaydedildi Görüntü ön ayarları Tüm satırları hızlıca stilize etmek için hazır ayarlar + Favori %s + Ana sayfadaki satırları ve görselleri seçin From 6ef4356ad1b541aca13feecb2259b05de511258f Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 12:29:42 +0000 Subject: [PATCH 019/118] Translated using Weblate (Danish) Currently translated at 97.1% (404 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 197 +++++++++++++++++++++++-- 1 file changed, 182 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 43da96b0..229bc4c8 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -76,7 +76,7 @@ Ingen opdatering tilgængelig Ingen Kun tvungne undertekster - Outro + Eftertekster Filsti Personer @@ -151,7 +151,7 @@ Trailer Trailere - + DVR-plan TV-guide @@ -159,7 +159,7 @@ Sæsoner TV-serier - + Grænseflade Ukendt @@ -187,47 +187,47 @@ Ekstra Andet - + Bag kulisserne - + Temasange - + Temavideoer - + Klip - + Slettede scener - + Interview - + Scener - + Prøver - + Specialindslag - + Kortfilm - + %s download @@ -287,7 +287,7 @@ Adfærd ved at springe reklamer over Spring fremad Adfærd ved at springe intro over - Adfærd ved at springe outro over + Adfærd ved at springe eftertekster over Adfærd ved at springe forhåndsvisninger over Adfærd ved at springe opsummeringer over Opdatering tilgængelig @@ -304,4 +304,171 @@ Undertekststil Baggrundsfarve Nulstil + Afspilning Backend + MPV: Brug hardwareafkodning + Deaktiver, hvis du oplever fejl + MPV indstillinger + ExoPlayer indstillinger + Spring over segmentet + Spring over reklamer + Spring over forhåndsvisning + Spring over opsummering + Spring over eftertekster + Spring over intro + Afspillet + Filter + År + Årti + Fjern + Dolby Vision + Dolby Atmos + MPV: Brug gpu-next + Rediger mpv.conf + Kassér ændringer? + Margin + Detaljeret logning + Indtast PIN + Log ind automatisk + Log ind via server + Tryk på midten for at bekræfte + Kræv PIN-kode til profil + Bekræft PIN + Fejl + PIN-koden skal være 4 cifre eller længere + Vil fjerne PIN + Størrelse på billeddiskcache (MB) + Vis muligheder + Kolonner + Mellemrum + Aspektforhold + Vis detaljer + Billedtype + + Gæsteroller + Kantstørrelse + Skift af billedopdateringsfrekvens + Automatisk + Brug brugernavn/adgangskode + Log ind + Generelt + Container + Titel + Codec + Profil + Niveau + Opløsning + Anamorfisk + Interlaced + Billedfrekvens + Bitdybde + Videoområde + Type af videoområde + Farverum + Farveoverførsel + Primære farver + Pixel-format + Referencerammer + NAL + Sprog + Layout + Kanaler + Samplingsfrekvens + AVC + Ja + Nej + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titler + Gentag + Vis favoritkanaler først + Sorter kanaler efter nyligt set + Farvekod programmer + Undertekstforsinkelse + Stil for baggrundsbillede + Skift af opløsning + Lokal + Afspil trailer + Ingen trailere + Ryd valg af spor + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Direkte afspilning af Dolby Vision Profile 7 + Ignorerer kontrol af enhedskompatibilitet + Opdag + Anmod + Afventer + Seerr integration + Fjern Seerr server + Seerr server tilføjet + Quick Connect + Godkend en anden enhed til at logge ind på din konto + Indtast Quick Connect-kode + Koden skal være på 6 cifre + Enheden er godkendt + Adgangskode + Brugernavn + URL + Populært lige nu + Kommende film + Kommende tv-serier + Anmod i 4K + AV1 software afkodning + MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne. + HDR-undertekststil + Billedunderteksters gennemsigtighed + Lysstyrke + Kontrast + Mætning + Farvetone + Rød + Grøn + Blå + Sløring + Gem i album + Afspil diasshow + Stop diasshow + Fra begyndelsen + Ikke flere billeder + Roter til venstre + Roter til højre + Zoom ind + Zoom ud + Diasshowvarighed + Afspil videoer under diasshow + Send medieinformationslog til serveren + Ingen grænse + Maks. antal dage i Næste + Tilføj række + Genrer i %1$s + Nyligt udkommet i %1$s + Højde + Anvend på alle rækker + Tilpas startsiden + Rækker på startsiden + Indlæs fra brugerprofilen på serveren + Gem i brugerprofilen på serveren + Indlæs fra webklienten + Brug seriens billede + Tilføj række for %1$s + Overskriv indstillinger på serveren? + Overskriv lokale indstillinger? + For episoder + Forslag til %1$s + Øg størrelsen på alle kort + Reducer størrelsen på alle kort + Brug thumb-billeder + Indstillinger gemt + Skærmforudindstillinger + Indbyggede forudindstillinger til hurtigt at style alle rækker + Vælg rækker og billeder på startsiden + Reklame From 4ad96ea29e565e95b9d3fc15a74fa58f576dec63 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 20 Feb 2026 20:18:49 +0000 Subject: [PATCH 020/118] Translated using Weblate (Italian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0aadb5bd..a3764b96 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -461,7 +461,7 @@ Recentemente rilasciato in %1$s Altezza Applica a tutte le righe - Carica dal server + Carica dal profilo utente del server Aggiungi riga per %1$s Sovrascrivere le impostazioni sul server? Sovrascrivere le impostazioni locali? @@ -475,8 +475,9 @@ Righe home Per gli episodi Personalizza home page - Salva sul server + Salva nel profilo utente del server Carica dal client web Usa immagine delle serie Scegli righe e immagini nella home page + Preferito %s From 40a3d70ba87c603b1e7b6a1a70ec70dfbc666634 Mon Sep 17 00:00:00 2001 From: danyrd92 Date: Fri, 20 Feb 2026 22:55:40 +0000 Subject: [PATCH 021/118] Translated using Weblate (Spanish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 831c6036..ad5cd773 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,7 +12,7 @@ Elegir %1$s Colección Colecciones - Comercial + Anuncio Confirmar Continuar viendo %.2f segundos @@ -119,7 +119,7 @@ Servidores detectados Descargando… - Introduce la IP o URL del servidor, incluido el puerto + Introduce la IP o URL del servidor Error cargando la colección %1$s Forzado Ir a la serie @@ -462,8 +462,8 @@ Aplicar a todas las filas Personalizar página de inicio Filas de inicio - Cargar desde el servidor - Guardar en el servidor + Cargar perfil de usuario desde el servidor + Guardar perfil de usuario en el servidor Cargar del cliente web Usar imágenes de la serie Agregar fila para %1$s @@ -479,4 +479,5 @@ Preajustes integrados para estilizar todas las filas Elegir filas e imágenes en la página de inicio Lanzado recientemente en %1$s + Favoritos%s From e536c0a9e79f0efbb9fe220dafa619d975e64865 Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 21 Feb 2026 10:40:37 +0000 Subject: [PATCH 022/118] Translated using Weblate (Turkish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c65d4b5e..aec35e54 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -52,7 +52,7 @@ Ana Ekran Hemen İntro - #ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ + #ABCDEFGHIJKLMNOPQRSTUVWXYZ Kütüphane Lisans bilgileri Canlı TV From 191966124b2854bf5bc23f5249ff4d294df4c86f Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sun, 22 Feb 2026 14:10:44 +0000 Subject: [PATCH 023/118] Translated using Weblate (Slovak) Currently translated at 53.6% (223 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 209 ++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index c55a7acc..195565df 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -22,7 +22,7 @@ Potvrdiť Pokračovať v pozeraní Hodnotenie kritikov - %.1f sekúnd + %.2f sekúnd Predvolené Vymazať Zomrel @@ -102,4 +102,211 @@ Vybrať používateľa Zobraziť informácie o debugu Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server. + Končí o %1$s + Zadajte adresu servera + Nenašli sa žiadne servery + Iba vynútené titulky + Hlasové vyhľadávanie + Spúšťanie + Hovorením vyhľadávajte + Spracovanie + Stlačte späť pre zrušenie + Chyba nahrávania zvuku + Chyba rozpoznávania hlasu + Vyžaduje sa povolenie na prístup k mikrofónu + Chyba siete + Časový limit siete + Nebol rozpoznaný žiadny hlas + Rozpoznávanie hlasu je zaneprázdnené + Chyba servera + Nebola zistená žiadna reč + Neznáma chyba + Nepodarilo sa spustiť rozpoznávanie hlasu + Časový limit rozpoznávania hlasu vypršal + Skúsiť znova + Zobraziť ďalšie + Zobraziť + Náhodne + Preskočiť + Dátum pridania + Dátum pridania epizódy + Dátum prehratia + Dátum vydania + Názov + Náhodne + Štúdiá + Odoslať + Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie + Podnázov + Titulky + Návrhy + Zmeniť servery + Zmeniť + Najlepšie hodnotené Nepozreté + Trailer + + Trailery + + + + + DVR rozvrh + Sprievodca + Séria + Série + + Seriály + + + + + Rozhranie + Neznámy + Aktualizácie + Verzia + Mierka videa + Video + Videá + Sledovať naživo + %1$d rokov + Prehrávať s prekódovaním + Informácie o médiu + Zobraziť hodiny + Poradie odvysielaných epizód + Vytvoriť nový playlist + Pridať do playlistu + Pozastavenie jedným kliknutím + Stlačte stred D-Padu pre pozastavenie/prehrávanie + Kurzívový font + Font + Pozadie + Úspech + Rodičovské hodnotenie + Trvanie + Bonusový materiál + + Ostatné + + + + + + Zo zákulisia + + + + + + Tématické piesne + + + + + + Tématické videá + + + + + + Klipy + + + + + + Vymazané scény + + + + + + Rozhovory + + + + + + Scény + + + + + + Vzorky + + + + + + Krátkometrážne filmy + + + + + + Krátke videá + + + + + Obľúbené %s + + %s stiahnutý + %s stiahnuté + %s stiahnutých + %s stiahnutých + + + %d hodina + %d hodiny + %d hodín + %d hodín + + + %s deň + %s dni + %s dní + %s dní + + + %d položka + %d položky + %d položiek + %d položiek + + + %d sekunda + %d sekundy + %d sekúnd + %d sekúnd + + Zariadenie podporuje AC3/Dolby Digital + Pokročilé nastavenia + Pokročilé UI + Téma aplikácie + Automaticky kontrolovať aktualizácie + Skontrolovať aktualizácie + Platí len pre seriály + Priame prehrávanie ASS titulkov + Priame prehrávanie PGS titulkov + Vždy downmixovať do stereo formátu + Použiť modul FFmpeg dekodéra + Predvolená mierka obsahu + Nainštalovať aktualizáciu + Nainštalovaná verzia + Maximálny počet položiek v riadkoch na domovskej obrazovke + Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté + Prispôsobenie položiek navigačného panela + Kliknutím prepnete stránky + Prepnúť stránky navigačného panelu pri zaostrení + Ochrana proti výpadku + Prehrať tématickú hudbu + Zapamätať vybrané karty + Kroky seek baru + Užitočné pre debugovanie + Odoslať logy aplikácie na aktuálny server + Pokus o odoslanie na posledný pripojený server + Odoslať správy o zlyhaní + Nastavenia From 0a4f21cc11990e42c50a672bc7d47fa2d824181a Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sun, 22 Feb 2026 13:36:04 +0000 Subject: [PATCH 024/118] Translated using Weblate (Czech) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0e3d0be..42085349 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -495,8 +495,8 @@ Použít na všechny řádky Přizpůsobit domovskou stránku Řádky na domovské stránce - Načíst ze serveru - Uložit na server + Načíst ze serverového uživatelského profilu + Uložit do serverového uživatelského profilu Načíst z webového klienta Použít obrázek seriálu Přidat řádek pro %1$s @@ -511,4 +511,5 @@ Zobrazit předvolby Vestavěné předvolby pro rychlou úpravu všech řádků Vyberte řádky a obrázky na domovské stránce + Oblíbené %s From bfa0cd0b05fbd631d1d785e6682b8f1e1f247f7f Mon Sep 17 00:00:00 2001 From: Sathen Date: Sun, 22 Feb 2026 19:41:20 +0000 Subject: [PATCH 025/118] Translated using Weblate (Ukrainian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ea7e0323..1a31690d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -474,4 +474,42 @@ Зменшити Тривалість слайд-шоу Відтворення відео під час слайд-шоу + Улюблені %s + + день + дні + днів + днів + + Швидке підключення + Дозвольте іншому пристрою увійти у ваш обліковий запис + Введіть код швидкого підключення + Код повинен складатися з 6 цифр + Пристрій успішно авторизовано + Надіслати інформацію про медіа на сервер + Без обмежень + Максимальна кількість днів в Очікуються + Додати рядок + Жанри %1$s + Нещодавно додані %1$s + Висота + Застосувати для всіх рядків + Змінити головну сторінку + Рядки на головній сторінці + Завантажувати профіль користувача з сереверу + Зберегти профіль користувача на сервер + Завантажити з веб клієнта + Використовувати зображення серіалу + Додати рядок для %1$s + Перезаписати налаштування на сервері? + Перезаписати налаштування на цьому пристрої? + Для епізодів + Рекомендації %1$s + Збільшити розмір карток + Зменшити розмір карток + Використовувати мініатюру + Налаштування збережені + Налаштування дисплея + Вбудовані налаштування для швидкого оформлення всіх рядків + Обери рядки і зображення для головної сторінки From ca425422befe35ec111a79dc5d8edaa1387384ff Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 23 Feb 2026 02:29:50 +0000 Subject: [PATCH 026/118] Translated using Weblate (Indonesian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 91708cf7..356732cd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -429,8 +429,8 @@ Terapkan ke semua baris Kustomisasi beranda Baris beranda - Muat dari server - Simpan ke server + Muat dari profil pengguna server + Simpan ke profil pengguna server Muat dari klien web Gunakan gambar seri Tambah baris untuk %1$s @@ -445,4 +445,5 @@ Preset tampilan Preset bawaan untuk kustomisasi cepat semua baris Pilih baris dan gambar di halaman beranda + Favoritkan %s From f1c2ff453076f5a39f82cb77a842077364cea2cd Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 23 Feb 2026 04:42:52 +0000 Subject: [PATCH 027/118] Translated using Weblate (French) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ae405a53..538fd523 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -463,8 +463,8 @@ S\'applique à toutes les lignes Personnaliser la page d\'accueil Lignes d\'accueil - Charger depuis le serveur - Enregistrer sur le serveur + Charger à partir du profil utilisateur du serveur + Enregistrer dans le profil utilisateur du serveur Charger à partir du client Web Utiliser l\'image de la série Ajouter une ligne pour %1$s @@ -479,4 +479,5 @@ Afficher les préréglages Préréglages intégrés pour styliser rapidement toutes les lignes Choisissez des lignes et des images sur la page d\'accueil + Favori %s From 34174cea55ba0f1e304f55421b8b94e2dfc3a7d4 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 23 Feb 2026 17:28:03 +0000 Subject: [PATCH 028/118] Translated using Weblate (Spanish) Currently translated at 91.9% (434 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ad5cd773..a74387bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -480,4 +480,20 @@ Elegir filas e imágenes en la página de inicio Lanzado recientemente en %1$s Favoritos%s + Ajustar + Recortar + Rellenar + Ajustar al ancho + Ajustar al alto + Miniaturas de las series + Miniaturas de los episodios + Mínimo + Bajo + Medio + Alto + Máximo + Morado + Naranja + Azul intenso + Negro From 2a90d12cbc03125d63777951e5fd47c10aa0654e Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 23 Feb 2026 17:34:00 +0000 Subject: [PATCH 029/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 91.3% (431 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1cae12e0..85ad4ed1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -436,8 +436,8 @@ 選擇首頁要顯示的列項目及圖片樣式 高度 自訂首頁 - 從伺服器載入 - 保存到伺服器 + 從伺服器載入設定 + 保存設定到伺服器 從網頁版載入 使用劇集圖片 覆蓋伺服器上的設定? @@ -445,4 +445,18 @@ 設定已儲存 顯示預設樣式 針對單集 + 原比例縮放 + 裁切填滿 + 拉伸填滿 + 填滿寬度 + 填滿高度 + 最低 + + 中等 + + 最大 + 紫色 + 橘色 + 深藍色 + 黑色 From 0ce2ff907c2ba457d6e422f463c5f08895fc687d Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 23 Feb 2026 18:33:14 +0000 Subject: [PATCH 030/118] Translated using Weblate (Spanish) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a74387bf..b4ef66da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -496,4 +496,42 @@ Naranja Azul intenso Negro + Póster + Póster compacto + Ignorar + Omitir automáticamente + Preguntar para omitir + Usar FFmpeg solo si no existe un decodificador integrado + Preferir FFmpeg sobre los decodificadores integrados + No usar nunca decodificadores FFmpeg + Al finalizar la reproducción + Durante los créditos finales + Blanco + Gris claro + Gris oscuro + Amarillo + Cian + Magenta + Contorno + Sombra + Ajustado al texto + Enmarcado + Preferir MPV + Usar ExoPlayer para reproducir contenido HDR + Póster (2:3) + 16:9 + 4:3 + Cuadrada (1:1) + Primaria + Miniatura + Imagen con color dinámico + Imagen + + Clave API + Iniciar sesión en el servidor Seerr + Usuario de Jellyfin + Usuario local + + No se encontraron subtítulos externos + Actualidad From a55399cb683c900d9e313d8fb011a383fedaac4e Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 23 Feb 2026 17:49:15 +0000 Subject: [PATCH 031/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 92.5% (437 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 85ad4ed1..a145fe45 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -459,4 +459,10 @@ 橘色 深藍色 黑色 + 不處理 + 自動跳過 + 詢問是否跳過 + 僅在無內建解碼器時使用 FFmpeg + 優先使用 FFmpeg 而非內建解碼器 + 不使用 FFmpeg 解碼器 From 3b736a136b048a1b0bf70fe0ee7ddf7e86a1a18f Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Mon, 23 Feb 2026 22:55:08 +0000 Subject: [PATCH 032/118] Translated using Weblate (Portuguese (Brazil)) Currently translated at 81.9% (387 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 429 +++++++++++++++++++++ 1 file changed, 429 insertions(+) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c7889af3..76bf308f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -31,4 +31,433 @@ Diretor Desabilitado Servidores encontrados + Baixando… + Habilitado + Termina às %1$s + Insira o IP ou URL do Servidor + Insira o endereço do servidor + Episódios + Erro ao carregar a coleção %1$s + Externo + Favoritos + Tamanho + Forçado + Gêneros + Ir para séries + Ir Para + Ocultar controles de reprodução + Ocultar informações de depuração + Ocultar + Início + Imediato + Introdução + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Biblioteca + Informação de licenças + TV ao Vivo + Carregando… + Marcar série inteira como reproduzida? + Marcar séria inteira como não reproduzida? + Marcar como não assistida + Marcar como assistida + Taxa de bits máxima + Mais como isso + Mais + + Filmes + + + + Nome + A Seguir + Sem dados + Sem resultados + Nenhum servidor encontrado + Nenhuma gravação agendada + Nenhuma atualização disponível + Nenhum(a) + Apenas Legendas Forçadas + Finalização + Caminho + + Pessoas + + + + Contagem de Reproduções + Reproduzir a partir daqui + Reproduzir + Mostrar informações de depuração da reprodução + Velocidade de Reprodução + Reprodução + Lista de reprodução + Listas de reprodução + Prévia + Configurações do Perfil de Usuário + Fila + Recapitulação + Recentemente adicionado em %1$s + Recentemente adicionado + Recentemente Gravado + Recentemente Publicado + Recomendado + Gravar Programa + Gravar Série + Desfavoritar + Reiniciar + Continuar + Salvar + Procurar + Procurando… + Pesquisa por voz + Iniciando + Fale para pesquisar + Processando + Aperte voltar para cancelar + Erro de gravação de áudio + Erro de reconhecimento de voz + Permissão de microfone necessária + Erro de rede + Tempo limite de rede esgotado + Nenhuma fala reconhecida + Reconhecimento de voz ocupado + Erro de servidor + Nenhuma fala detectada + Erro desconhecido + Falha no início do reconhecimento + Tempo limite do reconhecimento de voz esgotado + Tentar novamente + Selecionar Servidor + Selecionar Usuário + Mostrar informações de depuração + Mostrar próximo + Mostrar + Aleatório + Pular + Data de Adição + Data de Adição do Episódio + Data de Reprodução + Data de Lançamento + Nome + Aleatório + Estúdios + Enviar + O download está demorando muito, talvez você tenha que reiniciar a reprodução + Legenda + Legendas + Sugestões + Trocar de servidores + Trocar + Não assistidos com melhor avaliação + Trailer + + Trailers + + + + Agenda do DVR + Guia + Temporada + Temporadas + + Programas de TV + + + + Interface + Desconhecido + Atualizações + Versão + Escala de Vídeo + Vídeo + Vídeos + Assista ao vivo + %1$d anos de idade + Reproduzir com transcodificação + Informação de Mídia + Mostrar Relógio + Ordem de Lançamento do Episódio + Criar nova lista de reprodução + Adicionar a lista de reprodução + Pausar com um clique + Aperte o centro do controle direcional para pausar/reproduzir + Colocar a fonte em itálico + Fonte + Plano de fundo + Sucesso + Classificação Parental + Duração + Extras + + Outro + + + + + Por Trás das Cenas + + + + + Músicas Tema + + + + + Vídeos Tema + + + + + Clipes + + + + + Cenas Deletadas + + + + + Entrevistas + + + + + Cenas + + + + + Amostras + + + + + Curtas + + + + Favoritar %s + + %s download + %s downloads + %s downloads + + + %d hora + %d horas + %d horas + + + %s dia + %s dias + %s dias + + + %d item + %d itens + %d itens + + + %d segundo + %d segundos + %d segundos + + Dispositivo suporta AC3/Dolby Digital + Configurações Avançadas + IU Avançada + Tema do Aplicativo + Checar atualizações automaticamente + Atraso antes da próxima reprodução + Reproduzir próximo automaticamente + Checar atualizações + Aplicar apenas a Séries de TV + Reproduzir legendas ASS diretamente + Reproduzir legendas PGS diretamente + Sempre mixar para estéreo + Usar módulo de decodificação do FFmpeg + Escala padrão do conteúdo + Instalar atualização + Versão instalada + Máximo de itens nas linhas da página inicial + Escolher itens exibidos por padrão, os outros serão ocultados + Personalizar os Itens da Gaveta de Navegação + Clicar para trocar de páginas + Trocar a página de navegação no foco + Proteção Contra Desmaios + Tocar música tema + Sobreposições de reprodução + Lembrar abas selecionadas + Habilitar reassistir em a seguir + Passos da barra de busca + Útil para depuração + Enviar os registros do aplicativo para o servidor atual + Tentativa de envio para o último servidor conectado + Enviar Relatórios de Falhas + Configurações + Comportamento de pular os comerciais + Comportamento de pular as introduções + Comportamento de pular a finalização + Comportamento de pular as prévias + Comportamento de pular as recapitulações + Atualização disponível + URL usada para verificação de atualizações + URL de atualização + Tamanho da fonte + Cor da fonte + Opacidade da fonte + Estilo da borda + Cor da borda + Opacidade do fundo + Estilo do fundo + Estilo da legenda + Cor do fundo + Redefinir + Fonte em negrito + Backend de reprodução + MPV: Usar decodificação baseada em hardware + Desabilite se encontrar falhas + Opções do MPV + Opções do ExPlayer + Pular Segmento + Pular Anúncios + Pular Prévia + Pular Recapitulação + Pular Finalização + Pular Introdução + Reproduzido + Filtro + Ano + Década + Remover + Dolby Vision + Dolby Atmos + MPV: Usar gpu-next + Editar mpv.conf + Descartar mudanças? + Margem + Registro extenso + Insira o PIN + Entrar automaticamente + Autenticar via servidor + Aperte o botão central para confirmar + Exigir PIN para o perfil + Confirmar o PIN + Incorreto + PIN deve ter 4 ou mais dígitos + Irá remover o PIN + Tamanho do cache de imagens no disco (MB) + Ver opções + Colunas + Espaçamento + Proporção da Tela + Mostrar detalhes + Tipo de imagem + Estrelas convidadas + Tamanho da borda + Troca de taxa de atualização + Automático + Usar nome de usuário / senha + Autenticação + Geral + Contêiner + Título + Codec + Perfil + Nível + Resolução + Anamórfico + Entrelaçado + Taxa de atualização + Profundidade de bits + Alcance de vídeo + Tipo de alcance de vídeo + Espaço de cores + Transferência de cores + Primárias de cores + Formato de pixel + Quadros de referência + Idioma + Layout + Canais + Taxa de amostra + Sim + Não + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostrar títulos + Repetir + Mostrar canais favoritos primeiro + Ordenar canais por recentemente assistidos + Codificar programas por cor + Atraso na legenda + Estilo de fundo + Troca de resolução + Local + Reproduzir trailer + Nenhum trailer + Limpar escolhas de faixas + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Reprodução direta de Dolby Vision Profile 7 + Ignorar checagens de compatibilidade de dispositivo + Descobrir + Solicitar + Pendente + Integração com o Seerr + Remover Servidor Seerr + Servidor Seerr adicionado + Conexão Rápida + Autorizar outro dispositivo a se autenticar com sua conta + Insira o código de conexão rápida + O código deve ter 6 dígitos + Dispositivo autorizado com sucesso + Senha + Nome de usuário + URL + Tendências + Filmes Lançados em Breve + Programas de TV Lançados em Breve + Solicitar em 4K + Decodificação de AV1 via software + MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações. + Estilo da legenda HDR + Opacidade da legenda de imagem + Brilho + Contraste + Saturação + Matiz + Vermelho + Verde + Azul + Desfoque + Reproduzir apresentação de slides + Interromper apresentação de slides + No começo + Sem mais fotos + Girar a esquerda + Girar a direita + Mais zoom + Menos zoom + Duração da reprodução de slides + Reproduzir vídeos ao longo da apresentação de slides + Enviar registro de informações da mídia ao servidor + Sem limite + Máximo de dias no A Seguir + Adicionar linha + Gêneros em %1$s + Recentemente lançado em %1$s + Altura + Aplicar a todas as linhas + Personalizar página inicial From 3bdd177230278354f76618d81c63204e2ae4a956 Mon Sep 17 00:00:00 2001 From: Fjuro Date: Tue, 24 Feb 2026 09:59:56 +0000 Subject: [PATCH 033/118] Translated using Weblate (Czech) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 42085349..8f32bc3a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -512,4 +512,58 @@ Vestavěné předvolby pro rychlou úpravu všech řádků Vyberte řádky a obrázky na domovské stránce Oblíbené %s + Přizpůsobit + Oříznout + Vyplnit + Vyplnit šířku + Vyplnit výšku + Výchozí předvolba Wholphin + Wholphin kompaktní + Obrázky náhledů seriálů + Obrázky náhledů epizod + Nejnižší + Nízká + Střední + Vysoká + Plná + Fialová + Oranžová + Odvážná modrá + Černá + Ignorovat + Automaticky přeskočit + Zeptat se + Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér + Preferovat FFmpeg nad vestavěnými dekodéry + Nikdy nepoužívat dekodéry FFmpeg + Na konci přehrávání + Během titulků + Bílá + Světle šedá + Tmavě šedá + Žlutá + Azurová + Magenta + Okrajová linka + Stín + Zabalené + Krabicové + Preferovat MPV + Použít ExoPlayer pro přehrávání HDR obsahu + Plakát (2:3) + 16:9 + 4:3 + Čtverec (1:1) + Primární + Náhled + Obrázek s dynamickou barvou + Pouze obrázek + + Klíč API + Přihlásit se k serveru Seerr + Uživatel Jellyfin + Lokální uživatel + + Nenalezeny žádné vzdálené titulky + Současnost From 043f8cd3cfa9094ddd46254f6a23816f14b1745d Mon Sep 17 00:00:00 2001 From: SimonHung Date: Tue, 24 Feb 2026 17:25:41 +0000 Subject: [PATCH 034/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 95.3% (450 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a145fe45..6670d10e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -465,4 +465,17 @@ 僅在無內建解碼器時使用 FFmpeg 優先使用 FFmpeg 而非內建解碼器 不使用 FFmpeg 解碼器 + 在播放結束時 + 在播到片尾時 + 白色 + 淺灰色 + 深灰色 + 黃色 + 青色 + 洋紅色 + 外框 + 陰影 + 貼合文字 + 長方底框 + 優先使用 MPV From 33cd4d04aa2a331fce05b3ff39157556d6d72477 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:01:52 -0500 Subject: [PATCH 035/118] Release v0.5.1 From 69be7311ab372b6a8bc2a0e0e747408fcbb0711a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:15:04 -0500 Subject: [PATCH 036/118] Fix verify step --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86c17c4e..c654dad9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,9 @@ jobs: echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | - echo "Verify APK/AAB signatures" + echo "Verify APK signatures" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) - find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | From 1b94ab9bec9613999c3d2216ea2b16a50afc51ef Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:15:25 -0500 Subject: [PATCH 037/118] Release v0.5.1 From 536e8d366c1a720cec129fe0edc0543ca9c601be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:31:52 -0500 Subject: [PATCH 038/118] Update Dependencies to v3.4.0 (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [io.coil-kt.coil3:coil-svg](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-svg/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-svg/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-gif](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-gif/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-gif/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-network-okhttp](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-network-okhttp/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-network-okhttp/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-network-cache-control](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-network-cache-control/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-network-cache-control/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-compose](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-compose/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-compose/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-core](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-core/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-core/3.3.0/3.4.0?slim=true) | --- ### Release Notes
coil-kt/coil (io.coil-kt.coil3:coil-svg) ### [`v3.4.0`](https://redirect.github.com/coil-kt/coil/blob/HEAD/CHANGELOG.md#340---Unreleased) [Compare Source](https://redirect.github.com/coil-kt/coil/compare/3.3.0...3.4.0) - **New**: Add `ConcurrentRequestStrategy` to support combining in-flight network requests for the same key. ([#​2995](https://redirect.github.com/coil-kt/coil/pull/2995), [#​3326](https://redirect.github.com/coil-kt/coil/pull/3326)) - `DeDupeConcurrentRequestStrategy` enables this behavior and lets waiters wait for the results of an in-flight network request. - This behavior is experimental and is currently disabled by default. - Currently, requests are always combined based on their `diskCacheKey`. - `OkHttpNetworkFetcherFactory`, `KtorNetworkFetcherFactory`, and `NetworkFetcher.Factory` now accept `concurrentRequestStrategy`. - **New**: Decode images on JS/WASM using a web worker to avoid blocking the browser main thread. ([#​3305](https://redirect.github.com/coil-kt/coil/pull/3305)) - **New**: Add support for Linux native targets (`linuxX64` and `linuxArm64`) for non-Compose multiplatform artifacts. ([#​3054](https://redirect.github.com/coil-kt/coil/pull/3054)) - **New**: Add Compose-only APIs to improve transitions between subsequent requests. ([#​3141](https://redirect.github.com/coil-kt/coil/pull/3141), [#​3175](https://redirect.github.com/coil-kt/coil/pull/3175)) - `ImageRequest.Builder.useExistingImageAsPlaceholder` enables crossfading from the previous image when no placeholder is set. - `ImageRequest.Builder.preferEndFirstIntrinsicSize` lets `CrossfadePainter` prefer the end painter's intrinsic size. - **New**: Add `ImageLoader.Builder.repeatCount(Int)` in `coil-gif` to set a global animated image repeat count. ([#​3143](https://redirect.github.com/coil-kt/coil/pull/3143)) - **New**: Add support for preferring embedded video thumbnails in `coil-video`. ([#​3107](https://redirect.github.com/coil-kt/coil/pull/3107)) - **New**: Publish `coil-lint` with `coil-core` and add a lint check to catch accidental `kotlin.error()` calls in `ImageRequest.Builder` blocks. ([#​3304](https://redirect.github.com/coil-kt/coil/pull/3304)) - Set Kotlin language version to 2.1. ([#​3302](https://redirect.github.com/coil-kt/coil/pull/3302)) - Make `BitmapFetcher` available in common code. ([#​3286](https://redirect.github.com/coil-kt/coil/pull/3286)) - Use `applicationContext` when creating the singleton `ImageLoader` on Android. ([#​3246](https://redirect.github.com/coil-kt/coil/pull/3246)) - Cache eligible non-2xx HTTP responses by default (e.g. `404`) and stop caching non-cacheable responses (e.g. `500`). ([#​3137](https://redirect.github.com/coil-kt/coil/pull/3137), [#​3139](https://redirect.github.com/coil-kt/coil/pull/3139)) - Fix potential race condition when consuming OkHttp response bodies. ([#​3186](https://redirect.github.com/coil-kt/coil/pull/3186)) - Fix `maxBitmapSize` edge case to prevent oversized bitmap crashes on Android. ([#​3259](https://redirect.github.com/coil-kt/coil/pull/3259)) - Update Kotlin to 2.3.10. - Update Compose to 1.9.3. - Update Okio to 3.16.4. - Update Skiko to 0.9.22.2. - Update `kotlinx-io-okio` to 0.9.0. - Update `androidx.core` to 1.16.0. - Update `androidx.lifecycle` to 2.9.4. - Update `androidx.exifinterface` to 1.4.2.
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c2234f0..06a1b888 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.4" androidx-media3 = "1.9.2" -coil = "3.3.0" +coil = "3.4.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" From a7e86fbc15fd56170b97ec7b61d98faa088ef2d1 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:11:00 -0500 Subject: [PATCH 039/118] Add in-app & Android TV screensaver (#946) ## Description Adds both an optional in-app and Android TV OS (DreamService) screensaver Has settings for: - Starting delay, options from 30s to 1 hour - Duration to show each image, 30s to 2 minutes - Show clock (separate from app-wide clock) - Toggle animated zooming - Set a max age rating to limit what content is shown - Included media types: movies, tv shows, and/or photos By default only Movies & TV Shows are included. But if you have like family albums on your server, you could enable Photos too. There's also an button to start the screensaver so you can see what it looks like. ### Related issues Closes #230 ### Testing Emulator, Onn 4k pro, nvidia shield using both in-app and OS screensavers ## Screenshots ![screensaver_settings Large](https://github.com/user-attachments/assets/9b3240dd-81cb-4c20-bc4e-b2bf38698b2f) ## AI or LLM usage None ## Acknowledgements Some of the skeleton code for setting up the `DreamService` with the right lifecycle was copied and modified from the [official app's implementation](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt). --- app/src/main/AndroidManifest.xml | 11 + .../damontecres/wholphin/MainActivity.kt | 32 ++- .../wholphin/WholphinDreamService.kt | 98 ++++++++ .../wholphin/preferences/AppPreference.kt | 27 ++- .../preferences/AppPreferencesSerializer.kt | 20 ++ .../preferences/ScreensaverPreference.kt | 183 ++++++++++++++ .../wholphin/services/AppUpgradeHandler.kt | 17 ++ .../wholphin/services/ImageUrlService.kt | 25 +- .../wholphin/services/ScreensaverService.kt | 224 ++++++++++++++++++ .../services/UserPreferencesService.kt | 3 + .../damontecres/wholphin/ui/Extensions.kt | 15 +- .../wholphin/ui/components/AppScreensaver.kt | 214 +++++++++++++++++ .../wholphin/ui/nav/ApplicationContent.kt | 4 +- .../ui/playback/AmbientPlayerListener.kt | 32 --- .../wholphin/ui/playback/PlaybackPage.kt | 1 - .../wholphin/ui/playback/PlaybackViewModel.kt | 11 +- .../ui/preferences/ComposablePreference.kt | 10 +- .../ui/preferences/MultiChoicePreference.kt | 6 +- .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 46 +++- .../ui/preferences/PreferencesViewModel.kt | 2 + .../wholphin/ui/slideshow/SlideshowPage.kt | 9 - .../ui/slideshow/SlideshowViewModel.kt | 5 + app/src/main/proto/WholphinDataStore.proto | 11 + app/src/main/res/values/strings.xml | 25 ++ 25 files changed, 954 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 161352af..4479ae86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,17 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + + + + + + diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index b7f4b1d3..7b6f0c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,10 +3,12 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -49,6 +51,7 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager @@ -59,8 +62,10 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -134,6 +139,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + @Inject + lateinit var screensaverService: ScreensaverService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -317,6 +325,15 @@ class MainActivity : AppCompatActivity() { } }, ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } } } } @@ -324,10 +341,22 @@ class MainActivity : AppCompatActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (screensaverService.state.value.show) { + screensaverService.stop(false) + screensaverService.pulse() + return true + } else { + screensaverService.pulse() + return super.dispatchKeyEvent(event) + } + } + override fun onResume() { super.onResume() Timber.d("onResume") - lifecycleScope.launchIO { + lifecycleScope.launchDefault { + screensaverService.pulse() appUpgradeHandler.run() } } @@ -341,6 +370,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt new file mode 100644 index 00000000..1c0e4e0e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -0,0 +1,98 @@ +package com.github.damontecres.wholphin + +import android.service.dreams.DreamService +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@AndroidEntryPoint +class WholphinDreamService : + DreamService(), + SavedStateRegistryOwner { + @Inject + lateinit var screensaverService: ScreensaverService + + @Inject + lateinit var userPreferencesService: UserPreferencesService + + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val itemFlow = screensaverService.createItemFlow(lifecycleScope) + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@WholphinDreamService) + setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) + setContent { + var prefs by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + userPreferencesService.flow.collectLatest { prefs = it } + } + prefs?.let { prefs -> + WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { + val screensaverPrefs = + prefs.appPreferences.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 709abd6c..379b782b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -997,6 +997,12 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val ScreensaverSettings = + AppDestinationPreference( + title = R.string.screensaver_settings, + destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), + ) } } @@ -1011,6 +1017,7 @@ val basicPreferences = AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -1201,6 +1208,24 @@ val liveTvPreferences = AppPreference.LiveTvColorCodePrograms, ) +val screensaverPreferences = + listOf( + PreferenceGroup( + title = R.string.screensaver, + preferences = + listOf( + ScreensaverPreference.Enabled, + ScreensaverPreference.StartDelay, + ScreensaverPreference.Duration, + ScreensaverPreference.ShowClock, + ScreensaverPreference.Animate, + ScreensaverPreference.MaxAge, + ScreensaverPreference.ItemTypes, + ScreensaverPreference.Start, + ), + ), + ) + data class AppSwitchPreference( @get:StringRes override val title: Int, override val defaultValue: Boolean, @@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference( override val getter: (prefs: Pref) -> List, override val setter: (prefs: Pref, value: List) -> Pref, @param:StringRes val summary: Int? = null, - val toSharedPrefs: (T) -> String, - val fromSharedPrefs: (String) -> T?, ) : AppPreference> data class AppClickablePreference( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 6adcdca4..bf8ab2a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException +import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -120,6 +121,20 @@ class AppPreferencesSerializer colorCodePrograms = AppPreference.LiveTvColorCodePrograms.defaultValue }.build() + + screensaverPreference = + ScreensaverPreferences + .newBuilder() + .apply { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + showClock = ScreensaverPreference.ShowClock.defaultValue + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + }.build() }.build() advancedPreferences = @@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder photoPreferences = photoPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + screensaverPreference = screensaverPreference.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt new file mode 100644 index 00000000..1e1ad91c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.time.Duration.Companion.milliseconds + +object ScreensaverPreference { + val Enabled = + AppSwitchPreference( + title = R.string.in_app_screensaver, + defaultValue = false, + getter = { it.interfacePreferences.screensaverPreference.enabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + const val DEFAULT_START_DELAY = 15 * 60_000L + private val startDelayValues = + listOf( + 30_000L, + 60_000L, + 2 * 60_000L, + 5 * 60_000L, + 10 * 60_000L, + 15 * 60_000L, + 30 * 60_000L, + 60 * 60_000L, + ) + val StartDelay = + AppSliderPreference( + title = R.string.start_after, + defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), + min = 0, + max = startDelayValues.size - 1L, + interval = 1, + getter = { + startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + startDelay = startDelayValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY + v.milliseconds.toString() + } else { + null + } + }, + ) + + const val DEFAULT_DURATION = 30_000L + private val durationValues = + listOf( + 15_000L, + 30_000L, + 60_000L, + 2 * 60_000L, + ) + val Duration = + AppSliderPreference( + title = R.string.duration, + defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), + min = 0, + max = durationValues.size - 1L, + interval = 1, + getter = { + durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + duration = durationValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION + v.milliseconds.toString() + } else { + null + } + }, + ) + + val ShowClock = + AppSwitchPreference( + title = R.string.show_clock, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.showClock }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { showClock = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val Animate = + AppSwitchPreference( + title = R.string.animate, + defaultValue = true, + getter = { it.interfacePreferences.screensaverPreference.animate }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { animate = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + const val DEFAULT_MAX_AGE = 16 + private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) + val MaxAge = + AppSliderPreference( + title = R.string.max_age_rating, + defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), + min = 0, + max = maxAgeValues.size - 1L, + interval = 1, + getter = { + it.interfacePreferences.screensaverPreference.maxAgeFilter + .takeIf { it >= 0 } + ?.let { maxAgeValues.indexOf(it).toLong() } + ?: maxAgeValues.lastIndex.toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + maxAgeFilter = maxAgeValues[value.toInt()] + } + }, + summarizer = { value -> + when (value) { + null -> { + null + } + + maxAgeValues.lastIndex.toLong() -> { + WholphinApplication.instance.getString(R.string.no_max) + } + + 0L -> { + WholphinApplication.instance.getString(R.string.for_all_ages) + } + + else -> { + WholphinApplication.instance.getString( + R.string.up_to_age, + maxAgeValues[value.toInt()].toString(), + ) + } + } + }, + ) + + val ItemTypes = + AppMultiChoicePreference( + title = R.string.include_types, + summary = R.string.include_types_summary, + defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), + displayValues = R.array.screensaver_item_types, + getter = { + it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> + BaseItemKind.fromName(type) + } + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + clearItemTypes() + addAllItemTypes(value.map { it.serialName }) + } + }, + ) + + val Start = + AppClickablePreference( + title = R.string.start_screensaver, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 057ec550..23d6cef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences @@ -16,11 +17,13 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.io.File import javax.inject.Inject @@ -246,4 +249,18 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index f0de9d24..5e6bfa8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -37,9 +37,7 @@ class ImageUrlService fillHeight: Int? = null, ): String? = when (imageType) { - ImageType.BACKDROP, - ImageType.LOGO, - -> { + ImageType.LOGO -> { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { getItemImageUrl( itemId = seriesId, @@ -57,6 +55,27 @@ class ImageUrlService } } + ImageType.BACKDROP, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (backdropTags.isNotEmpty()) { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } + ImageType.THUMB -> { if (useSeriesForPrimary && parentThumbId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt new file mode 100644 index 00000000..ea806436 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -0,0 +1,224 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.view.WindowManager +import coil3.imageLoader +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.components.CurrentItem +import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class ScreensaverService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val scope: CoroutineScope, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) + val state: StateFlow = _state + + private var waitJob: Job? = null + + init { + userPreferencesService.flow + .onEach { prefs -> + _state.update { + val enabled = + prefs.appPreferences.interfacePreferences.screensaverPreference.enabled + keepScreenOnInternal(enabled) + ScreensaverState(enabled, false, false, false) + } + }.launchIn(scope) + } + + fun pulse() { + waitJob?.cancel() + if (_state.value.enabled) { +// Timber.v("pulse") + _state.update { + if (!it.active) { + it.copy(active = false) + } else { + it + } + } + + if (!_state.value.paused) { + waitJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + _state.update { + it.copy(active = true) + } + } + } + } + } + + fun start() { + _state.update { + it.copy( + enabledTemp = true, + active = true, + ) + } + } + + fun stop(cancelJob: Boolean) { + _state.update { + it.copy( + enabledTemp = false, + active = false, + ) + } + if (cancelJob) waitJob?.cancel() + } + + fun keepScreenOn(keep: Boolean) { + scope.launch { + val screensaverEnabled = _state.value.enabled + Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) + if (screensaverEnabled) { + // Page is requesting to keep screen on, so we don't wait to show the screensaver + _state.update { + it.copy(active = false, paused = keep) + } + if (!keep) { + pulse() + } + } else { + keepScreenOnInternal(keep) + } + } + } + + private suspend fun keepScreenOnInternal(keep: Boolean) = + withContext(Dispatchers.Main) { + val window = MainActivity.instance.window + if (keep) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + fun createItemFlow(scope: CoroutineScope): Flow = + flow { + val prefs = + userPreferencesService.flow + .first() + .appPreferences + .interfacePreferences.screensaverPreference + val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } + val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } + val request = + GetItemsRequest( + recursive = true, + includeItemTypes = itemTypes, + imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), + sortBy = listOf(ItemSortBy.RANDOM), + maxOfficialRating = maxAge?.toString(), + hasParentalRating = maxAge?.let { true }, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() + Timber.v("Got %s items", pager.size) + var index = 0 + if (pager.isEmpty()) { + emit(null) + } else { + while (true) { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { + val result = + context.imageLoader + .enqueue( + ImageRequest + .Builder(context) + .data(backdropUrl) + .build(), + ).job + .await() + try { + emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + } catch (_: CancellationException) { + break + } + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds + delay(duration) + } + } + index++ + } + } + }.cancellable() + } + +data class ScreensaverState( + val enabled: Boolean, + val enabledTemp: Boolean, + val active: Boolean, + val paused: Boolean, +) { + val show get() = (enabled || enabledTemp) && active && !paused +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 90d68e52..67e940b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +16,8 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, ) { + val flow = preferencesDataStore.data.map { UserPreferences(it) } + suspend fun getCurrent(): UserPreferences = serverRepository.currentUserDto.value!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index c997efd1..f6439bf1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -296,7 +295,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + * Tries to find the [Activity] for the given [Context] */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -310,18 +309,6 @@ fun Context.findActivity(): Activity? { return null } -/** - * Keep the screen on for an [Activity]. Often used with [findActivity]. - */ -fun Activity.keepScreenOn(keep: Boolean) { - Timber.v("Keep screen on: $keep") - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - /** * Selectively log errors from Coil image loading. * diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt new file mode 100644 index 00000000..e55a2aa6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -0,0 +1,214 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ScreensaverViewModel + @Inject + constructor( + private val screensaverService: ScreensaverService, + val preferencesDataStore: DataStore, + ) : ViewModel() { + val currentItem = screensaverService.createItemFlow(viewModelScope) + } + +data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, +) + +@Composable +fun AppScreensaver( + prefs: AppPreferences, + modifier: Modifier = Modifier, + viewModel: ScreensaverViewModel = hiltViewModel(), +) { + val currentItem by viewModel.currentItem.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = prefs.interfacePreferences.screensaverPreference.showClock, + duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, + animate = prefs.interfacePreferences.screensaverPreference.animate, + modifier = modifier, + ) +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun AppScreensaverContent( + currentItem: CurrentItem?, + showClock: Boolean, + duration: Duration, + animate: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier + .background(Color.Black), + ) { + val infiniteTransition = rememberInfiniteTransition() + val scale by infiniteTransition.animateFloat( + 1f, + if (animate) 1.1f else 1f, + infiniteRepeatable( + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + delayMillis = 500, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.backdropUrl) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + + var logoError by remember(currentItem) { mutableStateOf(false) } + val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random() + if (!logoError) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.logoUrl) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .build(), + contentDescription = "Logo", + onError = { + logoError = true + }, + modifier = + Modifier + .align(alignment) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(alignment) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(alignment), + ) + } + } + + val largeRadialGradient = + remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val biggerDimension = maxOf(size.height, size.width) + return RadialGradientShader( + colors = listOf(Color.Transparent, AppColors.TransparentBlack25), + center = size.center, + radius = biggerDimension / 1.5f, + colorStops = listOf(0f, 0.85f), + ) + } + } + } + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = largeRadialGradient, + blendMode = BlendMode.Multiply, + ) + if (showClock) { + // Add scrim to make clock more readable + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + } + if (showClock) { + TimeDisplay() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 0e3d5a43..5f01f20d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -55,8 +55,8 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) -private const val TOP_SCRIM_ALPHA = 0.55f -private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height +const val TOP_SCRIM_ALPHA = 0.55f +const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height @HiltViewModel class ApplicationContentViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt deleted file mode 100644 index 7fc97366..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.Player -import androidx.media3.common.Player.Listener -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn - -/** - * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback - * - * This will clean up the listener when disposed - */ -@Composable -fun AmbientPlayerListener(player: Player) { - val context = LocalContext.current - DisposableEffect(player) { - val listener = - object : Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - context.findActivity()?.keepScreenOn(isPlaying) - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - context.findActivity()?.keepScreenOn(false) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 0313a8c5..1459736d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -186,7 +186,6 @@ fun PlaybackPageContent( } } - AmbientPlayerListener(player) var contentScale by remember(playerBackend) { mutableStateOf( if (playerBackend == PlayerBackend.MPV) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4d44ff80..0d23f4bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -140,6 +141,7 @@ class PlaybackViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val imageUrlService: ImageUrlService, + private val screensaverService: ScreensaverService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -189,7 +191,10 @@ class PlaybackViewModel init { viewModelScope.launchIO { - addCloseable { disconnectPlayer() } + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } init() } } @@ -1424,6 +1429,10 @@ class PlaybackViewModel } } } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + screensaverService.keepScreenOn(isPlaying) + } } data class PlayerState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 9b9bcf9c..1762f916 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import java.io.File -import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -226,7 +225,7 @@ fun ComposablePreference( } is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues).toSortedSet() + val values = stringArrayResource(preference.displayValues) val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -237,12 +236,15 @@ fun ComposablePreference( list } MultiChoicePreference( - possibleValues = values as SortedSet, + possibleValues = preference.allValues, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(selectedValues.toList() as T) + onValueChange.invoke(it.toList() as T) + }, + valueDisplay = { index, _ -> + Text(values[index]) }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index a4686b10..bb34207f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun MultiChoicePreference( title: String, summary: String?, - possibleValues: Set, + possibleValues: List, selectedValues: Set, onValueChange: (List) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -59,7 +59,7 @@ fun MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(item) }, + headlineContent = { valueDisplay.invoke(index, item) }, trailingContent = { Switch( checked = selectedValues.contains(item), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index c9f6f529..454a0266 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -35,6 +35,7 @@ enum class PreferenceScreenOption { ADVANCED, EXO_PLAYER, MPV, + SCREENSAVER, ; companion object { 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 b04293f8..d9f1fb4d 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 @@ -50,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -128,6 +130,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences + PreferenceScreenOption.SCREENSAVER -> screensaverPreferences } val screenTitle = when (preferenceScreenOption) { @@ -135,6 +138,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options + PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings } var visible by remember { mutableStateOf(false) } @@ -283,7 +287,9 @@ fun PreferencesContent( if (movementSounds) playOnClickSound(context) if (release != null && updateAvailable) { release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) + viewModel.navigationManager.navigateTo( + Destination.UpdateApp, + ) } } else { updateVM.init(preferences.updateUrl) @@ -313,7 +319,8 @@ fun PreferencesContent( val summary = remember(cacheUsage) { cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val diskMB = + it.imageDiskUsed / AppPreference.MEGA_BIT val memoryUsedMB = it.imageMemoryUsed / AppPreference.MEGA_BIT val memoryMaxMB = @@ -392,7 +399,10 @@ fun PreferencesContent( seerrDialogMode = when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error(conn.serverUrl, conn.ex) + SeerrDialogMode.Error( + conn.serverUrl, + conn.ex, + ) } SeerrConnectionStatus.NotConfigured -> { @@ -409,9 +419,19 @@ fun PreferencesContent( modifier = Modifier, summary = when (seerrConnection) { - is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) - SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) - is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + is SeerrConnectionStatus.Error -> { + stringResource(R.string.voice_error_server) + } + + SeerrConnectionStatus.NotConfigured -> { + stringResource( + R.string.add_server, + ) + } + + is SeerrConnectionStatus.Success -> { + stringResource(R.string.enabled) + } }, onLongClick = {}, interactionSource = interactionSource, @@ -434,6 +454,19 @@ fun PreferencesContent( ) } + ScreensaverPreference.Start -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.screensaverService.start() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -597,6 +630,7 @@ fun PreferencesPage( PreferenceScreenOption.ADVANCED, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, + PreferenceScreenOption.SCREENSAVER, -> { PreferencesContent( initialPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index bec82e58..58f9a5e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO @@ -35,6 +36,7 @@ class PreferencesViewModel val preferenceDataStore: DataStore, val navigationManager: NavigationManager, val backdropService: BackdropService, + val screensaverService: ScreensaverService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 1c121ef6..f35bc102 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -63,8 +62,6 @@ import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad @@ -208,12 +205,6 @@ fun SlideshowPage( LaunchedEffect(slideshowActive) { player.repeatMode = if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - context.findActivity()?.keepScreenOn(slideshowActive) - } - DisposableEffect(Unit) { - onDispose { - context.findActivity()?.keepScreenOn(false) - } } var longPressing by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 4d2d0662..992659ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -67,6 +68,7 @@ class SlideshowViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val userPreferencesService: UserPreferencesService, + private val screensaverService: ScreensaverService, @Assisted val slideshowSettings: Destination.Slideshow, ) : ViewModel(), Player.Listener { @@ -110,6 +112,7 @@ class SlideshowViewModel init { addCloseable { + screensaverService.keepScreenOn(false) player.removeListener(this@SlideshowViewModel) player.release() } @@ -282,6 +285,7 @@ class SlideshowViewModel private var slideshowJob: Job? = null fun startSlideshow() { + screensaverService.keepScreenOn(true) _slideshow.update { SlideshowState(enabled = true, paused = false) } @@ -295,6 +299,7 @@ class SlideshowViewModel } fun stopSlideshow() { + screensaverService.keepScreenOn(false) slideshowJob?.cancel() _slideshow.update { SlideshowState(enabled = false, paused = false) diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 737dbb57..8d6034b1 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -144,6 +144,16 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +message ScreensaverPreferences{ + int64 start_delay = 1; + int64 duration = 2; + bool animate = 3; + int32 max_age_filter = 4; + bool enabled = 5; + repeated string item_types = 6; + bool show_clock = 7; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -155,6 +165,7 @@ message InterfacePreferences { LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; + ScreensaverPreferences screensaver_preference = 11; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93476b74..65afe072 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,11 @@ %d second %d seconds
+ + %s minutes + %s minute + %s minutes + Movies @@ -522,6 +527,19 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Start after + Animate + Max age rating + No max + For all ages + Up to age %s + Screensaver + Screensaver settings + Start screensaver + Duration + Photos + Include types + What types of items to show images for Fit Crop Fill @@ -673,10 +691,17 @@ No remote subtitles were found Present + Use in-app screensaver @string/backdrop_style_dynamic @string/backdrop_style_image @string/none + + @string/movies + @string/tv_shows + @string/photos + + From 9d857bc59e04e9b0d385e5d184bc0c8798fad591 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:08:09 -0500 Subject: [PATCH 040/118] Move new feature requests to discussions (#984) ## Description Update the issue templates to direct new feature requests to GitHub Discussions ### Related issues Closes #982 --- .../ideas.yml} | 4 +--- .github/ISSUE_TEMPLATE/00-bug.yml | 7 ++++--- .github/ISSUE_TEMPLATE/01-playback.yml | 8 +++++--- .github/ISSUE_TEMPLATE/03-question.yml | 15 --------------- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ 5 files changed, 18 insertions(+), 24 deletions(-) rename .github/{ISSUE_TEMPLATE/02-new-feature.yml => DISCUSSION_TEMPLATE/ideas.yml} (75%) delete mode 100644 .github/ISSUE_TEMPLATE/03-question.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 75% rename from .github/ISSUE_TEMPLATE/02-new-feature.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 500beea4..5d1b4346 100644 --- a/.github/ISSUE_TEMPLATE/02-new-feature.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,6 +1,4 @@ -name: "Feature Request" -description: Request a new feature -title: "[FEA] - " +title: "<title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index f67a7db7..2f4244a0 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,9 +1,10 @@ name: "Bug Report" description: Report a bug -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug" ] +type: "Bug" body: - type: textarea id: description @@ -28,7 +29,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -36,7 +37,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 2dc73ff6..978c1eba 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,9 +1,10 @@ name: "Playback Problem" description: Report an issue with playback -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug", "playback" ] +type: "Bug" body: - type: textarea id: description @@ -17,6 +18,7 @@ body: attributes: label: "Media info" description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) + placeholder: Use mediainfo or "Send media info to server" in the app to gather this validations: required: true - type: dropdown @@ -35,7 +37,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -43,7 +45,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml deleted file mode 100644 index bd10bd46..00000000 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Question" -description: Ask a question -title: "[QST] - <title>" -labels: [ - "question" -] -body: - - type: textarea - id: description - attributes: - label: "Question" - description: Please enter your question - placeholder: "How do I...?" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..1ba6fdae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: New feature / UI suggestion + url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas + about: Please request new features here + - name: Questions + url: https://github.com/damontecres/Wholphin/discussions/new?category=general + about: Please ask questions here From 138efcde004bcfe7b7972936d0b06a54f9debcf2 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 25 Feb 2026 15:09:46 -0500 Subject: [PATCH 041/118] Fix issue templates [skip ci] --- .github/ISSUE_TEMPLATE/00-bug.yml | 1 - .github/ISSUE_TEMPLATE/01-playback.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 2f4244a0..1aab31da 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -4,7 +4,6 @@ title: "<title>" labels: [ "bug" ] -type: "Bug" body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 978c1eba..34dc317b 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -4,7 +4,6 @@ title: "<title>" labels: [ "bug", "playback" ] -type: "Bug" body: - type: textarea id: description From 7f10b5a25ee6a4fe9c008aa5148041e371766770 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:15:02 -0500 Subject: [PATCH 042/118] Use Q&A for questions [skip ci] --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1ba6fdae..2ee91c39 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas about: Please request new features here - name: Questions - url: https://github.com/damontecres/Wholphin/discussions/new?category=general + url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a about: Please ask questions here From 37d52ef57e29c29fac53da92f707767ca88bf578 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:38:16 -0500 Subject: [PATCH 043/118] Update readme images to v0.5.1 --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1b3a0708..6a3c4c65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin </p> -<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> +![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) ## Features @@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ### Customized home page ![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) - ### Movie library browsing -<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> +![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) ### Movie page -<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> +![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) ### Series page -<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> +![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) + +### Genres in library +![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) + ### Playlist -<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> +![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) From 9b995bf6baf299fe87e1643fd8b07716b082c09b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:48:39 -0500 Subject: [PATCH 044/118] Fix some IO related crashes (#1008) ## Description - Catch network exceptions when querying to populate the nav drawer - Perform screensaver work on IO thread - Fix possible crash if screensaver service runs while regular activity is destroyed - Fix genre images & backdrop images not loading This PR also adds an Jellyfin SDK `ApiClient` wrapper to push all network calls onto the IO thread. And tries to use a more strict network-on-main policy for debug builds. ### Related issues Fixes #994 Fixes #1001 Should fix #1003 ### Testing Emulator with simulated exception throws ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 18 ++++ .../wholphin/WholphinApplication.kt | 28 +++--- .../wholphin/data/ServerRepository.kt | 4 +- .../wholphin/services/BackdropService.kt | 2 +- .../wholphin/services/ImageUrlService.kt | 4 +- .../wholphin/services/NavDrawerService.kt | 6 ++ .../wholphin/services/ScreensaverService.kt | 26 +++--- .../wholphin/services/hilt/AppModule.kt | 5 +- .../damontecres/wholphin/ui/Extensions.kt | 7 -- .../wholphin/ui/components/GenreCardGrid.kt | 1 + .../util/CoroutineContextApiClient.kt | 88 +++++++++++++++++++ 11 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 7b6f0c9b..5a6e446f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -79,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -165,6 +169,20 @@ class MainActivity : AppCompatActivity() { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } + screensaverService.keepScreenOn + .onEach { keepScreenOn -> + Timber.v("keepScreenOn: %s", keepScreenOn) + withContext(Dispatchers.Main) { + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + }.catch { ex -> + Timber.e(ex, "Error with keepScreenOn") + }.launchIn(lifecycleScope) + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index cf2c159a..900fc4c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi @@ -27,16 +26,6 @@ class WholphinApplication : init { instance = this - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .build(), - ) - } - if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { @@ -64,6 +53,23 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectNetwork() + .penaltyLog() + .penaltyDeathOnNetwork() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy + .Builder() + .detectAll() + .penaltyLog() + .build(), + ) + } OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index f013525b..610d0b88 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -42,8 +42,6 @@ class ServerRepository val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, ) { - private val sharedPreferences = getServerSharedPreferences(context) - private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -107,7 +105,7 @@ class ServerRepository _current.value = CurrentUser(updatedServer, updatedUser) _currentUserDto.value = userDto } - sharedPreferences.edit(true) { + getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index e082c5fa..e0c48cca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -52,7 +52,7 @@ class BackdropService if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } submit(item.id.toString(), imageUrl) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 5e6bfa8c..1dd642b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,11 +28,11 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, - imageTags: Map<ImageType, String?>, imageType: ImageType, + imageTags: Map<ImageType, String?>, + backdropTags: List<String>, parentThumbId: UUID? = null, parentBackdropId: UUID? = null, - backdropTags: List<String> = emptyList(), fillWidth: Int? = null, fillHeight: Int? = null, ): String? = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index adb21ff7..6b6111e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -11,11 +11,13 @@ import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -60,7 +62,11 @@ class NavDrawerService if (user != null && userDto != null && user.id == userDto.id) { updateNavDrawer(user, userDto) } + }.catch { ex -> + Timber.e(ex, "Error updating nav drawer") + showToast(context, "Error fetching user's views") }.launchIn(coroutineScope) + seerrServerRepository.active .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index ea806436..5d73ec21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -1,13 +1,12 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.view.WindowManager import coil3.imageLoader import coil3.request.ImageRequest -import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.components.CurrentItem import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -23,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.model.api.BaseItemKind @@ -52,6 +51,8 @@ class ScreensaverService private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) val state: StateFlow<ScreensaverState> = _state + val keepScreenOn = MutableStateFlow(false) + private var waitJob: Job? = null init { @@ -114,7 +115,7 @@ class ScreensaverService } fun keepScreenOn(keep: Boolean) { - scope.launch { + scope.launchDefault { val screensaverEnabled = _state.value.enabled Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) if (screensaverEnabled) { @@ -131,15 +132,9 @@ class ScreensaverService } } - private suspend fun keepScreenOnInternal(keep: Boolean) = - withContext(Dispatchers.Main) { - val window = MainActivity.instance.window - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } + private fun keepScreenOnInternal(keep: Boolean) { + keepScreenOn.update { keep } + } fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = flow { @@ -186,7 +181,7 @@ class ScreensaverService } val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) if (backdropUrl != null) { - val result = + try { context.imageLoader .enqueue( ImageRequest @@ -195,7 +190,6 @@ class ScreensaverService .build(), ).job .await() - try { emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) } catch (_: CancellationException) { break @@ -211,7 +205,7 @@ class ScreensaverService index++ } } - }.cancellable() + }.flowOn(Dispatchers.Default).cancellable() } data class ScreensaverState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 52c37413..546636e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -111,12 +112,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = OkHttpFactory(okHttpClient) + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) @Provides @Singleton fun jellyfin( - okHttpFactory: OkHttpFactory, + okHttpFactory: CoroutineContextApiClientFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index f6439bf1..865c6d29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -45,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -429,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } -@Composable -fun rememberBackDropImage(item: BaseItem): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } -} - /** * Check if this, coalescing nulls to zero, is greater than that */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e1e28f0c..d42eea5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -209,6 +209,7 @@ suspend fun getGenreImageMap( imageType = ImageType.BACKDROP, imageTags = item.imageTags.orEmpty(), fillWidth = cardWidthPx, + backdropTags = item.backdropImageTags.orEmpty(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt new file mode 100644 index 00000000..3ea6bdf9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.ApiClientFactory +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.HttpMethod +import org.jellyfin.sdk.api.client.RawResponse +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.api.sockets.SocketApi +import org.jellyfin.sdk.api.sockets.SocketConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import kotlin.coroutines.CoroutineContext + +/** + * Wraps [ApiClient.request] with the given [CoroutineContext] + */ +class CoroutineContextApiClient( + private val client: ApiClient, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClient() { + override val baseUrl: String? + get() = client.baseUrl + override val accessToken: String? + get() = client.accessToken + override val clientInfo: ClientInfo + get() = client.clientInfo + override val deviceInfo: DeviceInfo + get() = client.deviceInfo + override val httpClientOptions: HttpClientOptions + get() = client.httpClientOptions + override val webSocket: SocketApi + get() = client.webSocket + + override fun update( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) { + client.update(baseUrl, accessToken, clientInfo, deviceInfo) + } + + override suspend fun request( + method: HttpMethod, + pathTemplate: String, + pathParameters: Map<String, Any?>, + queryParameters: Map<String, Any?>, + requestBody: Any?, + ): RawResponse = + withContext(coroutineContext) { + client.request( + method, + pathTemplate, + pathParameters, + queryParameters, + requestBody, + ) + } +} + +class CoroutineContextApiClientFactory( + private val factory: OkHttpFactory, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClientFactory, + SocketConnectionFactory { + override fun create( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, + ): ApiClient = + CoroutineContextApiClient( + factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), + coroutineContext, + ) + + override fun create( + clientOptions: HttpClientOptions, + scope: CoroutineScope, + ): SocketConnection = factory.create(clientOptions, scope) +} From b65e55f4ed6c9a6ef428d74bd801b365e56e206a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:27:22 -0500 Subject: [PATCH 045/118] A few small UI fixes (#1012) ## Description - Restores corner text for episode cards - Always show end time on playback overlay instead of it being controlled by the "Show Clock" setting - Better logic to determine person's role in media ### Related issues Fixes #1010 Fixes #1004 Fixes #975 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 6 ++- .../damontecres/wholphin/data/model/Person.kt | 51 ++++++++++++++++++- .../wholphin/services/PeopleFavorites.kt | 12 ++++- .../ui/detail/series/SeriesViewModel.kt | 2 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 41 ++++++++------- app/src/main/res/values/strings.xml | 13 +++++ 6 files changed, 99 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 950e9b40..e88673c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -95,7 +95,11 @@ data class BaseItem( data.indexNumber?.let { "E$it" } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = - if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) { + if (type == BaseItemKind.SERIES || + type == BaseItemKind.SEASON || + type == BaseItemKind.EPISODE || + type == BaseItemKind.BOX_SET + ) { data.indexNumber?.let { "E$it" } ?: data.userData ?.unplayedItemCount diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index c12d37b5..ac4a4710 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -19,19 +23,21 @@ data class Person( ) { companion object { fun fromDto( + context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = false, ) fun fromDto( + context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -39,10 +45,51 @@ data class Person( Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = favorite, ) } } + +private fun personRole( + context: Context, + role: String?, + type: PersonKind, +): String? = + if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { + role + } else if (role.equals(type.name, ignoreCase = true)) { + type.stringRes?.let { context.getString(it) } + } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { + role + } else { + listOfNotNull( + role?.takeIf { it.isNotNullOrBlank() }, + type.stringRes?.let { context.getString(it) }, + ).takeIf { it.isNotEmpty() }?.joinToString(" - ") + } + +@get:StringRes +private val PersonKind.stringRes: Int? + get() = + when (this) { + PersonKind.UNKNOWN -> R.string.unknown + PersonKind.ACTOR -> R.string.actor + PersonKind.DIRECTOR -> R.string.director + PersonKind.COMPOSER -> R.string.composer + PersonKind.WRITER -> R.string.writer + PersonKind.GUEST_STAR -> R.string.guest_star + PersonKind.PRODUCER -> R.string.producer + PersonKind.CONDUCTOR -> R.string.conductor + PersonKind.LYRICIST -> R.string.lyricist + PersonKind.ARRANGER -> R.string.arranger + PersonKind.ENGINEER -> R.string.engineer + PersonKind.MIXER -> R.string.mixer + PersonKind.REMIXER -> R.string.mixer + PersonKind.CREATOR -> R.string.creator + PersonKind.ARTIST -> R.string.artist + PersonKind.ALBUM_ARTIST -> R.string.artist + else -> null + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index b4967b03..6a35dda5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.services +import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty +import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject @@ -12,6 +14,7 @@ import javax.inject.Singleton class PeopleFavorites @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -27,7 +30,14 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } + people.map { + Person.fromDto( + context, + it, + favorites[it.id] ?: false, + api, + ) + } }.orEmpty() people } 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 3d80aa9d..a299fe3c 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 @@ -528,7 +528,7 @@ class SeriesViewModel api.userLibraryApi .getItem(item.id) .content.people - ?.map { Person.fromDto(it, api) } + ?.map { Person.fromDto(context, it, api) } .orEmpty() PeopleInItem(item.id, list) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index b1500b60..0886590e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -612,29 +612,28 @@ fun Controller( fontSize = subtitleTextSize, ) } - if (showClock) { - var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(playerControls) { - while (isActive) { - val remaining = - (playerControls.duration - playerControls.currentPosition) - .div(playerControls.playbackParameters.speed) - .toLong() - .milliseconds - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) - delay(1.seconds) - } + + var endTimeStr by remember { mutableStateOf("...") } + LaunchedEffect(playerControls) { + while (isActive) { + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) + delay(1.seconds) } - Text( - text = "Ends $endTimeStr", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) } + Text( + text = "Ends $endTimeStr", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) } } // TODO need to move these up a level? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65afe072..967d6922 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,4 +704,17 @@ <item>@string/photos</item> </string-array> + <string name="actor">Actor</string> + <string name="composer">Composer</string> + <string name="writer">Writer</string> + <string name="guest_star">Guest star</string> + <string name="producer">Producer</string> + <string name="conductor">Conductor</string> + <string name="lyricist">Lyricist</string> + <string name="arranger">Arranger</string> + <string name="engineer">Engineer</string> + <string name="mixer">Mixer</string> + <string name="creator">Creator</string> + <string name="artist">Artist</string> + </resources> From 55466f803cdcb89fbdd9d7315b2bfe6c02957082 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:00:44 -0500 Subject: [PATCH 046/118] Better animation to show/hide headers on grid pages (#1015) ## Description Changes the animation for showing/hiding the header title/buttons on grid pages. This eliminates the awkward jump the grid does to fill the screen. Instead it smoothly resizes. ### Related issues Fixes the second item in #221 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/CollectionFolderGrid.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderLiveTv.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderMovie.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderTv.kt | 10 ++++------ .../damontecres/wholphin/ui/detail/FavoritesPage.kt | 10 ++++------ .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 8 +++++++- .../damontecres/wholphin/ui/discover/DiscoverPage.kt | 10 ++++------ 7 files changed, 31 insertions(+), 37 deletions(-) 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 4a71404a..1a33118d 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 @@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.LocalBringIntoViewSpec @@ -802,8 +800,8 @@ fun CollectionFolderGridContent( ) { AnimatedVisibility( showHeader || loadingState !is DataLoadingState.Success, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index dc426409..0d9bd338 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -127,8 +125,8 @@ fun CollectionFolderLiveTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 96ddbe94..6f67b011 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -76,8 +74,8 @@ fun CollectionFolderMovie( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index d72001ba..68b9dd18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -80,8 +78,8 @@ fun CollectionFolderTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index fc337d88..50b0053e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -98,8 +96,8 @@ fun FavoritesPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 037a5ae7..30309304 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -140,7 +142,11 @@ fun TvGuideGrid( .fillMaxHeight(.30f), ) } - AnimatedVisibility(focusedPosition.row < 1) { + AnimatedVisibility( + focusedPosition.row < 1, + enter = expandVertically(), + exit = shrinkVertically(), + ) { ExpandableFaButton( title = R.string.view_options, iconStringRes = R.string.fa_sliders, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 53b04161..5c18a0ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -60,8 +58,8 @@ fun DiscoverPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, From fd9063fbad8a2bf933058ba2b240de52c444324d Mon Sep 17 00:00:00 2001 From: voc0der <contact@netspace.in> Date: Sat, 28 Feb 2026 19:35:02 +0000 Subject: [PATCH 047/118] Hold-to-seek acceleration for playback DPAD and seekbar (#900) ## Description - Adds hold-to-seek acceleration for left/right DPAD input during playback skip handling. - Reuses one shared, duration-aware acceleration profile for both playback DPAD and seekbar skipping. - Extracts acceleration logic into a shared helper to keep playback and seekbar behavior consistent. - Slows the acceleration ramp (about one-third) to improve control during longer holds. - Preserves tap-on-release behavior: single taps seek on key release, while holds seek on repeated key-down without an extra release step. - Normalizes unknown/unset duration values to use the shortest-content acceleration profile. - Added unit coverage for shared seek acceleration math to prevent accidental regression in ramp thresholds. ### Related issues Supersedes #846 Incorporates #784 Closes #522 ### Testing Tested on Nvidia Shield Pro 2019. - Quick left/right tap still performs normal skip behavior. - Holding left/right seeks repeatedly with progressive acceleration. - Seekbar hold uses the same acceleration profile as playback hold. - Releasing after a hold does not trigger an extra seek step. ## AI or LLM usage Codex 5.3 and Claude Opus 4.6 were used for implementation, debugging, and cross-review. Changes were manually validated on device. --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../ui/playback/PlaybackKeyHandler.kt | 96 +++++++++++++++- .../wholphin/ui/playback/PlaybackPage.kt | 1 + .../wholphin/ui/playback/SeekAcceleration.kt | 52 +++++++++ .../wholphin/ui/playback/SeekBar.kt | 104 ++++++++++++++---- .../ui/playback/SeekAccelerationTest.kt | 53 +++++++++ 5 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index f10a0758..5ed82729 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -20,6 +20,7 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, + private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, private val skipBackOnResume: Duration?, @@ -28,15 +29,22 @@ class PlaybackKeyHandler( private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, ) { + private var leftHandledByRepeat = false + private var rightHandledByRepeat = false + fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - var result = true if (!controlsEnabled) { - result = false + return false + } else if (handleHoldSkip(it)) { + return true } else if (it.type != KeyEventType.KeyUp) { - result = false - } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + return false + } + + var result = true + if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { updateSkipIndicator(-seekBack.inWholeMilliseconds) @@ -111,4 +119,84 @@ class PlaybackKeyHandler( } return result } + + private fun handleHoldSkip(event: KeyEvent): Boolean { + if ( + controllerViewState.controlsVisible || + !skipWithLeftRight || + (!isSkipBack(event) && !isSkipForward(event)) + ) { + return false + } + + val isBack = isSkipBack(event) + return when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + setHandledByRepeat(isBack = isBack, handled = false) + return true + } + val multiplier = + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = normalizedDurationMs(), + ) + setHandledByRepeat(isBack = isBack, handled = true) + seekWithMultiplier(isBack = isBack, multiplier = multiplier) + } else { + setHandledByRepeat(isBack = isBack, handled = false) + } + true + } + + KeyEventType.KeyUp -> { + if (!handledByRepeat(isBack = isBack)) { + seekWithMultiplier(isBack = isBack, multiplier = 1) + } + setHandledByRepeat(isBack = isBack, handled = false) + true + } + + else -> { + false + } + } + } + + private fun seekWithMultiplier( + isBack: Boolean, + multiplier: Int, + ) { + if (isBack) { + val skipDuration = seekBack * multiplier + player.seekBack(skipDuration) + updateSkipIndicator(-skipDuration.inWholeMilliseconds) + } else { + val skipDuration = seekForward * multiplier + player.seekForward(skipDuration) + updateSkipIndicator(skipDuration.inWholeMilliseconds) + } + } + + private fun setHandledByRepeat( + isBack: Boolean, + handled: Boolean, + ) { + if (isBack) { + leftHandledByRepeat = handled + } else { + rightHandledByRepeat = handled + } + } + + private fun handledByRepeat(isBack: Boolean): Boolean = + if (isBack) { + leftHandledByRepeat + } else { + rightHandledByRepeat + } + + private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 1459736d..3b9958b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -235,6 +235,7 @@ fun PlaybackPageContent( skipWithLeftRight = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt new file mode 100644 index 00000000..1349a642 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.playback + +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12 + +/** + * Shared seek acceleration profile for hold-to-seek behavior. + * Keep this in sync anywhere directional key repeat seeking is handled. + */ +fun calculateSeekAccelerationMultiplier( + repeatCount: Int, + durationMs: Long, +): Int { + if (repeatCount <= 0 || durationMs <= 0L) return 1 + + // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. + val scaledRepeatCount = repeatCount / 3 + if (scaledRepeatCount <= 0) return 1 + + val durationMinutes = durationMs / 60_000L + return when { + durationMinutes < 30 -> { + if (scaledRepeatCount < 30) 1 else 2 + } + + durationMinutes < 90 -> { + when { + scaledRepeatCount < 13 -> 1 + scaledRepeatCount < 50 -> 2 + scaledRepeatCount < 75 -> 3 + else -> 4 + } + } + + durationMinutes < 150 -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 2 + scaledRepeatCount < 60 -> 4 + else -> 6 + } + } + + else -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 3 + scaledRepeatCount < 60 -> 6 + else -> 10 + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index d1db4144..e34d91af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview import kotlin.time.Duration @@ -80,15 +79,16 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) + seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) + seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -126,16 +126,19 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) + seekPositionMs = + (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds * multiplier) + .coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -148,8 +151,9 @@ fun IntervalSeekBarImpl( fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - onLeft: () -> Unit, - onRight: () -> Unit, + durationMs: Long, + onLeft: (Int) -> Unit, + onRight: (Int) -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -158,14 +162,13 @@ fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() + var leftHandledByRepeat by remember { mutableStateOf(false) } + var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { Canvas( modifier = Modifier @@ -173,24 +176,79 @@ fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - val trigger = - event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 when (event.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - if (trigger) onLeft.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + leftHandledByRepeat = false + return@onPreviewKeyEvent true + } + leftHandledByRepeat = true + onLeft.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = durationMs, + ), + ) + } else { + leftHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) + } + leftHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - if (trigger) onRight.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + rightHandledByRepeat = false + return@onPreviewKeyEvent true + } + rightHandledByRepeat = true + onRight.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = durationMs, + ), + ) + } else { + rightHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } } false - }.handleDPadKeyEvents( - onLeft = onLeft, - onRight = onRight, - ).focusable(enabled = enabled, interactionSource = interactionSource), + }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt new file mode 100644 index 00000000..6a6037f9 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeekAccelerationTest { + @Test + fun returnsOneWhenNotRepeating() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) + } + + @Test + fun unknownDurationDoesNotAccelerate() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) + } + + @Test + fun shortContentHasTwoTiers() { + val shortDurationMs = 20L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), + ) + } + + @Test + fun mediumContentEscalatesAcrossAllTiers() { + val mediumDurationMs = 60L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), + ) + assertEquals( + 3, + calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), + ) + assertEquals( + 4, + calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), + ) + } +} From 0b73b4cf642a3ee9d5ff996827b6581fb887f02d Mon Sep 17 00:00:00 2001 From: Justin Caveda <justincaveda11031@gmail.com> Date: Sun, 1 Mar 2026 15:59:44 -0600 Subject: [PATCH 048/118] Fix: optimize suggestions performance (#849) ## Description - Aims to further optimize performance for the suggestions fetching ### Related issues Fixes #833 ### Testing Android Emulator NVIDIA Shield Pro 2019 ## Screenshots N/A ## AI or LLM usage AI being used to assist with updating/creating unit tests --- .../services/SuggestionsSchedulerService.kt | 35 ++++++++++++++++- .../wholphin/services/SuggestionsWorker.kt | 12 ++++-- .../SuggestionsSchedulerServiceTest.kt | 39 +++++++++++++++++-- .../services/SuggestionsWorkerTest.kt | 1 + 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index edca582b..86bab2f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -8,6 +8,7 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository @@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.util.UUID import javax.inject.Inject +import kotlin.random.Random import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -31,7 +34,6 @@ class SuggestionsSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, - private val cache: SuggestionsCache, private val workManager: WorkManager, ) { private val activity = @@ -42,6 +44,7 @@ class SuggestionsSchedulerService // Exposed for testing internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } init { serverRepository.current.observe(activity) { user -> @@ -60,6 +63,28 @@ class SuggestionsSchedulerService userId: UUID, serverId: UUID, ) { + val workInfos = + withContext(dispatcher) { + workManager + .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) + .get() + } + val activeWork = + workInfos.firstOrNull { + !it.state.isFinished + } + val scheduledUserId = + activeWork + ?.tags + ?.firstOrNull { it.startsWith("user:") } + ?.removePrefix("user:") + + val isAlreadyScheduledForUser = scheduledUserId == userId.toString() + if (isAlreadyScheduledForUser) { + Timber.d("SuggestionsWorker already scheduled for user %s", userId) + return + } + val constraints = Constraints .Builder() @@ -75,12 +100,18 @@ class SuggestionsSchedulerService val periodicWorkRequestBuilder = PeriodicWorkRequestBuilder<SuggestionsWorker>( repeatInterval = 12.hours.toJavaDuration(), + flexTimeInterval = 1.hours.toJavaDuration(), ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - .setInitialDelay(60.seconds.toJavaDuration()) + .addTag("user:$userId") + + val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) + periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) + + Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index eab87f20..4560f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -99,11 +99,17 @@ class SuggestionsWorker itemsPerRow, ) ensureActive() + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, view.id, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) + return@runCatching + } cache.put( userId, view.id, itemKind, - suggestions.map { it.id }, + newIds, ) }.onFailure { e -> Timber.e( @@ -242,7 +248,7 @@ class SuggestionsWorker GetItemsRequest( parentId = parentId, userId = userId, - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, + fields = extraFields, includeItemTypes = listOf(itemKind), genreIds = genreIds, recursive = true, @@ -252,7 +258,7 @@ class SuggestionsWorker sortOrder = sortOrder?.let { listOf(it) }, limit = limit, enableTotalRecordCount = false, - imageTypeLimit = 1, + imageTypeLimit = 0, ) return GetItemsRequestHandler .execute(api, request) diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 7da1d63c..0b48a011 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.google.common.util.concurrent.ListenableFuture import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest { private val currentLiveData = MutableLiveData<CurrentUser?>() private val mockActivity = mockk<AppCompatActivity>(relaxed = true) private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockCache = mockk<SuggestionsCache>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) @@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest { SuggestionsSchedulerService( context = mockActivity, serverRepository = mockServerRepository, - cache = mockCache, workManager = mockWorkManager, - ).also { it.dispatcher = testDispatcher } + ).also { + it.dispatcher = testDispatcher + it.initialDelaySecondsProvider = { 60L } + } + + private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { + @Suppress("UNCHECKED_CAST") + val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() + every { future.get() } returns infos + every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future + } @Test fun schedules_periodic_work_when_user_present() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest { verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } + + @Test + fun does_not_schedule_if_already_scheduled_for_same_user() = + runTest { + val userId = UUID.randomUUID() + val workInfo = mockk<androidx.work.WorkInfo>() + every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED + every { workInfo.tags } returns setOf("user:$userId") + mockWorkInfos(listOf(workInfo)) + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 7d633d87..5679777e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -46,6 +46,7 @@ class SuggestionsWorkerTest { every { mockApi.userViewsApi } returns mockUserViewsApi every { mockApi.baseUrl } returns "http://localhost" every { mockApi.accessToken } returns "test-token" + coEvery { mockCache.get(any(), any(), any()) } returns null } @After From 8935067c5a966abdc4085bf2de0d17b1433774d8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:38:41 -0500 Subject: [PATCH 049/118] Add option to delete media (#1014) ## Description Adds a setting to enable "Media management" which adds a context menu item to delete media. This is accessible using either the More button or long clicking. You can delete episodes, seasons, series and movies/videos. If order for the option to appear the current user must have server-side settings for "Allow media deletion from" enabled for the library and the in-app setting under Settings->Advanced Settings->"Show media management options". ### Related issues Closes #104 ### Testing Emulator ## Screenshots ### Context menu ![delete_context_2 Large](https://github.com/user-attachments/assets/cabc3c24-1b43-4f88-8d71-b7275f9eadc4) ### Confirmation ![delete_confirm Large](https://github.com/user-attachments/assets/8036dde1-8a51-424b-bbdd-f2826dd3e0ab) ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 14 +- .../wholphin/data/model/BaseItem.kt | 2 + .../wholphin/preferences/AppPreference.kt | 13 ++ .../services/MediaManagementService.kt | 104 +++++++++++++ .../wholphin/services/NavDrawerService.kt | 6 +- .../wholphin/services/hilt/AppModule.kt | 2 +- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../ui/components/CollectionFolderGrid.kt | 16 ++ .../wholphin/ui/components/Dialogs.kt | 144 ++++++++++++++++-- .../wholphin/ui/components/PlayButtons.kt | 63 +++++++- .../wholphin/ui/detail/DetailUtils.kt | 25 +++ .../wholphin/ui/detail/movie/MovieDetails.kt | 15 ++ .../ui/detail/movie/MovieViewModel.kt | 13 ++ .../ui/detail/series/SeriesDetails.kt | 100 +++++++++--- .../ui/detail/series/SeriesOverview.kt | 47 ++++-- .../ui/detail/series/SeriesViewModel.kt | 65 +++++++- .../wholphin/util/ApiRequestPager.kt | 16 ++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 19 files changed, 597 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 900fc4c3..ceb7264e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -62,13 +62,13 @@ class WholphinApplication : .penaltyDeathOnNetwork() .build(), ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy - .Builder() - .detectAll() - .penaltyLog() - .build(), - ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) } OkHttp.initialize(this) initAcra { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index e88673c1..3c34851b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -72,6 +72,8 @@ data class BaseItem( } } + val canDelete: Boolean get() = data.canDelete == true + @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 379b782b..ef3f14e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference<AppPreferences>( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference<AppPreferences>( title = R.string.one_click_pause, @@ -1110,6 +1122,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ManageMedia, AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, 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 new file mode 100644 index 00000000..f3aff8c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow<DeletedItem>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + // TODO enable + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 6b6111e4..b83f0bd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -149,7 +151,9 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + withContext(Dispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } val items = mutableListOf<NavDrawerItem>() val moreItems = mutableListOf<NavDrawerItem>() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 546636e5..e3e42efa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -185,7 +185,7 @@ object AppModule { @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @Provides @Singleton diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 2d9b6d5e..ab0f69f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -58,6 +58,7 @@ val DefaultItemFields = ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) /** @@ -72,6 +73,7 @@ val SlimItemFields = ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) val PhotoItemFields = 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 1a33118d..6eae4f26 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 @@ -60,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo 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.ThemeSongPlayer @@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.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.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination @@ -128,6 +130,7 @@ class CollectionFolderViewModel private val navigationManager: NavigationManager, private val themeSongPlayer: ThemeSongPlayer, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -199,6 +202,19 @@ 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) + } + } + } + } } private fun saveLibraryDisplayInfo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 579d9a97..a1f2362a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,31 +271,65 @@ fun DialogPopupContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { if (dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == 0, + Modifier.onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(dialogItems.lastIndex) + focusRequesters[dialogItems.lastIndex].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + index == dialogItems.lastIndex, + Modifier.onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + focusRequesters[0].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ), ) } } @@ -471,6 +517,80 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + fun chooseVersionParams( context: Context, sources: List<MediaSourceInfo>, 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 17b84dee..570d411e 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 @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource @@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -234,7 +241,7 @@ fun ExpandablePlayButton( fun ExpandablePlayButton( @StringRes title: Int, resume: Duration, - icon: @Composable () -> Unit, + icon: @Composable BoxScope.() -> Unit, onClick: (position: Duration) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -254,12 +261,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - icon.invoke() + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -359,6 +367,48 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { onClick.invoke() }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -417,12 +467,19 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onClick = {}, + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onClick = {}, + interactionSource = source, + ) } } } 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 cebfff70..645ba2ce 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,6 +29,7 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, + val onClickDelete: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -62,6 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -139,6 +141,17 @@ fun buildMoreDialogItems( actions.onClickAddPlaylist.invoke(item.id) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, ): List<DialogItem> = buildList { @@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome( actions.onClickAddPlaylist.invoke(itemId) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, 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 2a40a399..ada15158 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 @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -111,6 +112,7 @@ fun MovieDetails( var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val moreActions = MoreDialogActions( @@ -126,6 +128,7 @@ fun MovieDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -201,6 +204,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -291,6 +295,7 @@ fun MovieDetails( playbackPosition = similar.playbackPosition, watched = similar.played, favorite = similar.favorite, + canDelete = false, actions = moreActions, ) moreDialog = @@ -362,6 +367,16 @@ fun MovieDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index ed46656c..993260dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService 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.PeopleFavorites @@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -73,6 +75,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -90,6 +93,9 @@ class MovieViewModel val chosenStreams = MutableLiveData<ChosenStreams?>(null) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + var canDelete: Boolean = false + private set + init { init() } @@ -106,6 +112,7 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } + canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } @@ -274,4 +281,10 @@ class MovieViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } 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 579a3866..222f7f77 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -24,12 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.DeleteButton import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -76,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -102,21 +108,25 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() + val canDelete by viewModel.canDeleteSeries.collectAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } when (val state = loading) { is LoadingState.Error -> { @@ -150,6 +160,7 @@ fun SeriesDetails( similar = similar, played = played, favorite = item.data.userData?.isFavorite ?: false, + canDelete = canDelete, modifier = modifier, onClickItem = { index, item -> viewModel.navigateTo(item.destination()) @@ -163,23 +174,29 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + scope.launchDefault { + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + canDelete = viewModel.canDelete(season), + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + onClickDelete = { + showDeleteDialog = it + }, + ) + } }, overviewOnClick = { overviewDialog = @@ -231,6 +248,9 @@ fun SeriesDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), ) if (showWatchConfirmation) { @@ -283,6 +303,19 @@ fun SeriesDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + if (seasons?.lastOrNull()?.id == item.id) { + focusManager.moveFocus(FocusDirection.Previous) + } + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -305,6 +338,7 @@ fun SeriesDetailsContent( discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -436,6 +470,23 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + onClick = { + position = HEADER_ROW + moreActions.onClickDelete.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -638,9 +689,11 @@ fun SeriesDetailsHeader( fun buildDialogForSeason( context: Context, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { @@ -679,6 +732,17 @@ fun buildDialogForSeason( onClickPlay.invoke(true) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), 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 8fba8ed6..79731e47 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.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 @@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable @@ -89,6 +93,7 @@ fun SeriesOverview( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } @@ -118,6 +123,7 @@ fun SeriesOverview( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } LaunchedEffect(episodes) { episodes?.let { episodes -> @@ -177,7 +183,7 @@ fun SeriesOverview( } } - fun buildMoreForEpisode( + suspend fun buildMoreForEpisode( ep: BaseItem, chosenStreams: ChosenStreams?, fromLongClick: Boolean, @@ -194,6 +200,7 @@ fun SeriesOverview( seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete(ep), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -216,6 +223,9 @@ fun SeriesOverview( showPlaylistDialog = it }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), onChooseVersion = { chooseVersion = @@ -315,7 +325,9 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + } }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -343,18 +355,22 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + } } }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) + scope.launchDefault { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + genres = it.data.genres.orEmpty(), + files = it.data.mediaSources.orEmpty(), + ) + } } }, personOnClick = { @@ -420,6 +436,17 @@ fun SeriesOverview( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.subtitle ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + episodeRowFocusRequester.tryRequestFocus() + showDeleteDialog = null + }, + ) + } } private const val EPISODE_ROW = 0 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 a299fe3c..0e1b326b 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 @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService 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.PeopleFavorites @@ -24,10 +25,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt @@ -90,6 +93,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -111,6 +115,7 @@ class SeriesViewModel val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val people = MutableLiveData<List<Person>>(listOf()) val similar = MutableLiveData<List<BaseItem>>() + val canDeleteSeries = MutableStateFlow(false) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) @@ -127,6 +132,7 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) + canDeleteSeries.update { mediaManagementService.canDelete(item) } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -259,9 +265,12 @@ class SeriesViewModel if (seriesPageType == SeriesPageType.DETAILS) { listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -300,6 +309,7 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ), ) Timber.v( @@ -551,6 +561,59 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + this@SeriesViewModel.item.value?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = episodes.value as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index b03ce9f9..4b30c418 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -166,6 +166,22 @@ class ApiRequestPager<T>( } } + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) + } + } + } + fetchPageBlocking(position, true) + } + companion object { private const val DEBUG = false } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 8d6034b1..081b25e4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -166,6 +166,7 @@ message InterfacePreferences { BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 967d6922..cc1ebf9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,6 +692,8 @@ <string name="no_subtitles_found">No remote subtitles were found</string> <string name="series_continueing">Present</string> <string name="in_app_screensaver">Use in-app screensaver</string> + <string name="delete_item">Are you sure you want to delete this item?</string> + <string name="show_media_management">Show media management options</string> <string-array name="backdrop_style_options"> <item>@string/backdrop_style_dynamic</item> <item>@string/backdrop_style_image</item> From 00047012962cecc5a4836ccd45c7ef1ab83b3aa2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:46:46 -0500 Subject: [PATCH 050/118] Dev: add simple automated UI test setup (#1027) ## Description This PR is mostly a proof-of-concept for setting up automated UI testing. The tests use Roboelectric so they can run without an emulator. There is also a sample instrumented test for running on emulators or devices for future tests. It adds two basic automated UI tests for adding a server. These are sort integration tests because they use the actual implementations for the view model and services like `ServerRepository` and `JellyfinServerDao`. Also, moves the bulk of `MainActivity`'s compose code into a function for future tests. ### Related issues N/A ### Testing Local testing ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 14 +- .../wholphin/test/InstrumentedBasicUiTests.kt | 75 +++++ .../damontecres/wholphin/test/TestActivity.kt | 7 + .../wholphin/test/WholphinTestRunner.kt | 14 + app/src/debug/AndroidManifest.xml | 94 ++++++ .../damontecres/wholphin/MainActivity.kt | 171 ++--------- .../damontecres/wholphin/MainContent.kt | 149 +++++++++ .../wholphin/data/ServerRepository.kt | 21 +- .../services/SetupNavigationManager.kt | 2 +- .../wholphin/services/hilt/AppModule.kt | 43 ++- .../wholphin/ui/setup/ServerList.kt | 6 +- .../wholphin/ui/setup/SwitchServerContent.kt | 2 + .../ui/setup/SwitchServerViewModel.kt | 5 + .../wholphin/util/CrashReportSenderFactory.kt | 3 +- .../damontecres/wholphin/test/TestActivity.kt | 7 + .../damontecres/wholphin/ui/BasicUiTests.kt | 238 +++++++++++++++ .../damontecres/wholphin/ui/TestModule.kt | 282 ++++++++++++++++++ .../github/damontecres/wholphin/ui/Utils.kt | 16 + gradle/libs.versions.toml | 4 + 19 files changed, 990 insertions(+), 163 deletions(-) create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/main/java/com/github/damontecres/wholphin/MainContent.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 366dfcd1..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,7 @@ android { targetSdk = 36 versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" } signingConfigs { @@ -140,6 +140,12 @@ android { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } protobuf { @@ -254,6 +260,7 @@ dependencies { implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -292,4 +299,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..72cf2896 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,75 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + requestedDestination = Destination.Home(), + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..29b2eb75 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission + android:name="android.permission.READ_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature + android:name="android.software.leanback" + android:required="false" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> + + <!-- Required for Android 11+ to query voice recognition availability --> + <queries> + <intent> + <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> + </intent> + </queries> + + <application + android:allowBackup="true" + android:banner="@mipmap/ic_banner" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Wholphin" + android:name=".WholphinApplication" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <activity android:name=".test.TestActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.provider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + tools:node="remove" /> + +<!-- <service--> +<!-- android:name=".WholphinDreamService"--> +<!-- android:exported="true"--> +<!-- android:label="@string/app_name"--> +<!-- android:permission="android.permission.BIND_DREAM_SERVICE">--> +<!-- <intent-filter>--> +<!-- <action android:name="android.service.dreams.DreamService" />--> +<!-- <category android:name="android.intent.category.DEFAULT" />--> +<!-- </intent-filter>--> +<!-- </service>--> + </application> + +</manifest> diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 5a6e446f..e2afbfa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -8,12 +8,9 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService @@ -62,17 +47,12 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint @@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -230,129 +211,19 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - LaunchedEffect(Unit) { - backdropService.clearBackdrop() - } - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - ApplicationContent( - user = current.user, - server = current.server, - startDestination = - requestedDestination - ?: Destination.Home(), - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } - } - }, - ) - val screenSaverState by screensaverService.state.collectAsState() - if (screenSaverState.enabled || screenSaverState.enabledTemp) { - AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), - ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) - } + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() } - } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), + ) } } } @@ -400,6 +271,22 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..982240a4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +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.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.components.AppScreensaver +import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock + +@Composable +fun MainContent( + backStack: MutableList<SetupDestination>, + navigationManager: NavigationManager, + appPreferences: AppPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + requestedDestination: Destination, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + ProvideLocalClock { + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 610d0b88..2d786ed8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -9,10 +9,12 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -41,6 +43,7 @@ class ServerRepository val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -57,7 +60,7 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) @@ -71,7 +74,7 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ): CurrentUser? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -126,7 +129,7 @@ class ServerRepository return null } val serverAndUsers = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.getServer(serverId) } if (serverAndUsers != null) { @@ -149,7 +152,7 @@ class ServerRepository } suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverId?.let { serverDao.getServer(serverId)?.server } } @@ -163,7 +166,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -213,7 +216,7 @@ class ServerRepository } apiClient.update(accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } @@ -233,7 +236,7 @@ class ServerRepository } apiClient.update(baseUrl = null, accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } @@ -252,7 +255,7 @@ class ServerRepository suspend fun setUserPin( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val newUser = user.copy(pin = pin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { @@ -265,7 +268,7 @@ class ServerRepository } suspend fun authorizeQuickConnect(code: String): Boolean = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val userId = currentUser.value?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index bd95ea1d..e4910031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class SetupNavigationManager @Inject constructor() { - var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) + var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index e3e42efa..991a8e98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -18,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -49,6 +50,14 @@ annotation class IoCoroutineScope @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -62,12 +71,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -177,15 +180,29 @@ object AppModule { } } + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @Provides @Singleton @@ -199,3 +216,13 @@ object AppModule { @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) } + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index b209ee59..5fd47621 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -382,7 +383,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 4902ca06..a4af8580 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -352,6 +353,7 @@ fun SwitchServerContent( ), modifier = Modifier + .testTag("server_url_text") .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3..08398a86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -42,6 +45,8 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 43d3e45a..c1630900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import android.content.Context import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -49,7 +50,7 @@ class CrashReportSender : ReportSender { createJellyfin { this.context = context clientInfo = AppModule.clientInfo(context) - deviceInfo = AppModule.deviceInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory minimumServerVersion = Jellyfin.minimumVersion diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt new file mode 100644 index 00000000..66178a4e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(TestModule.testDispatcher) + mockkStatic(Dispatchers::class) + + every { Dispatchers.IO } returns TestModule.testDispatcher + every { Dispatchers.Default } returns TestModule.testDispatcher + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.addServerState.value.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt new file mode 100644 index 00000000..39705dd3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,282 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.DatabaseModule +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser.value?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ + @Provides + @Singleton + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore<AppPreferences>, + @IoCoroutineScope scope: CoroutineScope, + ): RememberTabManager = mockk() + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, Dispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + + @Provides + @Singleton + fun userPreferencesDataStore( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore<AppPreferences> = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt new file mode 100644 index 00000000..d0bc8532 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,16 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.jellyfin.sdk.api.client.Response + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06a1b888..e477d360 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" +runner = "1.7.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } @@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } @@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From be90a42c57d41480f2e612fd5e138a956b32f2d7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:46:55 -0500 Subject: [PATCH 051/118] Various fixes (#1030) ## Description Fixes several small issues throughout the app - Adds 1440p as a resolution option - Removes the delay when d-pad seeking on the seek bar & reduces it slightly when controls are hidden - Fixes padding one home customize pages - Adds a title to the search for dialog ### Related issues Fixes #1017 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/WholphinDreamService.kt | 23 +++++++++------- .../wholphin/ui/components/AppScreensaver.kt | 1 + .../wholphin/ui/components/Dialogs.kt | 3 +++ .../ui/detail/search/SearchForDialog.kt | 26 ++++++++++++------- .../ui/main/settings/HomeSettingsPage.kt | 1 + .../wholphin/ui/playback/SeekAcceleration.kt | 2 +- .../wholphin/ui/playback/SeekBar.kt | 12 ++------- .../wholphin/ui/util/StreamFormatting.kt | 24 ++++++++--------- app/src/main/jni/event.cpp | 1 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/FormattingTests.kt | 23 ++++++++++++++++ 11 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 1c0e4e0e..4d77c3a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject @@ -69,16 +70,18 @@ class WholphinDreamService : } prefs?.let { prefs -> WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { - val screensaverPrefs = - prefs.appPreferences.interfacePreferences.screensaverPreference - val currentItem by itemFlow.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = screensaverPrefs.showClock, - duration = screensaverPrefs.duration.milliseconds, - animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), - ) + ProvideLocalClock { + val screensaverPrefs = + prefs.appPreferences.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt index e55a2aa6..d26675b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -165,6 +165,7 @@ fun AppScreensaverContent( ) { Text( text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.displaySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index a1f2362a..a96bc859 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -656,6 +656,9 @@ fun chooseStream( ) add( DialogItem( + leadingContent = { + SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) + }, headlineContent = { Text(text = stringResource(R.string.only_forced_subtitles)) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt index 6717527e..09df419c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -91,10 +92,25 @@ fun SearchForContent( } } } + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + val title = titleRes?.let { stringResource(it) } ?: "" Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { + Text( + text = stringResource(R.string.search_for, title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), @@ -190,16 +206,8 @@ fun SearchForContent( text = stringResource(R.string.no_results), ) } else { - val titleRes = - remember { - when (searchType) { - BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.PLAYLIST -> R.string.playlists - else -> null - } - } ItemRow( - title = titleRes?.let { stringResource(it) } ?: "", + title = "", items = st.items, onClickItem = { _, item -> onClick.invoke(item) }, onLongClickItem = { _, _ -> }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 1185f719..fb025c02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -240,6 +240,7 @@ fun HomeSettingsPage( onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, + modifier = destModifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt index 1349a642..6925d5cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.playback -internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12 +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 /** * Shared seek acceleration profile for hold-to-seek behavior. diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index e34d91af..92c6114b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -182,14 +182,10 @@ fun SeekBarDisplay( KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount if (repeatCount > 0) { - if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { - leftHandledByRepeat = false - return@onPreviewKeyEvent true - } leftHandledByRepeat = true onLeft.invoke( calculateSeekAccelerationMultiplier( - repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + repeatCount = repeatCount, durationMs = durationMs, ), ) @@ -217,14 +213,10 @@ fun SeekBarDisplay( KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount if (repeatCount > 0) { - if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { - rightHandledByRepeat = false - return@onPreviewKeyEvent true - } rightHandledByRepeat = true onRight.invoke( calculateSeekAccelerationMultiplier( - repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + repeatCount = repeatCount, durationMs = durationMs, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index ff4e62da..ca6e4027 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -27,18 +27,18 @@ object StreamFormatting { resolutionString(height, width, interlaced) } else { when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" + width > 5120 || height > 4320 -> "8K" + width > 2560 || height > 1440 -> "4K" + width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) + width > 1280 || height > 962 -> "1080" + interlaced(interlaced) + width > 1024 || height > 576 -> "720" + interlaced(interlaced) + width > 960 || height > 544 -> "576" + interlaced(interlaced) + width > 845 || height > 480 -> "540" + interlaced(interlaced) + width > 720 || height > 404 -> "480" + interlaced(interlaced) + width > 682 || height > 384 -> "404" + interlaced(interlaced) + width > 640 || height > 360 -> "384" + interlaced(interlaced) + width > 426 || height > 240 -> "360" + interlaced(interlaced) + width > 256 || height > 144 -> "240" + interlaced(interlaced) else -> height.toString() + interlaced(interlaced) } } diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp index b092b661..26c69300 100644 --- a/app/src/main/jni/event.cpp +++ b/app/src/main/jni/event.cpp @@ -107,6 +107,7 @@ void *event_thread(void *arg) case MPV_EVENT_END_FILE: mp_end_file = (mpv_event_end_file*)mp_event->data; sendEndFileEventToJava(env, mp_end_file); + break; default: ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); sendEventToJava(env, mp_event->event_id); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc1ebf9d..c7a2fb92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -718,5 +718,6 @@ <string name="mixer">Mixer</string> <string name="creator">Creator</string> <string name="artist">Artist</string> + <string name="search_for">Search %s</string> </resources> diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt new file mode 100644 index 00000000..b0a8810f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString +import org.junit.Assert +import org.junit.Test + +class FormattingTests { + @Test + fun testResolutionStrings() { + Assert.assertEquals("4K", resolutionString(3840, 2160, false)) + Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) + Assert.assertEquals("720p", resolutionString(1280, 720, false)) + Assert.assertEquals("480i", resolutionString(640, 480, true)) + + Assert.assertEquals("576p", resolutionString(1024, 576, false)) + Assert.assertEquals("576p", resolutionString(960, 576, false)) + + // 21:9 + Assert.assertEquals("1080p", resolutionString(1920, 822, false)) + + Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) + } +} From 6ea5fd2121552f727c47780c679a2a3226fa2988 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:34:44 -0500 Subject: [PATCH 052/118] Update actions/upload-artifact action to v7 (#1009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) | action | major | `v6` → `v7` | --- ### Release Notes <details> <summary>actions/upload-artifact (actions/upload-artifact)</summary> ### [`v7`](https://redirect.github.com/actions/upload-artifact/compare/v6...v7) [Compare Source](https://redirect.github.com/actions/upload-artifact/compare/v6...v7) </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4zNi4yIiwidXBkYXRlZEluVmVyIjoiNDMuMzYuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==--> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c654dad9..9611c810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: AAB path: | From 6867ba76575e4143326706d61ac9212e96b2fdd1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:35:11 -0500 Subject: [PATCH 053/118] Update Dependencies (#1013) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e477d360..b60ca106 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin = "2.3.10" ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.02.00" +composeBom = "2026.02.01" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -33,7 +33,7 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.33.5" +protobuf-javalite = "4.34.0" hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" 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 054/118] 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<SeerrAuthMethod?>(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<HttpUrl, LoadingState>() 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 055/118] 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<DeletedItem> = _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<PositionItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<PositionItem?>(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<Trailer>?, 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<RowColumnItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(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<DialogItem> = 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<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(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<BaseItem?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(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<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(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<HomeState> = _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 056/118] 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<DisplayMode>({ 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) } } From 83ebd922d9fcce56f36bddcceb0af8289163e888 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:34:57 -0500 Subject: [PATCH 057/118] Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer (#569) ## Description Add better ASS/SSA support to ExoPlayer by using [`libass-android`](https://github.com/peerless2012/libass-android). This is enabled with the "Use libass for ASS subtitles" advanced settings for ExoPlayer. It is enabled by default. ### Related issues Related to #22 --- app/build.gradle.kts | 1 + .../wholphin/services/PlayerFactory.kt | 115 ++- .../wholphin/ui/playback/PlaybackPage.kt | 55 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 10 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 779 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 120 +-- app/src/main/res/values/strings.xml | 2 +- gradle/libs.versions.toml | 3 + 8 files changed, 573 insertions(+), 512 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22bd0deb..b5f86522 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,6 +231,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) + implementation(libs.ass.media) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 425ab94d..a5d6fc78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -10,22 +10,28 @@ import androidx.datastore.core.DataStore import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener -import com.github.damontecres.wholphin.preferences.AppPreference +import androidx.media3.extractor.DefaultExtractorsFactory import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.factory.AssRenderersFactory +import io.github.peerless2012.ass.media.kt.withAssMkvSupport +import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory +import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.lang.reflect.Constructor @@ -46,71 +52,17 @@ class PlayerFactory var currentPlayer: Player? = null private set - fun createVideoPlayer(): Player { - if (currentPlayer?.isReleased == false) { - Timber.w("Player was not released before trying to create a new one!") - currentPlayer?.release() - } - - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue - val newPlayer = - when (backend) { - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - val useGpuNext = - prefs?.mpvOptions?.useGpuNext - ?: AppPreference.MpvGpuNext.defaultValue - MpvPlayer(context, enableHardwareDecoding, useGpuNext) - .apply { - playWhenReady = true - } - } - - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - val decodeAv1 = prefs?.overrides?.decodeAv1 == true - Timber.v("extensions=$extensions") - val rendererMode = - when (extensions) { - MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - } - ExoPlayer - .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() - .apply { - playWhenReady = true - } - } - } - currentPlayer = newPlayer - return newPlayer - } - suspend fun createVideoPlayer( backend: PlayerBackend, prefs: PlaybackPreferences, - ): Player { + ): PlayerCreation { withContext(Dispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() } } - + var assHandler: AssHandler? = null val newPlayer = when (backend) { PlayerBackend.PREFER_MPV, @@ -125,8 +77,9 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled + val directPlayAss = prefs.overrides.directPlayAss val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions") + Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -134,17 +87,42 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } + val dataSourceFactory = DefaultDataSource.Factory(context) + val extractorsFactory = DefaultExtractorsFactory() + var renderersFactory: RenderersFactory = + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + if (directPlayAss) { + assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL) + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + renderersFactory = AssRenderersFactory(assHandler, renderersFactory) + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ), + ).setSubtitleParserFactory(assSubtitleParserFactory) + } else { + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory, + ) + } ExoPlayer .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .build() + .apply { + assHandler?.init(this) + } } } currentPlayer = newPlayer - return newPlayer + return PlayerCreation(newPlayer, assHandler) } } @@ -157,6 +135,11 @@ val Player.isReleased: Boolean } } +data class PlayerCreation( + val player: Player, + val assHandler: AssHandler? = null, +) + // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 class WholphinRenderersFactory( context: Context, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 3b9958b6..c7a4bf10 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.ui.playback +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.annotation.Dimension import androidx.annotation.OptIn @@ -42,16 +45,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface @@ -83,6 +88,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -127,8 +133,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - player = playerState!!.player, - playerBackend = playerState!!.backend, + playerState = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -141,13 +146,15 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - player: Player, - playerBackend: PlayerBackend, + playerState: PlayerState, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { + val player = playerState.player + val playerBackend = playerState.backend + val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val configuration = LocalConfiguration.current @@ -320,10 +327,14 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { + var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, + modifier = + scaledModifier.onSizeChanged { + playerSurfaceSize = it + }, ) if (presentationState.coverSurface) { Box( @@ -416,7 +427,7 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( @@ -427,12 +438,42 @@ fun PlaybackPageContent( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } + playerState.assHandler?.let { assHandler -> + if (prefs.overrides.directPlayAss) { + Timber.v("Adding AssSubtitleView") + addView( + AssSubtitleView(context, assHandler).apply { + layoutParams = + FrameLayout + .LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ).apply { gravity = Gravity.CENTER } + }, + ) + } + } } }, update = { it.setCues(cues) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(it) + it.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + val resized = + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } + if (resized) { + Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSurfaceSize.width, + playerSurfaceSize.height, + ).apply { gravity = Gravity.CENTER } + } + } + } }, onReset = { it.setCues(null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 0d23f4bf..4f652360 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -73,6 +73,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -218,7 +219,8 @@ class PlaybackViewModel isHdr: Boolean, is4k: Boolean, ) { - val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val softwareDecoding = + !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val playerBackend = when (preferences.appPreferences.playbackPreferences.playerBackend) { PlayerBackend.UNRECOGNIZED, @@ -237,13 +239,14 @@ class PlaybackViewModel disconnectPlayer() } - player = + val playerCreation = playerFactory.createVideoPlayer( playerBackend, preferences.appPreferences.playbackPreferences, ) + this.player = playerCreation.player currentPlayer.update { - PlayerState(player, playerBackend) + PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -1438,6 +1441,7 @@ class PlaybackViewModel data class PlayerState( val player: Player, val backend: PlayerBackend, + val assHandler: AssHandler?, ) data class MediaSegmentState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index f35bc102..3b57017e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs @@ -88,413 +89,429 @@ fun SlideshowPage( ), ) { val context = LocalContext.current + val loading by viewModel.loading.collectAsState() - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() - val imageState by viewModel.image.observeAsState() - - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } - - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } - - override fun stopSlideshow() { - viewModel.stopSlideshow() - } + when (val st = loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) } - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", - ) - - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) - - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width - - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f - - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } - - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) - } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff - } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff - } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } + LoadingState.Success -> { + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() + val imageState by viewModel.image.observeAsState() - LaunchedEffect(imageState) { - reset(true) - } - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } - var longPressing by remember { mutableStateOf(false) } + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, + override fun stopSlideshow() { + viewModel.stopSlideshow() + } + } + + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", ) - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } - } - return@onKeyEvent true - } - } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) - } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() - } - } + val focusRequester = remember { FocusRequester() } - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { - Toast - .makeText( - context, - R.string.no_more_images, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } else if (isOverlayShowing && it.key == Key.Back) { - showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true - } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() - } - result - }, - ) { - when (loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() } - ImageLoadingState.Loading -> { - LoadingPage(modifier) + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f } - is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } + + LaunchedEffect(imageState) { + reset(true) + } + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } + + var longPressing by remember { mutableStateOf(false) } + + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, + ) + + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) + + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true } } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } + } + + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) + } + + ImageLoadingState.Loading -> { + LoadingPage(modifier) + } + + is ImageLoadingState.Success -> { + imageState?.let { imageState -> + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + contentModifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + contentModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = + TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .crossfade(!showLoadingThumbnail) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + loading = { + ImageLoadingPlaceholder( + thumbnailUrl = imageState.thumbnailUrl, + showThumbnail = showLoadingThumbnail, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize(), + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) + } + } + } + } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + imageState?.let { imageState -> + ImageOverlay( modifier = contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() }, ) } } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() + }, + ) + } } } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) - } - } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 992659ca..b86814ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService @@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -77,9 +79,8 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - val player by lazy { - playerFactory.createVideoPlayer() - } + lateinit var player: Player + private set private var saveFilters = true @@ -104,6 +105,8 @@ class SlideshowViewModel private val _image = MutableLiveData<ImageState>() val image: LiveData<ImageState> = _image + val loading = MutableStateFlow<LoadingState>(LoadingState.Pending) + val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) private val _imageFilter = MutableLiveData(VideoFilter()) val imageFilter = ThrottledLiveData(_imageFilter, 500L) @@ -113,58 +116,67 @@ class SlideshowViewModel init { addCloseable { screensaverService.keepScreenOn(false) - player.removeListener(this@SlideshowViewModel) - player.release() - } - player.addListener(this@SlideshowViewModel) - viewModelScope.launchIO { - val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue -// val album = -// api.userLibraryApi -// .getItem( -// itemId = slideshowSettings.parentId, -// ).content -// .let { BaseItem(it, false) } -// this@SlideshowViewModel.album.setValueOnMain(album) - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = true, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser.value?.let { user -> - val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter - } + if (this@SlideshowViewModel::player.isInitialized) { + player.removeListener(this@SlideshowViewModel) + player.release() + } + } + viewModelScope.launchIO { + try { + val appPreferences = userPreferencesService.getCurrent().appPreferences + val playerCreation = + playerFactory.createVideoPlayer( + backend = PlayerBackend.EXO_PLAYER, + appPreferences.playbackPreferences, + ) + player = playerCreation.player + player.addListener(this@SlideshowViewModel) + + val photoPrefs = appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = true, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter + } + } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + loading.update { LoadingState.Success } + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } + } catch (ex: Exception) { + Timber.e(ex, "Error") + loading.update { LoadingState.Error(ex) } } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a2fb92..5136c83a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,7 +312,7 @@ <string name="check_for_updates">Check for updates</string> <string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Direct play ASS subtitles</string> + <string name="direct_play_ass">Use libass for ASS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="downmix_stereo">Always downmix to stereo</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b60ca106..a4de2268 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" +assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" @@ -134,6 +135,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From ce44e9593b76343c5aff6f5093a8391afe6f5f13 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:46:47 -0500 Subject: [PATCH 058/118] Screensaver fixes (#1047) ## Description Fixes a few issues with the in-app & OS screensavers - Make sure clock is updating for in-app screensaver - Fix crash if app is force quit before the OS screensaver starts ### Related issues Fixes #1045 ### Testing NVIDIA shield ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 29 +++++----- .../damontecres/wholphin/MainContent.kt | 56 +++++++++---------- .../wholphin/WholphinDreamService.kt | 54 ++++++++++++------ .../ui/preferences/PreferencesContent.kt | 2 +- .../wholphin/ui/util/LocalClock.kt | 2 +- 5 files changed, 81 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index e2afbfa0..3877c26c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint @@ -211,19 +212,21 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) ?: Destination.Home() - } - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - appPreferences = appPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - requestedDestination = requestedDestination, - modifier = Modifier.fillMaxSize(), - ) + ProvideLocalClock { + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() + } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 982240a4..5bade867 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -96,39 +96,37 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - ProvideLocalClock { - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!appPreferences.signInAutomatically) { - showContent = false - } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false } + } - if (showContent) { - ApplicationContent( - user = current.user, - server = current.server, - startDestination = requestedDestination, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 4d77c3a8..a4c09ad4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -5,11 +5,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.datastore.core.DataStore import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.lifecycleScope @@ -18,14 +20,19 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -33,11 +40,14 @@ import kotlin.time.Duration.Companion.milliseconds class WholphinDreamService : DreamService(), SavedStateRegistryOwner { + @Inject + lateinit var serverRepository: ServerRepository + @Inject lateinit var screensaverService: ScreensaverService @Inject - lateinit var userPreferencesService: UserPreferencesService + lateinit var preferencesDataStore: DataStore<AppPreferences> private val lifecycleRegistry = LifecycleRegistry(this) @@ -54,6 +64,12 @@ class WholphinDreamService : savedStateRegistryController.performRestore(null) lifecycleRegistry.currentState = Lifecycle.State.CREATED + lifecycleScope.launchDefault { + if (serverRepository.current.value == null) { + val prefs = preferencesDataStore.data.first() + serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) + } + } } override fun onAttachedToWindow() { @@ -64,23 +80,25 @@ class WholphinDreamService : setViewTreeLifecycleOwner(this@WholphinDreamService) setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) setContent { - var prefs by remember { mutableStateOf<UserPreferences?>(null) } - LaunchedEffect(Unit) { - userPreferencesService.flow.collectLatest { prefs = it } - } - prefs?.let { prefs -> - WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { - ProvideLocalClock { - val screensaverPrefs = - prefs.appPreferences.interfacePreferences.screensaverPreference - val currentItem by itemFlow.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = screensaverPrefs.showClock, - duration = screensaverPrefs.duration.milliseconds, - animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), - ) + val user by serverRepository.currentUser.observeAsState() + if (user != null) { + var prefs by remember { mutableStateOf<AppPreferences?>(null) } + LaunchedEffect(Unit) { + preferencesDataStore.data.collectLatest { prefs = it } + } + prefs?.let { prefs -> + WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) { + ProvideLocalClock { + val screensaverPrefs = prefs.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } } } } 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 729e7085..2dc4f3cf 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 @@ -153,7 +153,7 @@ fun PreferencesContent( try { System.loadLibrary("mpv") System.loadLibrary("player") - } catch (ex: Exception) { + } catch (ex: UnsatisfiedLinkError) { Timber.w(ex, "Could not load libmpv") showToast(context, "MPV is not supported on this device") viewModel.preferenceDataStore.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index d07f86ff..bca5ae64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -34,7 +34,7 @@ data class Clock( fun ProvideLocalClock(content: @Composable () -> Unit) { val clock = remember { Clock() } LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { while (isActive) { val now = LocalDateTime.now() val time = TimeFormatter.format(now) From 36162d6fc553aa8183bab0fbc17c596b6afff8f8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:16:12 -0500 Subject: [PATCH 059/118] Revert "Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer" (#1051) Unfortunately, there seem to be issues with switching to `libass-android`, so reverting those changes so I can cut a release and troubleshoot later. Reverts damontecres/Wholphin#569 --- app/build.gradle.kts | 1 - .../wholphin/services/PlayerFactory.kt | 115 +-- .../wholphin/ui/playback/PlaybackPage.kt | 55 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 10 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 789 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 114 ++- app/src/main/res/values/strings.xml | 2 +- gradle/libs.versions.toml | 3 - 8 files changed, 514 insertions(+), 575 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5f86522..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,7 +231,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) - implementation(libs.ass.media) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index a5d6fc78..425ab94d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -10,28 +10,22 @@ import androidx.datastore.core.DataStore import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener -import androidx.media3.extractor.DefaultExtractorsFactory +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.peerless2012.ass.media.AssHandler -import io.github.peerless2012.ass.media.factory.AssRenderersFactory -import io.github.peerless2012.ass.media.kt.withAssMkvSupport -import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory -import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.lang.reflect.Constructor @@ -52,17 +46,71 @@ class PlayerFactory var currentPlayer: Player? = null private set + fun createVideoPlayer(): Player { + if (currentPlayer?.isReleased == false) { + Timber.w("Player was not released before trying to create a new one!") + currentPlayer?.release() + } + + val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } + val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue + val newPlayer = + when (backend) { + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { + val enableHardwareDecoding = + prefs?.mpvOptions?.enableHardwareDecoding + ?: AppPreference.MpvHardwareDecoding.defaultValue + val useGpuNext = + prefs?.mpvOptions?.useGpuNext + ?: AppPreference.MpvGpuNext.defaultValue + MpvPlayer(context, enableHardwareDecoding, useGpuNext) + .apply { + playWhenReady = true + } + } + + PlayerBackend.EXO_PLAYER, + PlayerBackend.UNRECOGNIZED, + -> { + val extensions = prefs?.overrides?.mediaExtensionsEnabled + val decodeAv1 = prefs?.overrides?.decodeAv1 == true + Timber.v("extensions=$extensions") + val rendererMode = + when (extensions) { + MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + ExoPlayer + .Builder(context) + .setRenderersFactory( + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() + .apply { + playWhenReady = true + } + } + } + currentPlayer = newPlayer + return newPlayer + } + suspend fun createVideoPlayer( backend: PlayerBackend, prefs: PlaybackPreferences, - ): PlayerCreation { + ): Player { withContext(Dispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() } } - var assHandler: AssHandler? = null + val newPlayer = when (backend) { PlayerBackend.PREFER_MPV, @@ -77,9 +125,8 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled - val directPlayAss = prefs.overrides.directPlayAss val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") + Timber.v("extensions=$extensions") val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -87,42 +134,17 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } - val dataSourceFactory = DefaultDataSource.Factory(context) - val extractorsFactory = DefaultExtractorsFactory() - var renderersFactory: RenderersFactory = - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode) - val mediaSourceFactory = - if (directPlayAss) { - assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL) - val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) - renderersFactory = AssRenderersFactory(assHandler, renderersFactory) - DefaultMediaSourceFactory( - dataSourceFactory, - extractorsFactory.withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ), - ).setSubtitleParserFactory(assSubtitleParserFactory) - } else { - DefaultMediaSourceFactory( - dataSourceFactory, - extractorsFactory, - ) - } ExoPlayer .Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) - .build() - .apply { - assHandler?.init(this) - } + .setRenderersFactory( + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() } } currentPlayer = newPlayer - return PlayerCreation(newPlayer, assHandler) + return newPlayer } } @@ -135,11 +157,6 @@ val Player.isReleased: Boolean } } -data class PlayerCreation( - val player: Player, - val assHandler: AssHandler? = null, -) - // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 class WholphinRenderersFactory( context: Context, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index c7a4bf10..3b9958b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.ui.playback -import android.view.Gravity -import android.view.ViewGroup -import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.annotation.Dimension import androidx.annotation.OptIn @@ -45,18 +42,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface @@ -88,7 +83,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.mpv.MpvPlayer -import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -133,7 +127,8 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - playerState = playerState!!, + player = playerState!!.player, + playerBackend = playerState!!.backend, preferences = preferences, destination = destination, viewModel = viewModel, @@ -146,15 +141,13 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - playerState: PlayerState, + player: Player, + playerBackend: PlayerBackend, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { - val player = playerState.player - val playerBackend = playerState.backend - val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val configuration = LocalConfiguration.current @@ -327,14 +320,10 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { - var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier.onSizeChanged { - playerSurfaceSize = it - }, + modifier = scaledModifier, ) if (presentationState.coverSurface) { Box( @@ -427,7 +416,7 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( @@ -438,42 +427,12 @@ fun PlaybackPageContent( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } - playerState.assHandler?.let { assHandler -> - if (prefs.overrides.directPlayAss) { - Timber.v("Adding AssSubtitleView") - addView( - AssSubtitleView(context, assHandler).apply { - layoutParams = - FrameLayout - .LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ).apply { gravity = Gravity.CENTER } - }, - ) - } - } } }, update = { it.setCues(cues) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(it) - it.children.firstOrNull { it is AssSubtitleView }?.let { - (it as? AssSubtitleView)?.apply { - val resized = - layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } - if (resized) { - Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") - layoutParams = - FrameLayout - .LayoutParams( - playerSurfaceSize.width, - playerSurfaceSize.height, - ).apply { gravity = Gravity.CENTER } - } - } - } }, onReset = { it.setCues(null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4f652360..0d23f4bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -73,7 +73,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -219,8 +218,7 @@ class PlaybackViewModel isHdr: Boolean, is4k: Boolean, ) { - val softwareDecoding = - !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val playerBackend = when (preferences.appPreferences.playbackPreferences.playerBackend) { PlayerBackend.UNRECOGNIZED, @@ -239,14 +237,13 @@ class PlaybackViewModel disconnectPlayer() } - val playerCreation = + player = playerFactory.createVideoPlayer( playerBackend, preferences.appPreferences.playbackPreferences, ) - this.player = playerCreation.player currentPlayer.update { - PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) + PlayerState(player, playerBackend) } configurePlayer() } @@ -1441,7 +1438,6 @@ class PlaybackViewModel data class PlayerState( val player: Player, val backend: PlayerBackend, - val assHandler: AssHandler?, ) data class MediaSegmentState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 3b57017e..f35bc102 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -67,7 +67,6 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.LoadingState import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs @@ -89,429 +88,413 @@ fun SlideshowPage( ), ) { val context = LocalContext.current - val loading by viewModel.loading.collectAsState() - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() + val imageState by viewModel.image.observeAsState() + + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } + + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } + + override fun stopSlideshow() { + viewModel.stopSlideshow() + } } - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", + ) + + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f + } + + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } - LoadingState.Success -> { - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() - val imageState by viewModel.image.observeAsState() + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", + ) + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } + LaunchedEffect(imageState) { + reset(true) + } + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } + var longPressing by remember { mutableStateOf(false) } - override fun stopSlideshow() { - viewModel.stopSlideshow() - } - } - - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, ) - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width - - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f - - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } - - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) - } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } - - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) - } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff - } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff - } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } - - LaunchedEffect(imageState) { - reset(true) - } - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } - - var longPressing by remember { mutableStateOf(false) } - - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, - ) - - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) - - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } - } - return@onKeyEvent true - } - } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) - } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() - } - } - - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { - Toast - .makeText( - context, - R.string.no_more_images, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } else if (isOverlayShowing && it.key == Key.Back) { - showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true - } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() - } - result - }, - ) { - when (loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) - } - - ImageLoadingState.Loading -> { - LoadingPage(modifier) - } - - is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = - TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, - ) + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } } + return@onKeyEvent true } } - } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - imageState?.let { imageState -> - ImageOverlay( + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } + } + + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) + } + + ImageLoadingState.Loading -> { + LoadingPage(modifier) + } + + is ImageLoadingState.Success -> { + imageState?.let { imageState -> + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + contentModifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( modifier = contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .crossfade(!showLoadingThumbnail) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + loading = { + ImageLoadingPlaceholder( + thumbnailUrl = imageState.thumbnailUrl, + showThumbnail = showLoadingThumbnail, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize(), + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() }, ) } } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } } } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + imageState?.let { imageState -> + ImageOverlay( + modifier = + contentModifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } + } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() + }, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index b86814ac..992659ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService @@ -31,7 +30,6 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -79,8 +77,9 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - lateinit var player: Player - private set + val player by lazy { + playerFactory.createVideoPlayer() + } private var saveFilters = true @@ -105,8 +104,6 @@ class SlideshowViewModel private val _image = MutableLiveData<ImageState>() val image: LiveData<ImageState> = _image - val loading = MutableStateFlow<LoadingState>(LoadingState.Pending) - val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) private val _imageFilter = MutableLiveData(VideoFilter()) val imageFilter = ThrottledLiveData(_imageFilter, 500L) @@ -116,67 +113,58 @@ class SlideshowViewModel init { addCloseable { screensaverService.keepScreenOn(false) - if (this@SlideshowViewModel::player.isInitialized) { - player.removeListener(this@SlideshowViewModel) - player.release() - } + player.removeListener(this@SlideshowViewModel) + player.release() } + player.addListener(this@SlideshowViewModel) viewModelScope.launchIO { - try { - val appPreferences = userPreferencesService.getCurrent().appPreferences - val playerCreation = - playerFactory.createVideoPlayer( - backend = PlayerBackend.EXO_PLAYER, - appPreferences.playbackPreferences, - ) - player = playerCreation.player - player.addListener(this@SlideshowViewModel) - - val photoPrefs = appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = true, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser.value?.let { user -> - val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter - } + val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue +// val album = +// api.userLibraryApi +// .getItem( +// itemId = slideshowSettings.parentId, +// ).content +// .let { BaseItem(it, false) } +// this@SlideshowViewModel.album.setValueOnMain(album) + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = true, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - loading.update { LoadingState.Success } - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } - } catch (ex: Exception) { - Timber.e(ex, "Error") - loading.update { LoadingState.Error(ex) } } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5136c83a..c7a2fb92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,7 +312,7 @@ <string name="check_for_updates">Check for updates</string> <string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Use libass for ASS subtitles</string> + <string name="direct_play_ass">Direct play ASS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="downmix_stereo">Always downmix to stereo</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4de2268..b60ca106 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,6 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" -assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" @@ -135,8 +134,6 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } -ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From 781c020b67cf33c9bafdb974bcfd13057e1c284c Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 24 Feb 2026 22:20:33 +0000 Subject: [PATCH 060/118] Translated using Weblate (Spanish) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b4ef66da..3fe2aa97 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -35,7 +35,7 @@ <string name="name">Nombre</string> <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> - <string name="outro">Outro</string> + <string name="outro">Créditos finales</string> <string name="people">Personas</string> <string name="play">Reproducir</string> <string name="playback">Reproducción</string> @@ -264,8 +264,8 @@ <string name="skip_back_preference">Retroceder</string> <string name="skip_commercials_behavior">Comportamiento al saltar anuncios</string> <string name="skip_forward_preference">Avanzar</string> - <string name="skip_intro_behavior">Comportamiento al saltar la intro</string> - <string name="skip_outro_behavior">Comportamiento al saltar el outro</string> + <string name="skip_intro_behavior">Comportamiento al saltar la cabecera</string> + <string name="skip_outro_behavior">Comportamiento al saltar créditos finales</string> <string name="skip_previews_behavior">Comportamiento al saltar avances</string> <string name="skip_recap_behavior">Comportamiento al saltar resumen</string> <string name="update_available">Actualización disponible</string> @@ -291,8 +291,8 @@ <string name="skip_segment_commercial">Saltar anuncios</string> <string name="skip_segment_preview">Saltar avance</string> <string name="skip_segment_recap">Saltar resumen</string> - <string name="skip_segment_outro">Saltar outro</string> - <string name="skip_segment_intro">Saltar intro</string> + <string name="skip_segment_outro">Saltar créditos finales</string> + <string name="skip_segment_intro">Saltar cabecera</string> <string name="played">Reproducido</string> <string name="filter">FIltrar</string> <string name="year">Año</string> @@ -485,8 +485,8 @@ <string name="content_scale_fill">Rellenar</string> <string name="content_scale_fill_width">Ajustar al ancho</string> <string name="content_scale_fill_height">Ajustar al alto</string> - <string name="display_preset_series_thumb">Miniaturas de las series</string> - <string name="display_preset_episode_thumbnails">Miniaturas de los episodios</string> + <string name="display_preset_series_thumb">Miniatura de la serie</string> + <string name="display_preset_episode_thumbnails">Miniatura del episodio</string> <string name="volume_lowest">Mínimo</string> <string name="volume_low">Bajo</string> <string name="volume_medium">Medio</string> From a479afd10a564debbe358fa46f25755864f2f516 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 25 Feb 2026 07:34:03 +0000 Subject: [PATCH 061/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 46 ++++++++++++++++------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6670d10e..b325893a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -59,7 +59,7 @@ <string name="tv_guide">指南</string> <string name="updates">更新</string> <string name="version">版本</string> - <string name="video_scale">畫面比例</string> + <string name="video_scale">畫面縮放比例</string> <string name="video">影片</string> <string name="videos">影片</string> <string name="watch_live">觀看直播</string> @@ -104,7 +104,7 @@ <string name="direct_play_ass">直接播放 ASS 字幕</string> <string name="direct_play_pgs">直接播放 PGS 字幕</string> <string name="ffmpeg_extension_pref">使用 FFmpeg 解碼模組</string> - <string name="global_content_scale">預設畫面比例</string> + <string name="global_content_scale">預設縮放比例</string> <string name="install_update">安裝更新</string> <string name="installed_version">安裝版本</string> <string name="max_homepage_items">首頁每列項目上限</string> @@ -362,7 +362,7 @@ <string name="remove_seerr_server">移除 Seerr 伺服器</string> <string name="seerr_server_added">已新增 Seerr 伺服器</string> <string name="password">密碼</string> - <string name="username">使用者名稱</string> + <string name="username">帳號</string> <string name="url">URL</string> <string name="trending">當前趨勢</string> <string name="upcoming_movies">即將上映的電影</string> @@ -427,18 +427,18 @@ <string name="suggestions_for">推薦的 %1$s</string> <string name="increase_all_cards_size">放大所有海報尺寸</string> <string name="decrease_all_cards_size">縮小所有海報尺寸</string> - <string name="use_thumb_images">使用橫向縮圖</string> + <string name="use_thumb_images">使用橫式縮圖</string> <string name="add_row">新增一列</string> <string name="apply_all_rows">套用到所有列</string> <string name="home_rows">首頁各列</string> <string name="add_row_for">為 %1$s 新增列項目</string> <string name="display_presets_description">快速設定所有項目為內建預設樣式</string> - <string name="customize_home_summary">選擇首頁要顯示的列項目及圖片樣式</string> + <string name="customize_home_summary">選擇首頁要顯示的項目及圖片樣式</string> <string name="height">高度</string> <string name="customize_home">自訂首頁</string> <string name="load_from_server">從伺服器載入設定</string> <string name="save_to_server">保存設定到伺服器</string> - <string name="load_from_web_client">從網頁版載入</string> + <string name="load_from_web_client">從網頁版載入設定</string> <string name="use_series">使用劇集圖片</string> <string name="overwrite_server_settings">覆蓋伺服器上的設定?</string> <string name="overwrite_local_settings">覆蓋本地設定?</string> @@ -450,11 +450,11 @@ <string name="content_scale_fill">拉伸填滿</string> <string name="content_scale_fill_width">填滿寬度</string> <string name="content_scale_fill_height">填滿高度</string> - <string name="volume_lowest">最低</string> - <string name="volume_low">低</string> - <string name="volume_medium">中等</string> - <string name="volume_high">高</string> - <string name="volume_full">最大</string> + <string name="volume_lowest">最低音量</string> + <string name="volume_low">低音量</string> + <string name="volume_medium">中等音量</string> + <string name="volume_high">高音量</string> + <string name="volume_full">最大音量</string> <string name="purple">紫色</string> <string name="orange">橘色</string> <string name="bold_blue">深藍色</string> @@ -472,10 +472,32 @@ <string name="dark_gray">深灰色</string> <string name="yellow">黃色</string> <string name="cyan">青色</string> - <string name="magenta">洋紅色</string> + <string name="magenta">桃紅色</string> <string name="subtitle_edge_outline">外框</string> <string name="subtitle_edge_shadow">陰影</string> <string name="background_style_wrap">貼合文字</string> <string name="background_style_boxed">長方底框</string> <string name="prefer_mpv">優先使用 MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string> + <string name="favorite_items">我的最愛的 %s</string> + <string name="display_preset_default">Wholphin 預設樣式</string> + <string name="display_preset_compact">Wholphin 緊湊樣式</string> + <string name="display_preset_series_thumb">劇集縮圖樣式</string> + <string name="display_preset_episode_thumbnails">單集縮圖樣式</string> + <string name="aspect_ratios_poster">海報 (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">正方形 (1:1)</string> + <string name="image_type_primary">主封面</string> + <string name="image_type_thumb">橫式縮圖</string> + <string name="backdrop_style_dynamic">動態色彩背景</string> + <string name="backdrop_style_image">僅背景圖</string> + <string name="enter_url_api_key"><![CDATA[輸入 URL 及 API 金鑰]]></string> + <string name="api_key">API 金鑰</string> + <string name="seerr_login">登入 Seerr 伺服器</string> + <string name="seerr_jellyfin_user">Jellyfin 帳號</string> + <string name="seerr_local_user">Seerr 帳號</string> + <string name="search_and_download_subtitles"><![CDATA[搜尋和下載字幕]]></string> + <string name="no_subtitles_found">未找到遠端字幕</string> + <string name="series_continueing">至今</string> </resources> From cf369b26de34155f354f45e4d982e861d1c04bbb Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Wed, 25 Feb 2026 14:39:29 +0000 Subject: [PATCH 062/118] Translated using Weblate (Italian) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a3764b96..96b191c7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -480,4 +480,58 @@ <string name="use_series">Usa immagine delle serie</string> <string name="customize_home_summary">Scegli righe e immagini nella home page</string> <string name="favorite_items">Preferito %s</string> + <string name="content_scale_fit">Adatta</string> + <string name="content_scale_crop">Ritaglia</string> + <string name="content_scale_fill">Riempi</string> + <string name="content_scale_fill_width">Riempi larghezza</string> + <string name="content_scale_fill_height">Riempi altezza</string> + <string name="display_preset_default">Wholphin predefinito</string> + <string name="display_preset_compact">Wholphin Compatto</string> + <string name="display_preset_series_thumb">Immagini miniatura serie</string> + <string name="display_preset_episode_thumbnails">Immagini miniatura episodio</string> + <string name="volume_lowest">Minimo</string> + <string name="volume_low">Basso</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Massimo</string> + <string name="purple">Viola</string> + <string name="orange">Arancione</string> + <string name="bold_blue">Blu intenso</string> + <string name="black">Nero</string> + <string name="skip_ignore">Ignora</string> + <string name="skip_automatically">Salta automaticamente</string> + <string name="skip_ask">Chiedi se saltare</string> + <string name="ffmpeg_fallback">Usa FFmpeg solo se non esiste un decodificatore integrato</string> + <string name="ffmpeg_prefer">Preferisci FFmpeg ai decoder integrati</string> + <string name="ffmpeg_never">Non usare mai i decoder FFmpeg</string> + <string name="next_up_playback_end">Alla fine della riproduzione</string> + <string name="next_up_outro">Durante i titoli di coda</string> + <string name="white">Bianco</string> + <string name="light_gray">Grigio chiaro</string> + <string name="dark_gray">Grigio scuro</string> + <string name="yellow">Giallo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Ombra</string> + <string name="background_style_wrap">Avvolgi</string> + <string name="background_style_boxed">Con riquadro</string> + <string name="prefer_mpv">Preferisci MVP</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usa ExoPlayer per la riproduzione HDR</string> + <string name="aspect_ratios_poster">Copertina (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrato (1:1)</string> + <string name="image_type_primary">Principale</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Immagine con colore dinamico</string> + <string name="backdrop_style_image">Solo immagine</string> + <string name="enter_url_api_key"><![CDATA[Inserisci URL e chiave API]]></string> + <string name="api_key">Chiave API</string> + <string name="seerr_login">Accedi al server Seerr</string> + <string name="seerr_jellyfin_user">Utente Jellyfin</string> + <string name="seerr_local_user">Utente locale</string> + <string name="search_and_download_subtitles"><![CDATA[Cerca e scarica sottotitoli]]></string> + <string name="no_subtitles_found">Nessun sottotitolo remoto trovato</string> + <string name="series_continueing">In corso</string> </resources> From 0f8b5db397c0a108e1813c7a92c6e3fb033fcac1 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 25 Feb 2026 13:44:13 +0000 Subject: [PATCH 063/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 28875ea9..a49a19cf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -446,4 +446,58 @@ <string name="display_presets_description">可快速设置所有行样式的内置预设</string> <string name="customize_home_summary">选择首页上的行和图片</string> <string name="favorite_items">收藏的 %s</string> + <string name="content_scale_fit">适应</string> + <string name="content_scale_crop">裁剪</string> + <string name="content_scale_fill">填充</string> + <string name="content_scale_fill_width">填充宽度</string> + <string name="content_scale_fill_height">填充高度</string> + <string name="display_preset_default">Wholphin 默认</string> + <string name="display_preset_compact">Wholphin 紧凑</string> + <string name="display_preset_series_thumb">剧集缩略图</string> + <string name="display_preset_episode_thumbnails">分集缩略图</string> + <string name="purple">紫色</string> + <string name="orange">橙色</string> + <string name="bold_blue">深蓝色</string> + <string name="black">黑色</string> + <string name="skip_ignore">忽略</string> + <string name="skip_automatically">自动跳过</string> + <string name="skip_ask">询问后跳过</string> + <string name="ffmpeg_fallback">仅在无内置解码器时使用 FFmpeg</string> + <string name="ffmpeg_prefer">优先使用 FFmpeg 解码器而非内置解码器</string> + <string name="ffmpeg_never">从不使用 FFmpeg 解码器</string> + <string name="next_up_playback_end">播放结束时</string> + <string name="next_up_outro">播到片尾时</string> + <string name="white">白色</string> + <string name="light_gray">浅灰色</string> + <string name="dark_gray">深灰色</string> + <string name="yellow">黄色</string> + <string name="cyan">青色</string> + <string name="magenta">紫红色</string> + <string name="subtitle_edge_outline">轮廓</string> + <string name="subtitle_edge_shadow">阴影</string> + <string name="prefer_mpv">优先使用 mpv</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR 播放使用 ExoPlayer</string> + <string name="aspect_ratios_poster">海报 (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">正方形 (1:1)</string> + <string name="image_type_primary">主图</string> + <string name="image_type_thumb">缩略图</string> + <string name="backdrop_style_dynamic">动态配色图片</string> + <string name="backdrop_style_image">仅图片</string> + <string name="enter_url_api_key"><![CDATA[输入 URL 与 API 密钥]]></string> + <string name="api_key">API 密钥</string> + <string name="seerr_login">登录 Seerr 服务器</string> + <string name="seerr_jellyfin_user">Jellyfin 用户</string> + <string name="seerr_local_user">本地用户</string> + <string name="search_and_download_subtitles"><![CDATA[搜索并下载字幕]]></string> + <string name="no_subtitles_found">未找到远程字幕</string> + <string name="volume_lowest">最低音量</string> + <string name="volume_low">低音量</string> + <string name="volume_medium">中等音量</string> + <string name="volume_high">高音量</string> + <string name="volume_full">最大音量</string> + <string name="background_style_wrap">环绕式</string> + <string name="background_style_boxed">框式</string> + <string name="series_continueing">当前</string> </resources> From 0c94a0dbd62a76e10f3bd11b7068d656adeff92c Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 24 Feb 2026 20:36:18 +0000 Subject: [PATCH 064/118] Translated using Weblate (Turkish) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index aec35e54..7de70d74 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -472,4 +472,58 @@ <string name="display_presets_description">Tüm satırları hızlıca stilize etmek için hazır ayarlar</string> <string name="favorite_items">Favori %s</string> <string name="customize_home_summary">Ana sayfadaki satırları ve görselleri seçin</string> + <string name="content_scale_fit">Sığdır</string> + <string name="content_scale_crop">Kırp</string> + <string name="content_scale_fill">Doldur</string> + <string name="content_scale_fill_width">Genişliği Doldur</string> + <string name="content_scale_fill_height">Yüksekliği Doldur</string> + <string name="display_preset_default">Wholphin Varsayılan</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_series_thumb">Dizi Küçük Resimleri</string> + <string name="display_preset_episode_thumbnails">Bölüm Küçük Resimleri</string> + <string name="volume_lowest">En Düşük</string> + <string name="volume_low">Düşük</string> + <string name="volume_medium">Orta</string> + <string name="volume_high">Yüksek</string> + <string name="volume_full">Tam</string> + <string name="purple">Mor</string> + <string name="orange">Turuncu</string> + <string name="bold_blue">Koyu Mavi</string> + <string name="black">Siyah</string> + <string name="skip_ignore">Yoksay</string> + <string name="skip_automatically">Otomatik Atla</string> + <string name="skip_ask">Atlamak için sor</string> + <string name="ffmpeg_fallback">Dahili çözücü yoksa FFmpeg kullan</string> + <string name="ffmpeg_prefer">Dahili çözücüler yerine FFmpeg\'i tercih et</string> + <string name="ffmpeg_never">FFmpeg çözücülerini asla kullanma</string> + <string name="next_up_playback_end">Oynatma sonunda</string> + <string name="next_up_outro">Kapanış bölümünde / Jenerik sırasında</string> + <string name="white">Beyaz</string> + <string name="light_gray">Açık Gri</string> + <string name="dark_gray">Koyu Gri</string> + <string name="yellow">Sarı</string> + <string name="cyan">Camgöbeği</string> + <string name="magenta">Macenta</string> + <string name="subtitle_edge_outline">Kenarlık</string> + <string name="subtitle_edge_shadow">Gölge</string> + <string name="background_style_wrap">Kapla</string> + <string name="background_style_boxed">Kutu içinde</string> + <string name="prefer_mpv">MPV\'yi tercih et</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR oynatma için ExoPlayer kullan</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kare (1:1)</string> + <string name="image_type_primary">Birincil</string> + <string name="image_type_thumb">Küçük Resim</string> + <string name="backdrop_style_dynamic">Dinamik renkli görsel</string> + <string name="backdrop_style_image">Sadece görsel</string> + <string name="enter_url_api_key"><![CDATA[URL ve API Anahtarını Girin]]></string> + <string name="api_key">API Anahtarı</string> + <string name="seerr_login">Seerr sunucusuna giriş yap</string> + <string name="seerr_jellyfin_user">Jellyfin kullanıcısı</string> + <string name="seerr_local_user">Yerel kullanıcı</string> + <string name="search_and_download_subtitles"><![CDATA[Altyazı ara ve indir]]></string> + <string name="no_subtitles_found">Uzak altyazı bulunamadı</string> + <string name="series_continueing">Mevcut</string> </resources> From c5d6eaa2719f4e6de5baa43ab0be15b923cbd767 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Thu, 26 Feb 2026 01:36:32 +0000 Subject: [PATCH 065/118] Translated using Weblate (Italian) Currently translated at 97.1% (473 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 96b191c7..1fb27ae7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -534,4 +534,9 @@ <string name="search_and_download_subtitles"><![CDATA[Cerca e scarica sottotitoli]]></string> <string name="no_subtitles_found">Nessun sottotitolo remoto trovato</string> <string name="series_continueing">In corso</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minuti</item> + <item quantity="other">%s minuti</item> + </plurals> </resources> From 691ae4a1a4db8831cf8d11a3363cafbfcdb330c1 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Thu, 26 Feb 2026 16:35:52 +0000 Subject: [PATCH 066/118] Translated using Weblate (Spanish) Currently translated at 100.0% (487 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3fe2aa97..c4b70a44 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -534,4 +534,23 @@ <string name="search_and_download_subtitles"><![CDATA[Buscar y descargar subtítulos]]></string> <string name="no_subtitles_found">No se encontraron subtítulos externos</string> <string name="series_continueing">Actualidad</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Comenzar después de</string> + <string name="animate">Animación</string> + <string name="max_age_rating">Clasificación por edad máxima</string> + <string name="for_all_ages">Para todas las edades</string> + <string name="up_to_age">Menores de %s</string> + <string name="screensaver">Salvapantallas</string> + <string name="screensaver_settings">Configuración de salvapantallas</string> + <string name="start_screensaver">Iniciar salvapantallas</string> + <string name="duration">Duración</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Qué tipos de elementos mostrar</string> + <string name="in_app_screensaver">Usar salvapantallas de la aplicación</string> + <string name="no_max">Sin límite</string> </resources> From 898191e44af874a08e35891a7374bd867347443b Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Thu, 26 Feb 2026 13:45:09 +0000 Subject: [PATCH 067/118] Translated using Weblate (Portuguese) Currently translated at 93.6% (456 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 47 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fb5fbb5..45d0fcd2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -463,8 +463,8 @@ <string name="apply_all_rows">Aplicar a todas as linhas</string> <string name="customize_home">Personalizar página inicial</string> <string name="home_rows">Linhas da página inicial</string> - <string name="load_from_server">Carregar do servidor</string> - <string name="save_to_server">Gravar para o servidor</string> + <string name="load_from_server">Carregar do perfil de utilizador do servidor</string> + <string name="save_to_server">Guardar no perfil de utilizador do servidor</string> <string name="load_from_web_client">Carregar a partir do cliente web</string> <string name="use_series">Usar imagem da série</string> <string name="add_row_for">Adicionar linha para %1$s</string> @@ -479,4 +479,47 @@ <string name="display_presets_description">Predefinições embarcadas para definir todas as linhas rapidamente</string> <string name="customize_home_summary">Escolha as linhas e imagens na página inicial</string> <string name="decrease_all_cards_size">Diminuir o tamanho de todos os cartões</string> + <string name="favorite_items">Favoritos %s</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Começar depois</string> + <string name="animate">Animado</string> + <string name="max_age_rating">Idade máxima</string> + <string name="no_max">Sem máximo</string> + <string name="for_all_ages">Para todas as idades</string> + <string name="up_to_age">Até a idade %s</string> + <string name="screensaver">Proteção de ecrã</string> + <string name="screensaver_settings">Definições de protecção de ecrã</string> + <string name="start_screensaver">Iniciar protecção de ecrã</string> + <string name="duration">Duração</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Que tipos de itens devem mostrar imagens</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Recortar</string> + <string name="content_scale_fill">Preencher</string> + <string name="content_scale_fill_width">Preencher largura</string> + <string name="content_scale_fill_height">Preencher altura</string> + <string name="display_preset_default">Padrão do Wholphin</string> + <string name="display_preset_compact">Wholphin Compacto</string> + <string name="display_preset_series_thumb">Imagens em miniatura da série</string> + <string name="display_preset_episode_thumbnails">Imagens em miniatura do episódio</string> + <string name="volume_lowest">Mínimo</string> + <string name="volume_low">Baixo</string> + <string name="volume_medium">Médio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Roxo</string> + <string name="orange">Laranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Preto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Saltar automaticamente</string> + <string name="skip_ask">Pedir para saltar</string> + <string name="ffmpeg_fallback">Usa FFmpeg apenas se não houver um descodificador integrado</string> + <string name="ffmpeg_prefer">Prefere o FFmpeg aos descodificadores integrados</string> + <string name="ffmpeg_never">Nunca usar os descodificadores FFmpeg</string> </resources> From a1d651ae21a7101e92ff142682c31ecf96f1d29b Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Thu, 26 Feb 2026 04:01:50 +0000 Subject: [PATCH 068/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 97.1% (473 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b325893a..0dac3571 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -500,4 +500,7 @@ <string name="search_and_download_subtitles"><![CDATA[搜尋和下載字幕]]></string> <string name="no_subtitles_found">未找到遠端字幕</string> <string name="series_continueing">至今</string> + <plurals name="minutes"> + <item quantity="other">%s 分鐘</item> + </plurals> </resources> From 034f91ba3a7d480223c90f43549ed688a3e558aa Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Thu, 26 Feb 2026 01:43:55 +0000 Subject: [PATCH 069/118] Translated using Weblate (Italian) Currently translated at 100.0% (487 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1fb27ae7..91e299b6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -539,4 +539,18 @@ <item quantity="many">%s minuti</item> <item quantity="other">%s minuti</item> </plurals> + <string name="start_after">Inizia dopo</string> + <string name="animate">Anima</string> + <string name="max_age_rating">Età massima</string> + <string name="for_all_ages">Per tutte le età</string> + <string name="up_to_age">Fino a %s anni</string> + <string name="screensaver">Salvaschermo</string> + <string name="screensaver_settings">Impostazioni salvaschermo</string> + <string name="start_screensaver">Avvia salvaschermo</string> + <string name="duration">Durata</string> + <string name="photos">Foto</string> + <string name="include_types">Includi tipi</string> + <string name="include_types_summary">Quali tipi di elementi mostrare nelle immagini</string> + <string name="no_max">Nessun massimo</string> + <string name="in_app_screensaver">Usa salvaschermo dell\'app</string> </resources> From c5316343b9d3db893f0ad9b3ec33054b5a6e1f82 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Thu, 26 Feb 2026 13:41:46 +0000 Subject: [PATCH 070/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (487 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a49a19cf..2ec747ca 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -150,7 +150,7 @@ <string name="font">字体</string> <string name="background">背景</string> <string name="success">成功</string> - <string name="official_rating">年龄分级</string> + <string name="official_rating">内容分级</string> <string name="runtime_sort">播放时长</string> <string name="extras">附加内容</string> <plurals name="other_extras"> @@ -500,4 +500,21 @@ <string name="background_style_wrap">环绕式</string> <string name="background_style_boxed">框式</string> <string name="series_continueing">当前</string> + <plurals name="minutes"> + <item quantity="other">%s 分钟</item> + </plurals> + <string name="animate">动画</string> + <string name="start_after">启动时间</string> + <string name="max_age_rating">最高年龄分级</string> + <string name="no_max">无上限</string> + <string name="for_all_ages">所有年龄</string> + <string name="up_to_age">最高至 %s 岁</string> + <string name="screensaver">屏幕保护程序</string> + <string name="screensaver_settings">屏幕保护程序设置</string> + <string name="start_screensaver">启动屏幕保护程序</string> + <string name="duration">时长</string> + <string name="photos">照片</string> + <string name="include_types">包含类型</string> + <string name="include_types_summary">要显示图片的项目类型</string> + <string name="in_app_screensaver">使用应用内屏幕保护程序</string> </resources> From 9aaee3033ee57b9a5565d2add766a9ff86a5f624 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Thu, 26 Feb 2026 20:25:19 +0000 Subject: [PATCH 071/118] Translated using Weblate (Turkish) Currently translated at 100.0% (487 of 487 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7de70d74..9a1d41bb 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -526,4 +526,22 @@ <string name="search_and_download_subtitles"><![CDATA[Altyazı ara ve indir]]></string> <string name="no_subtitles_found">Uzak altyazı bulunamadı</string> <string name="series_continueing">Mevcut</string> + <plurals name="minutes"> + <item quantity="one">%s dakika</item> + <item quantity="other">%s dakika</item> + </plurals> + <string name="start_after">Başlama süresi</string> + <string name="animate">Oynatma Süresi</string> + <string name="max_age_rating">Maksimum yaş sınırı</string> + <string name="no_max">Sınır yok</string> + <string name="for_all_ages">Genel İzleyici</string> + <string name="up_to_age">%s yaşa kadar</string> + <string name="screensaver">Ekran koruyucu</string> + <string name="screensaver_settings">Ekran koruyucu ayarları</string> + <string name="start_screensaver">Ekran koruyucuyu başlat</string> + <string name="duration">Süre</string> + <string name="photos">Fotoğraflar</string> + <string name="include_types">İçerik türleri</string> + <string name="include_types_summary">Görsel gösterilecek öğe türleri</string> + <string name="in_app_screensaver">Uygulama içi ekran koruyucuyu kullan</string> </resources> From 7432df3eb18d074dd9bd3d4f34f08b1260c828ed Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 27 Feb 2026 05:58:24 +0000 Subject: [PATCH 072/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0dac3571..8a1478a9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -503,4 +503,30 @@ <plurals name="minutes"> <item quantity="other">%s 分鐘</item> </plurals> + <string name="start_after">啟動時間</string> + <string name="animate">動態效果</string> + <string name="max_age_rating">年齡分級上限</string> + <string name="no_max">不限制</string> + <string name="for_all_ages">適合所有年齡</string> + <string name="up_to_age">最高至 %s 歲</string> + <string name="screensaver">螢幕保護程式</string> + <string name="screensaver_settings">螢幕保護程式設定</string> + <string name="start_screensaver">立即開始</string> + <string name="duration">切換間隔</string> + <string name="photos">照片</string> + <string name="include_types">包含類型</string> + <string name="include_types_summary">要顯示圖片的類型</string> + <string name="in_app_screensaver">使用內建螢幕保護程式</string> + <string name="guest_star">客串演員</string> + <string name="actor">演員</string> + <string name="composer">作曲</string> + <string name="writer">編劇</string> + <string name="producer">製作人</string> + <string name="conductor">指揮</string> + <string name="lyricist">作詞</string> + <string name="arranger">編曲</string> + <string name="engineer">錄音師</string> + <string name="mixer">混音師</string> + <string name="creator">創作者</string> + <string name="artist">演出者</string> </resources> From fd53206aeeed8db517995bd778248c02fa44d765 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 27 Feb 2026 04:35:44 +0000 Subject: [PATCH 073/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2ec747ca..ad8008a2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -517,4 +517,16 @@ <string name="include_types">包含类型</string> <string name="include_types_summary">要显示图片的项目类型</string> <string name="in_app_screensaver">使用应用内屏幕保护程序</string> + <string name="actor">演员</string> + <string name="composer">作曲</string> + <string name="writer">编剧</string> + <string name="guest_star">客串</string> + <string name="producer">制片</string> + <string name="conductor">指挥</string> + <string name="lyricist">作词</string> + <string name="arranger">编曲</string> + <string name="engineer">录音</string> + <string name="mixer">混音</string> + <string name="creator">主创</string> + <string name="artist">艺人</string> </resources> From b2aea426ef964eb589c279745bddfccf73e94579 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Fri, 27 Feb 2026 14:03:03 +0000 Subject: [PATCH 074/118] Translated using Weblate (Spanish) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c4b70a44..a6340bc5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -553,4 +553,16 @@ <string name="include_types_summary">Qué tipos de elementos mostrar</string> <string name="in_app_screensaver">Usar salvapantallas de la aplicación</string> <string name="no_max">Sin límite</string> + <string name="actor">Intérprete</string> + <string name="composer">Compositor</string> + <string name="writer">Guionista</string> + <string name="guest_star">Estrella invitada</string> + <string name="producer">Productor</string> + <string name="conductor">Director musical</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arreglista</string> + <string name="engineer">Ingeniero de sonido</string> + <string name="mixer">Ingeniero de mezcla</string> + <string name="creator">Creador</string> + <string name="artist">Artista</string> </resources> From ea4e4ad1409c52587df64b6ded4d1a10f901a7af Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Fri, 27 Feb 2026 14:39:11 +0000 Subject: [PATCH 075/118] Translated using Weblate (Italian) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 91e299b6..2f22c8d4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -553,4 +553,16 @@ <string name="include_types_summary">Quali tipi di elementi mostrare nelle immagini</string> <string name="no_max">Nessun massimo</string> <string name="in_app_screensaver">Usa salvaschermo dell\'app</string> + <string name="composer">Compositore</string> + <string name="writer">Sceneggiatore</string> + <string name="actor">Attore</string> + <string name="producer">Produttore</string> + <string name="guest_star">Ospite speciale</string> + <string name="conductor">Direttore d’orchestra</string> + <string name="lyricist">Paroliere</string> + <string name="arranger">Arrangiatore</string> + <string name="engineer">Ingegnere del suono</string> + <string name="mixer">Tecnico di mixaggio</string> + <string name="artist">Artista</string> + <string name="creator">Creatore</string> </resources> From 321ef8965a39236d1debe1c76ac536cb34ef7dec Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Fri, 27 Feb 2026 09:18:21 +0000 Subject: [PATCH 076/118] Translated using Weblate (Turkish) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9a1d41bb..636a6349 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -544,4 +544,16 @@ <string name="include_types">İçerik türleri</string> <string name="include_types_summary">Görsel gösterilecek öğe türleri</string> <string name="in_app_screensaver">Uygulama içi ekran koruyucuyu kullan</string> + <string name="actor">Aktör</string> + <string name="composer">Besteci</string> + <string name="writer">Yazar</string> + <string name="guest_star">Konuk sanatçı</string> + <string name="producer">Yapımcı</string> + <string name="conductor">Orkestra şefi</string> + <string name="lyricist">Söz yazarı</string> + <string name="arranger">Aranjör</string> + <string name="engineer">Mühendis</string> + <string name="mixer">Karıştırıcı</string> + <string name="creator">Yaratıcı</string> + <string name="artist">Sanatçı</string> </resources> From 9daa6c9337c55c9610e87e06f950c55c0e14704b Mon Sep 17 00:00:00 2001 From: EmadAljohani <emadaljohani@noreply.codeberg.org> Date: Sat, 28 Feb 2026 04:27:59 +0000 Subject: [PATCH 077/118] Added translation using Weblate (Arabic) --- app/src/main/res/values-ar/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-ar/strings.xml diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> \ No newline at end of file From 46c79938363d35bec14f486dc30d0f7a703cc530 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Sat, 28 Feb 2026 14:26:37 +0000 Subject: [PATCH 078/118] Translated using Weblate (Portuguese) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 45d0fcd2..62cafc3f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -522,4 +522,47 @@ <string name="ffmpeg_fallback">Usa FFmpeg apenas se não houver um descodificador integrado</string> <string name="ffmpeg_prefer">Prefere o FFmpeg aos descodificadores integrados</string> <string name="ffmpeg_never">Nunca usar os descodificadores FFmpeg</string> + <string name="next_up_playback_end">No final da reprodução</string> + <string name="next_up_outro">Durante os créditos finais</string> + <string name="white">Branco</string> + <string name="light_gray">Cinzento claro</string> + <string name="dark_gray">Cinzento escuro</string> + <string name="yellow">Amarelo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Envolver</string> + <string name="background_style_boxed">Com caixa</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrado (1:1)</string> + <string name="image_type_primary">Primária</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> + <string name="backdrop_style_image">Apenas imagem</string> + <string name="enter_url_api_key"><![CDATA[Inserir URL e Chave API]]></string> + <string name="api_key">Chave API</string> + <string name="seerr_login">Iniciar sessão no servidor Seerr</string> + <string name="seerr_jellyfin_user">Utilizador Jellyfin</string> + <string name="seerr_local_user">Utilizador local</string> + <string name="search_and_download_subtitles"><![CDATA[Procurar e transferir legendas]]></string> + <string name="no_subtitles_found">Não foram encontradas legendas remotas</string> + <string name="series_continueing">Em curso</string> + <string name="in_app_screensaver">Usar protetor de ecrã da aplicação</string> + <string name="actor">Actor</string> + <string name="composer">Compositor</string> + <string name="writer">Argumentista</string> + <string name="guest_star">Convidado especial</string> + <string name="producer">Produtor</string> + <string name="conductor">Director musicar</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arranjador</string> + <string name="engineer">Engenheiro de som</string> + <string name="mixer">Técnico de mixagem</string> + <string name="creator">Criador</string> + <string name="artist">Artista</string> </resources> From 49667bc053bc6f4bba2bf96e427f105103f74bc9 Mon Sep 17 00:00:00 2001 From: zyplex <zyplex@noreply.codeberg.org> Date: Sun, 1 Mar 2026 16:30:22 +0000 Subject: [PATCH 079/118] Translated using Weblate (German) Currently translated at 79.1% (395 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 013f5ccd..8076b47f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -432,4 +432,18 @@ <string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string> <string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string> <string name="quick_connect">Quick Connect</string> + <plurals name="minutes"> + <item quantity="one">%s Minute</item> + <item quantity="other">%s Minuten</item> + </plurals> + <string name="interlaced">Interlaced</string> + <string name="nal">NAL</string> + <string name="quick_connect_summary">Gewähre anderen Geräten Zugriff auf deinen Account</string> + <string name="quick_connect_code">Eingabe Quick Connect Code</string> + <string name="no_limit">Keine Beschränkung</string> + <string name="add_row">Reihe hinzufügen</string> + <string name="genres_in">Genres in %1$s</string> + <string name="recently_released_in">kürzlich veröffentlicht in %1$s</string> + <string name="height">Höhe</string> + <string name="apply_all_rows">Auf alle Reihen anwenden</string> </resources> From 3284a400842783dc52abe729b324a60f523b33e6 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Mon, 2 Mar 2026 02:27:25 +0000 Subject: [PATCH 080/118] Translated using Weblate (Portuguese) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 62cafc3f..6d8ac4e0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -121,7 +121,7 @@ <string name="top_unwatched">Mais Votados Não Assistidos</string> <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailers</item> + <item quantity="one">Trailer</item> <item quantity="many">Trailers</item> <item quantity="other">Trailers</item> </plurals> @@ -553,7 +553,7 @@ <string name="no_subtitles_found">Não foram encontradas legendas remotas</string> <string name="series_continueing">Em curso</string> <string name="in_app_screensaver">Usar protetor de ecrã da aplicação</string> - <string name="actor">Actor</string> + <string name="actor">Ator</string> <string name="composer">Compositor</string> <string name="writer">Argumentista</string> <string name="guest_star">Convidado especial</string> From 01ac0dec4cb2ea0a2800ec8810b0098624968664 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Mon, 2 Mar 2026 03:37:07 +0000 Subject: [PATCH 081/118] Translated using Weblate (Indonesian) Currently translated at 98.3% (491 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 81 +++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 356732cd..23e81de6 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -242,8 +242,8 @@ <string name="font_size">Ukuran font</string> <string name="font_color">Warna font</string> <string name="font_opacity">Transparansi font</string> - <string name="edge_style">Jenis garis tepi</string> - <string name="edge_color">Warna garis tepi</string> + <string name="edge_style">Jenis pinggiran</string> + <string name="edge_color">Warna pinggiran</string> <string name="background_opacity">Transparansi latar belakang</string> <string name="background_style">Gaya latar belakang</string> <string name="subtitle_style">Gaya subtitel</string> @@ -318,7 +318,7 @@ <string name="require_pin_code">Membutuhkan PIN untuk profil</string> <string name="will_remove_pin">PIN akan dihapus</string> <string name="view_options">Opsi tampilan</string> - <string name="edge_size">Ukuran garis tepi</string> + <string name="edge_size">Ukuran pinggiran</string> <string name="automatic">Otomatis</string> <string name="username_or_password">Gunakan nama pengguna/kata sandi</string> <string name="subtitle_margin">Batas tepi</string> @@ -446,4 +446,79 @@ <string name="display_presets_description">Preset bawaan untuk kustomisasi cepat semua baris</string> <string name="customize_home_summary">Pilih baris dan gambar di halaman beranda</string> <string name="favorite_items">Favoritkan %s</string> + <string name="artist">Artis</string> + <plurals name="minutes"> + <item quantity="other">%s menit</item> + </plurals> + <string name="start_after">Mulai setelah</string> + <string name="animate">Animasi</string> + <string name="max_age_rating">Rating usia maks</string> + <string name="no_max">Tidak ada maks</string> + <string name="for_all_ages">Untuk semua umur</string> + <string name="up_to_age">Maks. usia %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Pengaturan screensaver</string> + <string name="start_screensaver">Mulai screensaver</string> + <string name="duration">Durasi</string> + <string name="photos">Foto</string> + <string name="include_types">Sertakan jenis</string> + <string name="include_types_summary">Tampilkan gambar untuk jenis item</string> + <string name="display_preset_default">Wholphin Bawaan</string> + <string name="display_preset_compact">Wholphin Ringkas</string> + <string name="display_preset_series_thumb">Gambar Thumbnail Serial</string> + <string name="display_preset_episode_thumbnails">Gambar Thumbnail Episode</string> + <string name="volume_lowest">Terendah</string> + <string name="volume_low">Rendah</string> + <string name="volume_medium">Sedang</string> + <string name="volume_high">Tinggi</string> + <string name="volume_full">Penuh</string> + <string name="purple">Ungu</string> + <string name="orange">Oranye</string> + <string name="bold_blue">Biru Tegas</string> + <string name="black">Hitam</string> + <string name="skip_ignore">Abaikan</string> + <string name="skip_automatically">Lewati otomatis</string> + <string name="skip_ask">Tanya sebelum melewati</string> + <string name="ffmpeg_fallback">Gunakan FFmpeg hanya jika dekoder bawaan tidak ada</string> + <string name="ffmpeg_prefer">Utamakan FFmpeg daripada dekoder bawaan</string> + <string name="ffmpeg_never">Jangan pernah gunakan dekoder FFmpeg</string> + <string name="next_up_playback_end">Di akhir pemutaran</string> + <string name="next_up_outro">Selama kredit akhir/outro</string> + <string name="white">Putih</string> + <string name="light_gray">Abu-abu muda</string> + <string name="dark_gray">Abu-abu tua</string> + <string name="yellow">Kuning</string> + <string name="cyan">Sian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Garis luar</string> + <string name="prefer_mpv">Utamakan MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Gunakan ExoPlayer untuk pemutaran HDR</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Persegi (1:1)</string> + <string name="image_type_primary">Utama</string> + <string name="image_type_thumb">Thumbnail</string> + <string name="backdrop_style_dynamic">Gambar dengan warna dinamis</string> + <string name="backdrop_style_image">Hanya gambar</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Masuk ke server Seerr</string> + <string name="seerr_jellyfin_user">Pengguna Jellyfin</string> + <string name="seerr_local_user">Pengguna lokal</string> + <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> + <string name="no_subtitles_found">Subtitel remote tidak ditemukan</string> + <string name="series_continueing">Tayang</string> + <string name="in_app_screensaver">Gunakan screensaver dalam aplikasi</string> + <string name="actor">Aktor</string> + <string name="composer">Komposer</string> + <string name="writer">Penulis</string> + <string name="guest_star">Bintang tamu</string> + <string name="producer">Produser</string> + <string name="conductor">Konduktor</string> + <string name="lyricist">Penulis lirik</string> + <string name="arranger">Aransemen</string> + <string name="engineer">Teknisi</string> + <string name="mixer">Mixer</string> + <string name="creator">Kreator</string> </resources> From d051b0d4025fa4a898db32a5b9be393102f40794 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 2 Mar 2026 07:29:45 +0000 Subject: [PATCH 082/118] Translated using Weblate (French) Currently translated at 100.0% (499 of 499 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 538fd523..04a595ed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -480,4 +480,89 @@ <string name="display_presets_description">Préréglages intégrés pour styliser rapidement toutes les lignes</string> <string name="customize_home_summary">Choisissez des lignes et des images sur la page d\'accueil</string> <string name="favorite_items">Favori %s</string> + <plurals name="minutes"> + <item quantity="one">%s minute</item> + <item quantity="many">%s minutes</item> + <item quantity="other">%s minute</item> + </plurals> + <string name="start_after">Démarrer après</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Classification par âge maximale</string> + <string name="no_max">Pas de maximum</string> + <string name="for_all_ages">Pour tous les âges</string> + <string name="up_to_age">Jusqu\'à l\'âge de %s</string> + <string name="screensaver">Écran de veille</string> + <string name="screensaver_settings">Paramètres de l\'économiseur d\'écran</string> + <string name="start_screensaver">Lancer l\'économiseur d\'écran</string> + <string name="duration">Durée</string> + <string name="photos">Photos</string> + <string name="include_types">Inclure les types</string> + <string name="include_types_summary">Quels types d\'éléments afficher des images pour</string> + <string name="content_scale_fit">Ajustement</string> + <string name="content_scale_crop">Recadrer</string> + <string name="content_scale_fill">Remplir</string> + <string name="content_scale_fill_width">Largeur de remplissage</string> + <string name="content_scale_fill_height">Hauteur de remplissage</string> + <string name="display_preset_default">Wholphin Par défaut</string> + <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_series_thumb">Images miniatures de la série</string> + <string name="display_preset_episode_thumbnails">Images miniatures de l\'épisode</string> + <string name="volume_lowest">Le plus bas</string> + <string name="volume_low">Faible</string> + <string name="volume_medium">Moyen</string> + <string name="volume_high">Haute</string> + <string name="volume_full">Complet</string> + <string name="purple">Violet</string> + <string name="orange">Orange</string> + <string name="bold_blue">Bleu gras</string> + <string name="black">Noir</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Passer automatiquement</string> + <string name="skip_ask">Demander à passer</string> + <string name="ffmpeg_fallback">N\'utilisez FFmpeg que si aucun décodeur intégré n\'existe</string> + <string name="ffmpeg_prefer">Préférez utiliser FFmpeg plutôt que les décodeurs intégrés</string> + <string name="ffmpeg_never">N\'utilisez jamais les décodeurs FFmpeg</string> + <string name="next_up_playback_end">À la fin de la lecture</string> + <string name="next_up_outro">Pendant le générique de fin/outro</string> + <string name="white">Blanc</string> + <string name="light_gray">Gris clair</string> + <string name="dark_gray">Gris foncé</string> + <string name="yellow">Jaune</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contour</string> + <string name="subtitle_edge_shadow">Ombre</string> + <string name="background_style_wrap">Envelopper</string> + <string name="background_style_boxed">Encadré</string> + <string name="prefer_mpv">Préférer MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Utiliser ExoPlayer pour la lecture HDR</string> + <string name="aspect_ratios_poster">Affiche (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Carré (1:1)</string> + <string name="image_type_primary">Primaire</string> + <string name="image_type_thumb">Miniature</string> + <string name="backdrop_style_dynamic">Image avec couleur dynamique</string> + <string name="backdrop_style_image">Image uniquement</string> + <string name="enter_url_api_key">< ![CDATA[Entrez l\'URL et la clé API]]></string> + <string name="api_key">Clé API</string> + <string name="seerr_login">Se connecter au serveur Seerr</string> + <string name="seerr_jellyfin_user">Utilisateur Jellyfin</string> + <string name="seerr_local_user">Utilisateur local</string> + <string name="search_and_download_subtitles">< ![CDATA[Rechercher et télécharger des sous-titres]]></string> + <string name="no_subtitles_found">Aucun sous-titre distant n\'a été trouvé</string> + <string name="series_continueing">Présent</string> + <string name="in_app_screensaver">Utiliser l\'économiseur d\'écran intégré</string> + <string name="actor">Acteur</string> + <string name="composer">Compositeur</string> + <string name="writer">Écrivain</string> + <string name="guest_star">Invité spécial</string> + <string name="producer">Producteur</string> + <string name="conductor">Conducteur</string> + <string name="lyricist">Parolier</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingénieur</string> + <string name="mixer">Mélangeur</string> + <string name="creator">Créateur</string> + <string name="artist">Artiste</string> </resources> From d4cb9a6c9276c237d79213474360f8149a048ea7 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 3 Mar 2026 13:03:29 +0000 Subject: [PATCH 083/118] Translated using Weblate (Spanish) Currently translated at 100.0% (501 of 501 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a6340bc5..3ad0ffa1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -565,4 +565,6 @@ <string name="mixer">Ingeniero de mezcla</string> <string name="creator">Creador</string> <string name="artist">Artista</string> + <string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string> + <string name="show_media_management">Mostrar opciones de gestión de medios</string> </resources> From 61369ffac883eb70cb931bd28b554aaf8f59e9a5 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Tue, 3 Mar 2026 11:21:43 +0000 Subject: [PATCH 084/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (501 of 501 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ad8008a2..fa30747e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -529,4 +529,6 @@ <string name="mixer">混音</string> <string name="creator">主创</string> <string name="artist">艺人</string> + <string name="show_media_management">显示媒体管理选项</string> + <string name="delete_item">是否确定要删除此项目?</string> </resources> From 4ee8a5b4fcfbccfa595e1e97154461965b63444f Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 3 Mar 2026 12:15:43 +0000 Subject: [PATCH 085/118] Translated using Weblate (Turkish) Currently translated at 100.0% (501 of 501 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 636a6349..f08bed40 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -556,4 +556,6 @@ <string name="mixer">Karıştırıcı</string> <string name="creator">Yaratıcı</string> <string name="artist">Sanatçı</string> + <string name="delete_item">Bu öğeyi silmek istediğinizden emin misiniz?</string> + <string name="show_media_management">Medya yönetim seçeneklerini göster</string> </resources> From 000bd106228421f6a3af5cfe10e080846b79e11e Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 3 Mar 2026 23:44:51 +0000 Subject: [PATCH 086/118] Translated using Weblate (Spanish) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3ad0ffa1..93bf5c7c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -80,7 +80,7 @@ <string name="create_playlist">Crear nueva lista de reproducción</string> <string name="add_to_playlist">Agregar a la lista de reproducción</string> <string name="one_click_pause">Pausar con un clic</string> - <string name="one_click_pause_summary_on">Presiona el centro del D-Pad para pausar/reproducir</string> + <string name="one_click_pause_summary_on">Pulsa el botón central de la cruceta para reproducir/pausar</string> <string name="success">Éxito</string> <string name="runtime_sort">Duración</string> <string name="extras">Extras</string> @@ -567,4 +567,5 @@ <string name="artist">Artista</string> <string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string> <string name="show_media_management">Mostrar opciones de gestión de medios</string> + <string name="search_for">Buscar %s</string> </resources> From 30095366703300e384968f60a9424387ff6f7928 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 4 Mar 2026 02:38:45 +0000 Subject: [PATCH 087/118] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8a1478a9..5bf09d0f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -529,4 +529,7 @@ <string name="mixer">混音師</string> <string name="creator">創作者</string> <string name="artist">演出者</string> + <string name="delete_item">確定要刪除此項目嗎?</string> + <string name="show_media_management">顯示媒體管理選項</string> + <string name="search_for">搜尋 %s</string> </resources> From b0236cbd3b74598204bb1efe7375b918a9566ce4 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 4 Mar 2026 02:10:14 +0000 Subject: [PATCH 088/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fa30747e..47821481 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -531,4 +531,5 @@ <string name="artist">艺人</string> <string name="show_media_management">显示媒体管理选项</string> <string name="delete_item">是否确定要删除此项目?</string> + <string name="search_for">搜索 %s</string> </resources> From d69b6d6bb491725f8c848b22c64b5795ed2415a2 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 3 Mar 2026 20:52:19 +0000 Subject: [PATCH 089/118] Translated using Weblate (Turkish) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f08bed40..a91c431b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -558,4 +558,5 @@ <string name="artist">Sanatçı</string> <string name="delete_item">Bu öğeyi silmek istediğinizden emin misiniz?</string> <string name="show_media_management">Medya yönetim seçeneklerini göster</string> + <string name="search_for">Ara %s</string> </resources> From 0d326b49e750b7ddb06b54b1b43cf5800db986c7 Mon Sep 17 00:00:00 2001 From: zyplex <zyplex@noreply.codeberg.org> Date: Thu, 5 Mar 2026 22:28:00 +0000 Subject: [PATCH 090/118] Translated using Weblate (German) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8076b47f..ffc2c399 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -446,4 +446,19 @@ <string name="recently_released_in">kürzlich veröffentlicht in %1$s</string> <string name="height">Höhe</string> <string name="apply_all_rows">Auf alle Reihen anwenden</string> + <string name="add_row_for">Reihe hinzufügen für %1$s</string> + <string name="overwrite_server_settings">Überschreibe Einstellungen auf dem Server?</string> + <string name="overwrite_local_settings">Überschreibe lokale Einstellungen?</string> + <string name="suggestions_for">Vorschläge für %1$s</string> + <string name="send_media_info_log_to_server">Sende Media Info Log zum Server</string> + <string name="customize_home">Anpassung Homepage</string> + <string name="for_episodes">Für Episoden</string> + <string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string> + <string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string> + <string name="start_after">Starte anschließend</string> + <string name="display_preset_series_thumb">Serien Thumbnails</string> + <string name="display_preset_episode_thumbnails">Episoden Thumbnail</string> + <string name="seerr_login">Einloggen zum Seerr Server</string> + <string name="seerr_jellyfin_user">Jellyfin User</string> + <string name="show_media_management">Zeige Media Management Optionen</string> </resources> From b6cef6a4b18960edc4924868369ea48e44a00070 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Fri, 6 Mar 2026 12:49:17 +0000 Subject: [PATCH 091/118] Translated using Weblate (German) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 113 ++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ffc2c399..88899ee7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -199,12 +199,12 @@ <string name="italic_font">Kursivschrift</string> <string name="runtime_sort">Laufzeit</string> <plurals name="theme_songs"> - <item quantity="one">Theme Songs</item> - <item quantity="other"/> + <item quantity="one">Titelsong</item> + <item quantity="other">Titelsongs</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Theme Videos</item> - <item quantity="other"/> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideos</item> </plurals> <plurals name="clips"> <item quantity="one">Clip</item> @@ -224,11 +224,11 @@ </plurals> <plurals name="shorts"> <item quantity="one">Shorts</item> - <item quantity="other"/> + <item quantity="other">Kurzvideos</item> </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> - <string name="direct_play_ass">Direct play ASS subtitles</string> - <string name="direct_play_pgs">Direct play PGS subtitles</string> + <string name="direct_play_ass">Direktwiedergabe ASS Untertitel</string> + <string name="direct_play_pgs">Direktwiedergabe PGS Untertitel</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> <string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string> <string name="global_content_scale">Standard Skalierung</string> @@ -251,8 +251,8 @@ <string name="font_size">Schriftgröße</string> <string name="font_color">Schriftfarbe</string> <string name="font_opacity">Schriftart Deckkraft</string> - <string name="edge_style">Rand Stil</string> - <string name="edge_color">Rand Farbe</string> + <string name="edge_style">Stil der Umrandung</string> + <string name="edge_color">Farbe der Umrandung</string> <string name="background_opacity">Hintergrund Deckktraft</string> <string name="background_style">Hintergrund Stil</string> <string name="subtitle_style">Untertitel Stil</string> @@ -329,7 +329,7 @@ <string name="use_server_credentials">Login via Server</string> <string name="press_enter_to_confirm">Drücke die mittlere Taste zur Bestätigung</string> <string name="image_cache_size">Größe des Festplatten-Caches für Bilder (MB)</string> - <string name="cast_and_crew">Besetzung & Mitwirkende</string> + <string name="cast_and_crew"><![CDATA[Besetzung & Mitwirkende]]></string> <string name="container">Container</string> <string name="codec">Codec</string> <string name="level">Level</string> @@ -456,9 +456,98 @@ <string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string> <string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string> <string name="start_after">Starte anschließend</string> - <string name="display_preset_series_thumb">Serien Thumbnails</string> - <string name="display_preset_episode_thumbnails">Episoden Thumbnail</string> + <string name="display_preset_series_thumb">Serien Vorschaubilder</string> + <string name="display_preset_episode_thumbnails">Episoden Vorschaubilder</string> <string name="seerr_login">Einloggen zum Seerr Server</string> <string name="seerr_jellyfin_user">Jellyfin User</string> <string name="show_media_management">Zeige Media Management Optionen</string> + <string name="decrease_all_cards_size">Größe für alle Karten reduzieren</string> + <string name="settings_saved">Einstellungen gespeichert</string> + <string name="max_age_rating">Maximale Altersfreigabe</string> + <string name="no_max">Keine Begrenzung</string> + <string name="for_all_ages">Ohne Altersbeschränkung</string> + <string name="up_to_age">Ab %s Jahre</string> + <string name="screensaver">Bildschirmschoner</string> + <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="start_screensaver">Bildschirmschoner starten</string> + <string name="duration">Länge</string> + <string name="photos">Fotos</string> + <string name="include_types">Arten einschließen</string> + <string name="include_types_summary">Für welche Arten von Einträgen sollen Bilder angezeigt werden</string> + <string name="content_scale_fit">Angepasst</string> + <string name="content_scale_crop">Zugeschnitten</string> + <string name="content_scale_fill">Ausgefüllt</string> + <string name="skip_ignore">Ignorieren</string> + <string name="skip_automatically">Automatisch überspringen</string> + <string name="skip_ask">Vor dem Überspringen nachfragen</string> + <string name="ffmpeg_fallback">FFmpeg nur verwenden, sofern keine anderen Decoder vorhanden sind</string> + <string name="ffmpeg_prefer">FFmpeg statt der vorhandenen Decoder verwenden</string> + <string name="ffmpeg_never">FFmpeg Decoder nie verwenden</string> + <string name="next_up_playback_end">Am Ende einer Playlist</string> + <string name="next_up_outro">Während der Credits/Outro</string> + <string name="white">Weiß</string> + <string name="light_gray">Hellgrau</string> + <string name="dark_gray">Dunkelgrau</string> + <string name="yellow">Gelb</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Umrandung</string> + <string name="subtitle_edge_shadow">Schatten</string> + <string name="prefer_mpv">MPV bevorzugen</string> + <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für die HDR Wiedergabe verwenden</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrat (1:1)</string> + <string name="image_type_primary">Primär</string> + <string name="backdrop_style_dynamic">Bild mit dynamischen Farben</string> + <string name="api_key">API Schlüssel</string> + <string name="seerr_local_user">Lokaler Benutzer</string> + <string name="search_for">Suche %s</string> + <string name="display_presets">Design-Vorlagen</string> + <string name="animate">Animieren</string> + <string name="content_scale_fill_width">Breite ausfüllen</string> + <string name="content_scale_fill_height">Höhe ausfüllen</string> + <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="volume_lowest">Niedrigste</string> + <string name="volume_low">Niedrig</string> + <string name="volume_medium">mittel</string> + <string name="volume_high">Hoch</string> + <string name="volume_full">Voll</string> + <string name="purple">Lila</string> + <string name="orange">Orange</string> + <string name="black">Schwarz</string> + <string name="backdrop_style_image">Nur Bild</string> + <string name="series_continueing">Aktuell</string> + <string name="in_app_screensaver">Eingebauten Bildschirmschoner nutzen</string> + <string name="delete_item">Bist du sicher, dass du diesen Eintrag löschen möchtest?</string> + <string name="actor">Schauspieler</string> + <string name="composer">Komponist</string> + <string name="writer">Autor</string> + <string name="guest_star">Nebenrolle</string> + <string name="producer">Produzent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Liedtext-Schreiber</string> + <string name="artist">Künstler</string> + <string name="favorite_items">%s als Favorit speichern</string> + <string name="home_rows">Startseite - Reihen</string> + <string name="load_from_server">Vom Server Benutzerprofil laden</string> + <string name="save_to_server">Im Server Benutzerprofil speichern</string> + <string name="load_from_web_client">Vom Webclient laden</string> + <string name="increase_all_cards_size">Größe für alle Karten erhöhen</string> + <string name="use_thumb_images">Vorschaubilder benutzen</string> + <string name="no_subtitles_found">Es wurden keine Untertitel gefunden</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingenieur</string> + <string name="bold_blue">Dunkelblau</string> + <string name="image_subtitle_opacity">Untertitel-Transparenz</string> + <string name="image_type_thumb">Vorschaubild</string> + <string name="use_series">Serien Vorschaubilder verwenden</string> + <string name="enter_url_api_key"><![CDATA[URL & API Key eingeben]]></string> + <string name="search_and_download_subtitles"><![CDATA[Untertitel suchen & herunterladen]]></string> + <string name="creator">Ersteller</string> + <string name="background_style_wrap">Gestreckt</string> + <string name="background_style_boxed">Zentriert</string> + <string name="mixer">Mischer</string> </resources> From 0c197087742cecb11d0fc366cefda10f327dd964 Mon Sep 17 00:00:00 2001 From: isbit <isbit@noreply.codeberg.org> Date: Thu, 5 Mar 2026 16:40:15 +0000 Subject: [PATCH 092/118] Translated using Weblate (Swedish) Currently translated at 54.5% (274 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b7d13882..cef8bb90 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -277,4 +277,49 @@ <string name="enter_pin">Skriv in PIN</string> <string name="sign_in_auto">Logga in automatiskt</string> <string name="use_server_credentials">Logga in via server</string> + <string name="enter_server_address">Ange server-adress</string> + <string name="voice_error_network">Nätverksfel</string> + <string name="voice_error_unknown">Okänt fel</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minuter</item> + </plurals> + <string name="bold_font">Fet stil</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="discard_change">Avbryt ändringar?</string> + <string name="require_pin_code">Kräv PIN för profilen</string> + <string name="resolution">Upplösning</string> + <string name="language">Språk</string> + <string name="yes">Ja</string> + <string name="no">Nej</string> + <string name="live_tv_repeat">Upprepa</string> + <string name="password">Lösenord</string> + <string name="username">Användarnamn</string> + <string name="creator">Skapare</string> + <string name="engineer">Ingenjör</string> + <string name="actor">Skådespelare</string> + <string name="composer">Kompositör</string> + <string name="writer">Författare</string> + <string name="producer">Producent</string> + <string name="backdrop_style_image">Endast bild</string> + <string name="white">Vit</string> + <string name="light_gray">Ljusgrå</string> + <string name="dark_gray">Mörkgrå</string> + <string name="yellow">Gul</string> + <string name="purple">Lila</string> + <string name="black">Svart</string> + <string name="for_all_ages">För alla åldrar</string> + <string name="up_to_age">Upp till %s år</string> + <string name="screensaver">Skärmsläckare</string> + <string name="screensaver_settings">Inställningar Skärmsläckare</string> + <string name="start_screensaver">Starta skärmsläckare</string> + <string name="photos">Bilder</string> + <string name="settings_saved">Inställningar sparade</string> + <string name="rotate_left">Rotera vänster</string> + <string name="rotate_right">Rotera höger</string> + <string name="zoom_in">Zooma in</string> + <string name="zoom_out">Zooma ut</string> + <string name="no_limit">Ingen gräns</string> + <string name="add_row">Lägg till rad</string> + <string name="press_back_to_cancel">Tryck bakåt för att avbryta</string> </resources> From b4c35ce41b4e33a2ba2f382bf1d6f3b6246e89ea Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Thu, 5 Mar 2026 18:59:20 +0000 Subject: [PATCH 093/118] Translated using Weblate (Italian) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2f22c8d4..3142001e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -565,4 +565,7 @@ <string name="mixer">Tecnico di mixaggio</string> <string name="artist">Artista</string> <string name="creator">Creatore</string> + <string name="search_for">Cerca %s</string> + <string name="delete_item">Sei sicuro di voler cancellare questo elemento?</string> + <string name="show_media_management">Mostra opzioni di gestione dei media</string> </resources> From 062a3e99a4b166d7366d8f0585aa4f1466b9dc32 Mon Sep 17 00:00:00 2001 From: EmadAljohani <emadaljohani@noreply.codeberg.org> Date: Thu, 5 Mar 2026 23:24:07 +0000 Subject: [PATCH 094/118] Translated using Weblate (Arabic) Currently translated at 23.9% (120 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ar/ --- app/src/main/res/values-ar/strings.xml | 130 ++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 55344e51..327f969b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,3 +1,131 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file + <string name="about">حول</string> + <string name="active_recordings">التسجيلات النشطة</string> + <string name="add_favorite">إضافة للمفضلة</string> + <string name="add_server">إضافة خادم</string> + <string name="add_user">إضافة مستخدم</string> + <string name="audio">الصوت</string> + <string name="birthplace">مكان الميلاد</string> + <string name="bitrate">معدل البت</string> + <string name="born">وُلد</string> + <string name="cancel_recording">إلغاء التسجيل</string> + <string name="cancel_series_recording">إلغاء تسجيل المسلسل</string> + <string name="cancel">إلغاء</string> + <string name="chapters">الفصول</string> + <string name="choose_stream">اختيار %1$s</string> + <string name="clear_image_cache">مسح ذاكرة التخزين المؤقت للصور</string> + <string name="collection">مجموعة</string> + <string name="collections">المجموعات</string> + <string name="commercial">تجاري</string> + <string name="community_rating">تقييم المجتمع</string> + <string name="compose_error_message_title">حدث خطأ! اضغط على الزر لإرسال السجلات إلى خادمك.</string> + <string name="confirm">تأكيد</string> + <string name="continue_watching">تابع المشاهدة</string> + <string name="critic_rating">تقييم النقاد</string> + <string name="decimal_seconds">%.2f ثانية</string> + <string name="default_track">الافتراضي</string> + <string name="delete">حذف</string> + <string name="died">تُوفي</string> + <string name="directed_by">إخراج %1$s</string> + <string name="director">المخرج</string> + <string name="disabled">مُعطّل</string> + <string name="discovered_servers">الخوادم المكتشفة</string> + <string name="download_and_update"><![CDATA[تنزيل وتحديث]]></string> + <string name="downloading">تنزيل…</string> + <string name="enabled">مُفعّل</string> + <string name="ends_at">ينتهي عند %1$s</string> + <string name="enter_server_url">أدخل عنوان IP للخادم أو رابط URL</string> + <string name="enter_server_address">أدخل عنوان الخادم</string> + <string name="episodes">الحلقات</string> + <string name="error_loading_collection">خطأ في تحميل المجموعة %1$s</string> + <string name="external_track">خارجي</string> + <string name="favorites">المفضلة</string> + <string name="file_size">الحجم</string> + <string name="forced_track">إجبارية</string> + <string name="genres">الأنواع</string> + <string name="go_to_series">انتقل إلى المسلسل</string> + <string name="go_to">انتقل إلى</string> + <string name="hide_controller_timeout">إخفاء عناصر التحكم في التشغيل</string> + <string name="hide_debug_info">إخفاء معلومات تصحيح الأخطاء</string> + <string name="hide">إخفاء</string> + <string name="home">الرئيسية</string> + <string name="immediate">فوري</string> + <string name="intro">المقدمة</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">المكتبة</string> + <string name="license_info">معلومات الترخيص</string> + <string name="live_tv">تلفزيون مباشر</string> + <string name="loading">تحميل…</string> + <string name="mark_entire_series_as_played">هل تريد تعيين المسلسل بأكمله كمُشاهَد؟</string> + <string name="mark_entire_series_as_unplayed">هل تريد تعيين المسلسل بأكمله كغير مشاهد؟</string> + <string name="mark_unwatched">تعيين كغير مشاهد</string> + <string name="mark_watched">تعيين كمشاهد</string> + <string name="max_bitrate">الحد الأقصى لمعدل البت</string> + <string name="more_like_this">المزيد من هذا القبيل</string> + <string name="more">المزيد</string> + <plurals name="movies"> + <item quantity="zero">الأفلام</item> + <item quantity="one"></item> + <item quantity="two"></item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="name">الاسم</string> + <string name="next_up">التالي</string> + <string name="no_data">لا توجد بيانات</string> + <string name="no_results">لا توجد نتائج</string> + <string name="no_servers_found">لم يتم العثور على خوادم</string> + <string name="no_scheduled_recordings">لا توجد تسجيلات مجدولة</string> + <string name="no_update_available">لا يوجد تحديث متاح</string> + <string name="none">لا شيء</string> + <string name="only_forced_subtitles">الترجمات الإجبارية فقط</string> + <string name="outro">خاتمة</string> + <string name="path">المسار</string> + <plurals name="people"> + <item quantity="zero">الأشخاص</item> + <item quantity="one"></item> + <item quantity="two"></item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="play_count">عدد مرات التشغيل</string> + <string name="play_from_here">تشغيل من هنا</string> + <string name="play">تشغيل</string> + <string name="playback_debug_info">إظهار معلومات التشغيل التفصيلية</string> + <string name="playback_speed">سرعة التشغيل</string> + <string name="playback">التشغيل</string> + <string name="playlist">قائمة التشغيل</string> + <string name="playlists">قوائم التشغيل</string> + <string name="preview">معاينة</string> + <string name="profile_specific_settings">إعدادات ملف تعريف المستخدم</string> + <string name="queue">قائمة الانتظار</string> + <string name="recap">ملخص ما سبق</string> + <string name="recently_added_in">أُضيف مؤخرًا في %1$s</string> + <string name="recently_added">أُضيف مؤخرًا</string> + <string name="recently_recorded">التسجيلات الأخيرة</string> + <string name="recently_released">أحدث الإصدارات</string> + <string name="recommended">مقترح</string> + <string name="record_program">تسجيل البرنامج</string> + <string name="record_series">تسجيل المسلسل</string> + <string name="remove_favorite">إزالة من المفضلة</string> + <string name="restart">إعادة تشغيل</string> + <string name="resume">استئناف</string> + <string name="save">حفظ</string> + <string name="search_and_download"><![CDATA[بحث وتنزيل]]></string> + <string name="search">بحث</string> + <string name="searching">جارٍ البحث…</string> + <string name="voice_search">البحث الصوتي</string> + <string name="voice_starting">جارٍ البدء</string> + <string name="voice_search_prompt">تحدث للبحث</string> + <string name="processing">جارٍ المعالجة</string> + <string name="press_back_to_cancel">اضغط رجوع للإلغاء</string> + <string name="voice_error_audio">خطأ في تسجيل الصوت</string> + <string name="voice_error_client">خطأ في التعرف على الصوت</string> + <string name="voice_error_permissions">مطلوب إذن الوصول للميكروفون</string> + <string name="voice_error_network">خطأ في الشبكة</string> + <string name="voice_error_network_timeout">انتهت مهلة الشبكة</string> + <string name="voice_error_no_match">لم يتم التعرف على الكلام</string> +</resources> From d0f79086482c8e4ecd4d0e677c768539039497c2 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Fri, 6 Mar 2026 13:35:18 +0000 Subject: [PATCH 095/118] Translated using Weblate (Slovak) Currently translated at 44.6% (224 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 78 +++++++++++++------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 195565df..dfb498ba 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -146,9 +146,9 @@ <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailery</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="tv_dvr_schedule">DVR rozvrh</string> <string name="tv_guide">Sprievodca</string> @@ -156,9 +156,9 @@ <string name="tv_seasons">Série</string> <plurals name="tv_shows"> <item quantity="one">Seriály</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="ui_interface">Rozhranie</string> <string name="unknown">Neznámy</string> @@ -186,69 +186,69 @@ <string name="extras">Bonusový materiál</string> <plurals name="other_extras"> <item quantity="one">Ostatné</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Zo zákulisia</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="theme_songs"> <item quantity="one">Tématické piesne</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="theme_videos"> <item quantity="one">Tématické videá</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="clips"> <item quantity="one">Klipy</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Vymazané scény</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="interviews"> <item quantity="one">Rozhovory</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Scény</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="samples"> <item quantity="one">Vzorky</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="featurettes"> <item quantity="one">Krátkometrážne filmy</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="shorts"> <item quantity="one">Krátke videá</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="favorite_items">Obľúbené %s</string> <plurals name="downloads"> From 95e0725d8d3a7341a911d9ba01a3553b5a2dd4be Mon Sep 17 00:00:00 2001 From: RafaelSoaresP <rafaelsoaresp@noreply.codeberg.org> Date: Fri, 6 Mar 2026 13:35:17 +0000 Subject: [PATCH 096/118] Translated using Weblate (Portuguese (Brazil)) Currently translated at 76.8% (386 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 56 +++++++++++----------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 76bf308f..ed794d85 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -65,8 +65,8 @@ <string name="more">Mais</string> <plurals name="movies"> <item quantity="one">Filmes</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="name">Nome</string> <string name="next_up">A Seguir</string> @@ -81,8 +81,8 @@ <string name="path">Caminho</string> <plurals name="people"> <item quantity="one">Pessoas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="play_count">Contagem de Reproduções</string> <string name="play_from_here">Reproduzir a partir daqui</string> @@ -152,8 +152,8 @@ <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailers</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="tv_dvr_schedule">Agenda do DVR</string> <string name="tv_guide">Guia</string> @@ -161,8 +161,8 @@ <string name="tv_seasons">Temporadas</string> <plurals name="tv_shows"> <item quantity="one">Programas de TV</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="ui_interface">Interface</string> <string name="unknown">Desconhecido</string> @@ -190,53 +190,53 @@ <string name="extras">Extras</string> <plurals name="other_extras"> <item quantity="one">Outro</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Por Trás das Cenas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="theme_songs"> <item quantity="one">Músicas Tema</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="theme_videos"> <item quantity="one">Vídeos Tema</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="clips"> <item quantity="one">Clipes</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Cenas Deletadas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="interviews"> <item quantity="one">Entrevistas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Cenas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="samples"> <item quantity="one">Amostras</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="shorts"> <item quantity="one">Curtas</item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="favorite_items">Favoritar %s</string> <plurals name="downloads"> From dd0f253524d1e1a37a2002267603d15196cef3f3 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Fri, 6 Mar 2026 15:18:56 +0000 Subject: [PATCH 097/118] Translated using Weblate (German) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 88899ee7..2a8884ba 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -227,7 +227,7 @@ <item quantity="other">Kurzvideos</item> </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> - <string name="direct_play_ass">Direktwiedergabe ASS Untertitel</string> + <string name="direct_play_ass">Libass für ASS Untertitel verwenden</string> <string name="direct_play_pgs">Direktwiedergabe PGS Untertitel</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> <string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string> From b3be20f6dad2760d5363c78a1771673ca140a7d5 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 6 Mar 2026 15:30:30 +0000 Subject: [PATCH 098/118] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 47821481..3134076d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -208,7 +208,7 @@ <string name="check_for_updates">检查更新</string> <string name="combine_continue_next_summary">仅适用于电视剧</string> <string name="combine_continue_next"><![CDATA[合并继续观看与即将播放]]></string> - <string name="direct_play_ass">直接播放 ASS 字幕</string> + <string name="direct_play_ass">使用 libass 处理 ASS 字幕</string> <string name="direct_play_pgs">直接播放 PGS 字幕</string> <string name="downmix_stereo">始终降混为立体声</string> <string name="ffmpeg_extension_pref">使用 FFmpeg 解码模块</string> From 4cfb471e5da6f904c89df6638fdae51c36b0fb7f Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Fri, 6 Mar 2026 15:16:02 +0000 Subject: [PATCH 099/118] Translated using Weblate (Turkish) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a91c431b..213ef136 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -256,7 +256,7 @@ <string name="check_for_updates">Güncellemeleri kontrol et</string> <string name="combine_continue_next_summary">Yalnızca diziler için geçerlidir</string> <string name="combine_continue_next"><![CDATA[İzlemeye Devam Et ve Sıradaki Bölümleri Birleştir]]></string> - <string name="direct_play_ass">ASS altyazıları doğrudan oynat</string> + <string name="direct_play_ass">ASS altyazıları için libass kullan</string> <string name="direct_play_pgs">PGS altyazıları doğrudan oynat</string> <string name="downmix_stereo">Her zaman Stereo\'ya dönüştür</string> <string name="ffmpeg_extension_pref">FFmpeg kod çözücü kullan</string> From 73cf54b844e06491036ed327c1118092e29a0d89 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 6 Mar 2026 10:49:06 -0500 Subject: [PATCH 100/118] Fixes to strings --- app/src/main/res/values-fr/strings.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 04a595ed..74e702c8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -221,8 +221,8 @@ <string name="font_opacity">Opacité de la police</string> <string name="edge_style">Style des contours</string> <string name="edge_color">Couleur des contours</string> - <string name="background_opacity">Opacité de l’arrière-plan</string> - <string name="background_style">Style de l’arrière-plan</string> + <string name="background_opacity">Opacité de l\'arrière-plan</string> + <string name="background_style">Style de l\'arrière-plan</string> <string name="subtitle_style">Style des sous-titres</string> <string name="background_color">Couleur de fond</string> <string name="reset">Réinitialiser</string> @@ -234,10 +234,10 @@ <string name="exoplayer_options">Options ExoPlayer</string> <string name="skip_segment_unknown">Passer le segment</string> <string name="skip_segment_commercial">Passer les publicités</string> - <string name="skip_segment_preview">Passer l’aperçu</string> + <string name="skip_segment_preview">Passer l\'aperçu</string> <string name="skip_segment_recap">Passer le récapitulatif</string> <string name="skip_segment_outro">Passer le générique de fin</string> - <string name="skip_segment_intro">Passer l’intro</string> + <string name="skip_segment_intro">Passer l\'intro</string> <string name="played">Lu</string> <string name="filter">Filtre</string> <string name="year">Année</string> @@ -275,7 +275,7 @@ <string name="play_theme_music">Lire la musique de thème</string> <string name="skip_back_on_resume_preference">Revenir en arrière à la reprise de la lecture</string> <string name="skip_commercials_behavior">Comportement de saut des publicités</string> - <string name="skip_intro_behavior">Comportement de saut de l’intro</string> + <string name="skip_intro_behavior">Comportement de saut de l\'intro</string> <string name="skip_outro_behavior">Comportement de saut du générique de fin</string> <string name="skip_previews_behavior">Comportement de saut des aperçus</string> <string name="skip_recap_behavior">Comportement de saut du récapitulatif</string> @@ -289,7 +289,7 @@ <item quantity="many"/> <item quantity="other"/> </plurals> - <string name="combine_continue_next"><![CDATA[Combiner "Continuer à regarder" & "À suivre"]]></string> + <string name="combine_continue_next"><![CDATA[Combiner \"Continuer à regarder\" & \"À suivre\"]]></string> <string name="download_and_update"><![CDATA[Télécharger & Mettre à jour]]></string> <string name="search_and_download"><![CDATA[Rechercher & Télécharger]]></string> <string name="tv_dvr_schedule">Calendrier du DVR</string> @@ -544,12 +544,12 @@ <string name="image_type_thumb">Miniature</string> <string name="backdrop_style_dynamic">Image avec couleur dynamique</string> <string name="backdrop_style_image">Image uniquement</string> - <string name="enter_url_api_key">< ![CDATA[Entrez l\'URL et la clé API]]></string> + <string name="enter_url_api_key">Entrez l\'URL et la clé API</string> <string name="api_key">Clé API</string> <string name="seerr_login">Se connecter au serveur Seerr</string> <string name="seerr_jellyfin_user">Utilisateur Jellyfin</string> <string name="seerr_local_user">Utilisateur local</string> - <string name="search_and_download_subtitles">< ![CDATA[Rechercher et télécharger des sous-titres]]></string> + <string name="search_and_download_subtitles">Rechercher et télécharger des sous-titres</string> <string name="no_subtitles_found">Aucun sous-titre distant n\'a été trouvé</string> <string name="series_continueing">Présent</string> <string name="in_app_screensaver">Utiliser l\'économiseur d\'écran intégré</string> From 8a4be3dd32040f577c9486ca339c1c57efcfa744 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 6 Mar 2026 16:37:06 -0500 Subject: [PATCH 101/118] Release v0.5.2 From 256bb3f9884016fd18510fa64b34f1160e97bff0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:16:35 -0500 Subject: [PATCH 102/118] Fix crash after last screensaver image in list (#1055) ## Description Fix a screensaver crash after the last item Also adds some extra error checking ### Related issues Fixes #1053 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/AppUpgradeHandler.kt | 2 +- .../wholphin/services/ScreensaverService.kt | 58 ++++++++++--------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 23d6cef6..0331f04b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -250,7 +250,7 @@ suspend fun upgradeApp( } } - if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) { + if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { appPreferences.updateData { it.updateScreensaverPreferences { startDelay = ScreensaverPreference.DEFAULT_START_DELAY diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index 5d73ec21..c81893b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -161,27 +161,32 @@ class ScreensaverService if (pager.isEmpty()) { emit(null) } else { + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds while (true) { - val item = pager.getBlocking(index) - Timber.v("Next index=%s, item=%s", index, item?.id) - if (item != null) { - val backdropUrl = - if (item.type == BaseItemKind.PHOTO) { - api.libraryApi.getDownloadUrl(item.id) - } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } - val title = - if (item.type == BaseItemKind.PHOTO) { - item.data.premiereDate?.let { - formatDate(it.toLocalDate()) + try { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } - } else { - item.title - } - val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) - if (backdropUrl != null) { - try { + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { context.imageLoader .enqueue( ImageRequest @@ -191,18 +196,17 @@ class ScreensaverService ).job .await() emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) - } catch (_: CancellationException) { - break + delay(duration) } - val duration = - userPreferencesService - .getCurrent() - .appPreferences - .interfacePreferences.screensaverPreference.duration.milliseconds - delay(duration) } + } catch (_: CancellationException) { + break + } catch (ex: Exception) { + Timber.e(ex, "Error fetching next item") + delay(duration) } index++ + if (index > pager.lastIndex) index = 0 } } }.flowOn(Dispatchers.Default).cancellable() From 49a6cd8d2f3fe84b79b0d02358af50e0a922ec1e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 6 Mar 2026 18:16:48 -0500 Subject: [PATCH 103/118] Release v0.5.3 From afc47f254b8a91bf1d9f8a7c787971b56bc6d7ac Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:54:00 -0400 Subject: [PATCH 104/118] Request individual seasons from Jellyseerr (#1060) ## Description Adds a dialog to select individual seasons for a TV show when requesting it via Discover/Jellyseerr If a request is already pending, it can be updated. If there's more than 7 seasons, a second submit button is added at the bottom of the dialog for easier access. Also, adds a button from Jellyfin series details to its Discover equivalent and added a request button for partially available series to request more seasons. ### Related issues Closes #961 ### Testing Emulator & Jellyseerr 2.7.3 ## Screenshots ![discover_seasons Large](https://github.com/user-attachments/assets/c7781bf4-7c1b-4e4e-a0b8-a572111a8497) ## AI or LLM usage None --- .../wholphin/data/model/DiscoverItem.kt | 2 + .../services/SeerrServerRepository.kt | 1 + .../wholphin/services/SeerrService.kt | 13 + .../detail/discover/DiscoverSeriesDetails.kt | 38 +-- .../discover/DiscoverSeriesViewModel.kt | 89 ++++- .../discover/ExpandableDiscoverButtons.kt | 18 +- .../ui/detail/discover/RequestSeasons.kt | 323 ++++++++++++++++++ .../ui/detail/series/SeriesDetails.kt | 31 ++ .../ui/detail/series/SeriesViewModel.kt | 18 + .../ui/setup/seerr/SwitchSeerrViewModel.kt | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/seerr/seerr-api.yml | 23 +- 12 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index a274caa1..3144b014 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.runtime.Stable import com.github.damontecres.wholphin.api.seerr.model.CreditCast import com.github.damontecres.wholphin.api.seerr.model.CreditCrew import com.github.damontecres.wholphin.api.seerr.model.MovieDetails @@ -74,6 +75,7 @@ enum class SeerrAvailability( /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. */ +@Stable @Serializable data class DiscoverItem( val id: Int, 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 970e80c3..6db60574 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 @@ -57,6 +57,7 @@ class SeerrServerRepository connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow<SeerrUser?> = connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + val currentUserId: Flow<Int?> = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index e38ba41b..14aa0b9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import kotlinx.coroutines.flow.first @@ -125,4 +126,16 @@ class SeerrService } else { null } + + suspend fun getTvSeries(item: BaseItem): TvDetails? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { tvId -> + api.tvApi.tvTvIdGet(tvId = tvId) + } + } else { + null + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 3c036534..12acfd37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem @@ -99,6 +98,7 @@ fun DiscoverSeriesDetails( var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var showRequestSeasonDialog by remember { mutableStateOf(false) } val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) @@ -159,26 +159,7 @@ fun DiscoverSeriesDetails( trailers = trailers, requestOnClick = { item.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = item.name ?: "", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } + showRequestSeasonDialog = true } }, cancelOnClick = { @@ -218,6 +199,18 @@ fun DiscoverSeriesDetails( waitToLoad = params.fromLongClick, ) } + if (showRequestSeasonDialog) { + RequestSeasonsDialog( + title = item?.name ?: "", + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = { seasons, is4k -> + item?.id?.let { viewModel.request(it, seasons, is4k) } + showRequestSeasonDialog = false + }, + onDismissRequest = { showRequestSeasonDialog = false }, + ) + } } private const val HEADER_ROW = 0 @@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent( series: TvDetails, rating: DiscoverRating?, canCancel: Boolean, - seasons: List<Season>, + seasons: List<RequestSeason>, similar: List<DiscoverItem>, recommended: List<DiscoverItem>, trailers: List<Trailer>, @@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent( SeerrAvailability.from(series.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, requestOnClick = requestOnClick, + pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, moreOnClick = moreOnClick, goToOnClick = goToOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index f7b11137..da6550ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest -import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -63,7 +65,7 @@ class DiscoverSeriesViewModel val tvSeries = MutableLiveData<TvDetails?>(null) val rating = MutableLiveData<DiscoverRating?>(null) - val seasons = MutableLiveData<List<Season>>(listOf()) + val seasons = MutableLiveData<List<RequestSeason>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>() @@ -103,6 +105,7 @@ class DiscoverSeriesViewModel val discoveredItem = DiscoverItem(tv) backdropService.submit(discoveredItem) + updateSeasonStatus() updateCanCancel() withContext(Dispatchers.Main) { @@ -157,6 +160,43 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } + private suspend fun updateSeasonStatus() { + tvSeries.value?.let { tv -> + val seasonStatus = mutableMapOf<Int, SeerrAvailability>() + tv.seasons?.forEach { + it.seasonNumber?.let { + seasonStatus[it] = SeerrAvailability.UNKNOWN + } + } + val tvStatus = + SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo + ?.requests + ?.forEach { + it.seasons?.mapNotNull { season -> + season.seasonNumber?.let { + val current = seasonStatus[season.seasonNumber] + val new = + SeerrAvailability + .from(season.status) + ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + if (current == null || new.status > current.status) { + seasonStatus[season.seasonNumber] = new + } + } + } + } + Timber.v("seasonStatus=%s", seasonStatus) + val requestSeasons = + seasonStatus.mapNotNull { (seasonNumber, availability) -> + tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { + RequestSeason(it, availability) + } + } + seasons.setValueOnMain(requestSeasons) + } + } + private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) @@ -165,20 +205,43 @@ class DiscoverSeriesViewModel fun request( id: Int, + seasons: Set<Int>, is4k: Boolean, ) { viewModelScope.launchIO { - val request = - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons - ), - ) - fetchAndSetItem().await() - updateCanCancel() + tvSeries.value?.let { tv -> + val currentRequest = + tv.mediaInfo?.requests?.firstOrNull { + it.requestedBy?.id == + seerrServerRepository.currentUserId.first() + } + if (currentRequest != null) { + Timber.v("User has pending request, will update") + seerrService.api.requestApi.requestRequestIdPut( + requestId = currentRequest.id.toString(), + requestRequestIdPutRequest = + RequestRequestIdPutRequest( + is4k = is4k, + mediaType = RequestRequestIdPutRequest.MediaType.TV, + seasons = seasons.toList(), + ), + ) + } else { + Timber.v("New request for %s seasons", seasons.size) + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = is4k, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = seasons.toList(), + ), + ) + } + + fetchAndSetItem().await() + updateSeasonStatus() + updateCanCancel() + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt index 4b73a0b2..2c3fc036 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons( trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + pendingOnClick: () -> Unit = {}, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons( SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, -> { - // TODO? + pendingOnClick.invoke() } SeerrAvailability.PARTIALLY_AVAILABLE, @@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons( .onFocusChanged(buttonOnFocusChanged), ) } + if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) { + item("request_partial") { + ExpandableFaButton( + title = R.string.request, + iconStringRes = R.string.fa_download, + onClick = { + requestOnClick.invoke() + }, + enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } if (canCancel) { item("cancel") { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt new file mode 100644 index 00000000..4a56cb88 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -0,0 +1,323 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.ui.cards.AvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PendingIndicator +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class RequestSeason( + val season: Season, + val availability: SeerrAvailability, +) + +@Composable +fun RequestSeasons( + title: String, + seasons: List<RequestSeason>, + onSubmit: (Set<Int>, Boolean) -> Unit, + request4kEnabled: Boolean, + modifier: Modifier, +) { + val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } + val selected = + remember { + mutableStateSetOf<Int>( + *seasons + .mapNotNull { + if (it.availability > SeerrAvailability.UNKNOWN) { + it.season.seasonNumber + } else { + null + } + }.toTypedArray(), + ) + } + var is4k by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn { + item { + val isSelected = selected.containsAll(allSeasonNumbers) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selected.removeAll(allSeasonNumbers) + } else { + selected.addAll(allSeasonNumbers) + } + }, + ) + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + if (request4kEnabled) { + item { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + } + itemsIndexed(seasons) { index, season -> + val seasonNumber = season.season.seasonNumber + val isSelected = seasonNumber in selected + SeasonListItem( + season = season, + selected = isSelected, + onClick = { + if (isSelected) { + selected.remove(seasonNumber) + } else { + seasonNumber?.let { selected.add(it) } + } + }, + modifier = Modifier, + ) + } + if (seasons.size > 7) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + } + } + } +} + +@Composable +fun SeasonListItem( + season: RequestSeason, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + headlineContent = { + Text( + text = + season.season.name + ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ) + }, + supportingContent = { + season.season.episodeCount?.let { + Text( + // TODO should use plurals string + text = "${season.season.episodeCount} " + stringResource(R.string.episodes), + ) + } + }, + leadingContent = { + when (season.availability) { + SeerrAvailability.UNKNOWN -> {} + + SeerrAvailability.DELETED -> {} + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator() + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator() + } + + SeerrAvailability.AVAILABLE -> { + AvailableIndicator() + } + } + }, + trailingContent = { + Row { + Switch( + checked = selected, + onCheckedChange = { + onClick.invoke() + }, + ) + } + }, + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun ClickSurface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + pressedContainerColor = MaterialTheme.colorScheme.inverseSurface, + pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + ), + onClick = onClick, + content = content, + modifier = modifier, + ) +} + +@Composable +private fun ClickSwitch( + label: String, + checked: Boolean, + onClick: () -> Unit, +) { + ClickSurface( + onClick = onClick, + modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 8.dp) + .height(54.dp), + ) { + Switch( + checked = checked, + onCheckedChange = {}, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = label, + ) + } + } +} + +@Composable +fun RequestSeasonsDialog( + title: String, + seasons: List<RequestSeason>, + request4kEnabled: Boolean, + onSubmit: (Set<Int>, Boolean) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestSeasons( + title = title, + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview( + device = "spec:parent=tv_1080p", + backgroundColor = 0xFF383535, + uiMode = UI_MODE_TYPE_TELEVISION, + heightDp = 800, +) +@Composable +fun RequestSeasonsPreview() { + val seasons = + List(10) { + RequestSeason( + season = + Season( + seasonNumber = it + 1, + episodeCount = 10 + it, + ), + availability = + if (it < 3) { + SeerrAvailability.AVAILABLE + } else { + SeerrAvailability.UNKNOWN + }, + ) + } + + WholphinTheme { + RequestSeasons( + title = "Series title", + seasons = seasons, + request4kEnabled = true, + onSubmit = { _, _ -> }, + modifier = Modifier.width(400.dp), + ) + } +} 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 81beb82f..4f8e4366 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 @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -120,6 +123,7 @@ fun SeriesDetails( val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val discoverSeries by viewModel.discoverSeries.collectAsState() val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } @@ -230,6 +234,12 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discoverSeries = discoverSeries, + onClickDiscoverSeries = { + discoverSeries?.let { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + } + }, discovered = discovered, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) @@ -350,6 +360,8 @@ fun SeriesDetailsContent( onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, onClickDiscover: (Int, DiscoverItem) -> Unit, + discoverSeries: DiscoverItem?, + onClickDiscoverSeries: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -487,6 +499,25 @@ fun SeriesDetailsContent( }, ) } + AnimatedVisibility( + visible = discoverSeries != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExpandableFaButton( + title = R.string.discover, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = onClickDiscoverSeries, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { 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 53924431..ee07a170 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 @@ -58,6 +58,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -123,6 +124,7 @@ class SeriesViewModel val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + val discoverSeries = MutableStateFlow<DiscoverItem?>(null) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -232,6 +234,22 @@ class SeriesViewModel val results = seerrService.similar(item).orEmpty() discovered.update { results } } + viewModelScope.launchIO { + seerrService.active.collectLatest { active -> + val tv = + if (active) { + try { + seerrService.getTvSeries(item)?.let { DiscoverItem(it) } + } catch (ex: Exception) { + Timber.e(ex) + null + } + } else { + null + } + discoverSeries.update { tv } + } + } } mediaManagementService.deletedItemFlow .onEach { deletedItem -> 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 0f62f12f..02e97ff5 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 @@ -98,6 +98,7 @@ class SwitchSeerrViewModel ) } } + serverConnectionStatus.update { LoadingState.Success } } else { val message = results diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a2fb92..66276854 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -719,5 +719,6 @@ <string name="creator">Creator</string> <string name="artist">Artist</string> <string name="search_for">Search %s</string> + <string name="select_all">Select all</string> </resources> diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d619d042..d6b719ec 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1086,6 +1086,11 @@ components: type: array items: $ref: '#/components/schemas/Episode' + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true TvDetails: type: object properties: @@ -1268,6 +1273,10 @@ components: type: string type: type: string + seasons: + type: array + items: + $ref: '#/components/schemas/Season' required: - id - status @@ -6135,16 +6144,10 @@ paths: type: integer example: 123 seasons: - # oneOf: - # - type: array - # items: - # type: integer - # minimum: 0 - # - type: string - # enum: [all] - # TODO - type: string - enum: [all] + type: array + items: + type: integer + minimum: 0 nullable: true is4k: type: boolean From 7bb47bf329f58c91072184f762a1d6b8da059e5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:16:19 -0400 Subject: [PATCH 105/118] Photo/slideshow fixes & Seerr server removal fix (#1076) ## Description A few fixes - Better crossfade for photo slideshow - Fix "Go to" in context menu for photos - Fix certain home video/photo libraries not working for photos - Fix removing seerr server in some cases ### Related issues Closes #835 Fixes #1069 Addresses https://github.com/damontecres/Wholphin/issues/634#issuecomment-4018580518 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../services/SeerrServerRepository.kt | 21 +- .../ui/components/CollectionFolderGrid.kt | 3 + .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- .../wholphin/ui/detail/DetailUtils.kt | 3 +- .../wholphin/ui/nav/DestinationContent.kt | 13 +- .../ui/preferences/PreferencesContent.kt | 2 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 333 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 5 +- 8 files changed, 198 insertions(+), 184 deletions(-) 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 6db60574..80eafb7a 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 @@ -25,7 +25,7 @@ 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.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.supervisorScope @@ -71,10 +71,11 @@ class SeerrServerRepository } fun error( - serverUrl: String, + server: SeerrServer, + user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } + _connection.update { SeerrConnectionStatus.Error(server, user, exception) } seerrApi.update("", null) } @@ -175,8 +176,13 @@ class SeerrServerRepository } suspend fun removeServerForCurrentUser(): Boolean { - val current = current.firstOrNull() ?: return false - val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + val user = + when (val conn = connection.first()) { + SeerrConnectionStatus.NotConfigured -> return false + is SeerrConnectionStatus.Error -> conn.user + is SeerrConnectionStatus.Success -> conn.current.user + } + val rows = seerrServerDao.deleteUser(user) clear() return rows > 0 } @@ -191,7 +197,8 @@ sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus data class Error( - val serverUrl: String, + val server: SeerrServer, + val user: SeerrUser, val ex: Exception, ) : SeerrConnectionStatus @@ -317,7 +324,7 @@ class UserSwitchListener "Error logging into %s", server.url, ) - seerrServerRepository.error(server.url, ex) + seerrServerRepository.error(server, seerrUser, ex) } } } 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 8415c7a6..a4e4ef22 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 @@ -771,6 +771,9 @@ fun CollectionFolderGrid( onClickDelete = { showDeleteDialog = PositionItem(position, item) }, + onClickGoTo = { + onClickItem.invoke(position, it) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 4ddc41c1..562af214 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -59,7 +59,7 @@ fun CollectionFolderPhotoAlbum( index = index, filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, - recursive = true, + recursive = recursive, startSlideshow = false, ) } else { 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 f34c0228..5fe13871 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 @@ -30,6 +30,7 @@ data class MoreDialogActions( val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, val onClickDelete: (BaseItem) -> Unit, + val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, ) enum class ClearChosenStreams { @@ -246,7 +247,7 @@ fun buildMoreDialogItemsForHome( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(item.destination()) + actions.onClickGoTo(item) }, ) if (item.type in supportedPlayableTypes) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 0888e721..c0e4ce26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -209,7 +209,7 @@ fun DestinationContent( CollectionFolderPhotoAlbum( preferences = preferences, itemId = destination.itemId, - recursive = true, + recursive = false, modifier = modifier, ) } @@ -387,10 +387,19 @@ fun CollectionFolder( } CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, + -> { + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = recursiveOverride ?: false, + modifier = modifier, + ) + } + CollectionType.MUSICVIDEOS, CollectionType.MUSIC, CollectionType.BOOKS, - CollectionType.PHOTOS, -> { CollectionFolderGeneric( preferences, 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 2dc4f3cf..c214d3cb 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 @@ -400,7 +400,7 @@ fun PreferencesContent( when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.serverUrl, + conn.server.url, conn.ex, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index f35bc102..edb1e859 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -53,13 +52,16 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi import coil3.compose.SubcomposeAsyncImage +import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest -import coil3.request.crossfade +import coil3.request.transitionFactory import coil3.size.Size import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination @@ -70,12 +72,14 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "ImagePage" private const val DEBUG = false @SuppressLint("ConfigurationScreenWidthHeight") @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalCoilApi::class) @Composable fun SlideshowPage( slideshow: Destination.Slideshow, @@ -93,7 +97,7 @@ fun SlideshowPage( val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val position by viewModel.position.observeAsState(0) val pager by viewModel.pager.observeAsState() - val imageState by viewModel.image.observeAsState() +// val imageState by viewModel.image.observeAsState() var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } val isZoomed = zoomFactor * 100 > 102 @@ -197,9 +201,6 @@ fun SlideshowPage( } } - LaunchedEffect(imageState) { - reset(true) - } val player = viewModel.player val presentationState = rememberPresentationState(player) LaunchedEffect(slideshowActive) { @@ -209,16 +210,6 @@ fun SlideshowPage( var longPressing by remember { mutableStateOf(false) } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, - ) - Box( modifier = modifier @@ -303,149 +294,144 @@ fun SlideshowPage( result }, ) { - when (loadingState) { + when (val st = loadingState) { ImageLoadingState.Error -> { ErrorMessage("Error loading image", null, modifier) } ImageLoadingState.Loading -> { - LoadingPage(modifier) + LoadingPage(modifier, false) } is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) + } + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), + } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), ) } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) } } } @@ -455,30 +441,37 @@ fun SlideshowPage( exit = slideOutVertically { it }, modifier = Modifier.align(Alignment.BottomStart), ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) + when (val st = loadingState) { + ImageLoadingState.Error -> {} + + ImageLoadingState.Loading -> {} + + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( + modifier = + Modifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } } } AnimatedVisibility(showFilterDialog) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 992659ca..3a4f3bce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -141,7 +141,7 @@ class SlideshowViewModel parentId = slideshowSettings.parentId, includeItemTypes = includeItemTypes, fields = PhotoItemFields, - recursive = true, + recursive = slideshowSettings.recursive, sortBy = listOf(slideshowSettings.sortAndDirection.sort), sortOrder = listOf(slideshowSettings.sortAndDirection.direction), ), @@ -193,7 +193,8 @@ class SlideshowViewModel _pager.value?.let { pager -> viewModelScope.launchIO { try { - val image = pager.getBlocking(position) + val image = + if (position in pager.indices) pager.getBlocking(position) else null Timber.v("Got image for $position: ${image != null}") if (image != null) { this@SlideshowViewModel.position.setValueOnMain(position) From 0dca02b9197f677ea1295ef6c654fe1886f3fa0a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:10:14 -0400 Subject: [PATCH 106/118] Fix race condition when updating preferences (#1078) ## Description Fixes a race condition saving preferences. It would be triggered if you have "Remember selected tabs" enabled and either: 1) changed settings while on a library grid page or 2) changed settings on a page navigated _from_ a library grid page and then returned to the grid ### Related issues I think this will fix #1075 ### Testing Emulator following the steps above ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/services/hilt/AppModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 991a8e98..a44ab65b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -171,7 +171,7 @@ object AppModule { if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { scope.launch(ExceptionHandler()) { appPreference.updateData { - preferences.appPreferences.updateInterfacePreferences { + it.updateInterfacePreferences { putRememberedTabs(key(itemId), tabIndex) } } From cca818fabbcec2cc49ef65ba834c0e50d8fc2640 Mon Sep 17 00:00:00 2001 From: Jannik Mahr <156718546+Jannik-M91@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:44:44 +0100 Subject: [PATCH 107/118] Improve display of "DTS:HD" and "DTS:HD MA" audio tracks (#1064) <!-- By submitting this pull request, you acknowledge that you have read the [contributing guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md, including the AI/LLM policy, and [developer's guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md) --> ## Description The formatter for audio codecs did only match the profile of high definition DTS streams against "DTS:HD" or "DTS:X". At least for DTS:HD there are also cases where the profile contains "DTS-HD" with "-" instead of ":". Made the matcher more generic by allowing both "-"" or ":"" and also checking if the profile contains "MA" for Master Audio tracks. This will now correctly display the following high definition DTS types in the UI: - DTS:HD - DTS:HD MA - DTS:X **Note:** This is my first public pull request ever, let me know if I did it wrong :) ### Related issues Relates to (but does not fix): https://github.com/damontecres/Wholphin/issues/1057 ### Testing Tested on a Fire TV Cube Gen 3 with multiple files containing DTS HD audio tracks. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../wholphin/ui/util/StreamFormatting.kt | 13 +++++++------ app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 16 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index ca6e4027..15d35b3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -92,12 +92,13 @@ object StreamFormatting { context.getString(R.string.dolby_atmos) } - profile?.contains("DTS:X", true) == true -> { - context.getString(R.string.dts_x) - } - - profile?.contains("DTS:HD", true) == true -> { - context.getString(R.string.dts_hd) + profile?.contains("DTS", true) == true -> { + when { + profile.contains("X", true) -> context.getString(R.string.dts_x) + profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) + profile.contains("HD", true) -> context.getString(R.string.dts_hd) + else -> context.getString(R.string.dts) + } } else -> { diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8f32bc3a..e41bd2b5 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -401,6 +401,7 @@ <string name="clear_track_choices">Vyčistit výběr skladeb</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">True HD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 229bc4c8..7fdb5d82 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -397,6 +397,7 @@ <string name="clear_track_choices">Ryd valg af spor</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a8884ba..628c465a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -365,6 +365,7 @@ <string name="request_4k">In 4K anfragen</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 93bf5c7c..e228ac4d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,6 +401,7 @@ <string name="clear_track_choices">Opciones de pista claras</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 10b611eb..85a803a8 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -367,6 +367,7 @@ <string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 74e702c8..312caa8e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -382,6 +382,7 @@ <string name="no_trailers">Pas de bandes-annonces</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 23e81de6..dc94ff96 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -369,6 +369,7 @@ <string name="clear_track_choices">Hapus pilihan trek</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3142001e..596d898f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -383,6 +383,7 @@ <string name="only_forced_subtitles">Solo sottotitoli forzati</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ed794d85..94da1f4d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -405,6 +405,7 @@ <string name="clear_track_choices">Limpar escolhas de faixas</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6d8ac4e0..b4f44186 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -382,6 +382,7 @@ <string name="clear_track_choices">Apagar seleções de faixas</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 213ef136..4e4c1838 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -394,6 +394,7 @@ <string name="clear_track_choices">Parça seçimlerini temizle</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1a31690d..f7835121 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -432,6 +432,7 @@ <string name="clear_track_choices">Чіткий вибір доріжки</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3134076d..b78e3322 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -351,6 +351,7 @@ <string name="only_forced_subtitles">仅强制字幕</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5bf09d0f..c96c987f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -349,6 +349,7 @@ <string name="no_trailers">無預告片</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66276854..a28fbaae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -451,6 +451,7 @@ <string name="clear_track_choices">Clear track choices</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> From 62965c1b22da563ceef2f7d1e5e200ede0947afb Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:39:26 -0400 Subject: [PATCH 108/118] Fix slideshow images not resizing properly (#1089) ## Description During a slideshow of images, the viewpoint would resize to largest image resolution and images with smaller dimensions would be scaled down and centered with black bars. This might be a Coil bug, but I'm not 100% sure yet. This PR works around the issue by scaling all images to the display resolution instead of using `Size.ORIGINAL`. However, we want `Size.ORIGINAL` while zooming so it doesn't pixelate if the original image's resolution is larger than the display's. ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/835#issuecomment-4035005433 ### Testing Emulator with 1920x1080 & 800x1600 images ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/slideshow/SlideshowPage.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index edb1e859..2b3ef667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -395,8 +395,9 @@ fun SlideshowPage( ImageRequest .Builder(LocalContext.current) .data(imageState.url) - .size(Size.ORIGINAL) - .transitionFactory(CrossFadeFactory(750.milliseconds)) + .apply { + if (isZoomed) size(Size.ORIGINAL) + }.transitionFactory(CrossFadeFactory(750.milliseconds)) .useExistingImageAsPlaceholder(true) .build(), contentDescription = null, From 8bc3384094f8ef5dc3dbc42caabdbed52965e023 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:16:56 -0400 Subject: [PATCH 109/118] Simplify series overview page refresh (#1092) ## Description This PR simplifies refreshing the episode data when returning to the series overview page and reduces the number of queries. Also fixes a race condition when refreshing a single item in the an `ApiRequestPager`. ### Related issues Related to #767 ### Testing Emulator, but will need more testing during actual usage ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/series/SeriesOverview.kt | 14 ------------- .../ui/detail/series/SeriesViewModel.kt | 6 ------ .../wholphin/util/ApiRequestPager.kt | 20 +++++++++---------- 3 files changed, 10 insertions(+), 30 deletions(-) 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 3ed615e9..68cfa308 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 @@ -125,20 +125,6 @@ fun SeriesOverview( var rowFocused by rememberInt() var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } - LaunchedEffect(episodes) { - episodes?.let { episodes -> - if (episodes is EpisodeList.Success) { - if (episodes.episodes.isNotEmpty()) { - // TODO focus on first episode when changing seasons? -// firstItemFocusRequester.requestFocus() - episodes.episodes.getOrNull(position.episodeRowIndex)?.let { - viewModel.refreshEpisode(it.id, position.episodeRowIndex) - } - } - } - } - } - LaunchedEffect(position, episodes) { val focusedEpisode = (episodes as? EpisodeList.Success) 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 ee07a170..2b1b5976 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 @@ -387,12 +387,6 @@ class SeriesViewModel withContext(Dispatchers.Main) { this@SeriesViewModel.episodes.value = episodes } - if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - (episodes as? EpisodeList.Success) - ?.let { - it.episodes.getOrNull(it.initialEpisodeIndex) - }?.let { lookupPeopleInEpisode(it) } - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 4b30c418..08f9318e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -146,17 +146,17 @@ class ApiRequestPager<T>( position: Int, itemId: UUID, ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from( - it, - api, - useSeriesForPrimary, - ) - } - val pageNumber = position / pageSize - val index = position - pageNumber * pageSize mutex.withLock { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize val page = cachedPages.getIfPresent(pageNumber) if (page != null && index in page.indices) { page[index] = item From 2da55bb616fe17d1fbb981eb46f647f050ab4e61 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:54:55 -0400 Subject: [PATCH 110/118] Use newer libplacebo branch (#1077) ## Description Testing newer `libplacebo` to include https://github.com/haasn/libplacebo/commit/c93aa134ab62365ce1177efff99b8e1e66a818e7 ### Related issues Related to #806 --- scripts/mpv/get_dependencies.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index 7ca18de8..74bff6d6 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -30,7 +30,11 @@ clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freety clone "https://github.com/libass/libass" "0.17.4" libass -clone "https://github.com/haasn/libplacebo" "v7.351.0" libplacebo --recurse-submodules +rm -rf libplacebo +git clone "https://github.com/haasn/libplacebo" --single-branch -b "master" --recurse-submodules libplacebo +pushd libplacebo || exit +git checkout 1bd8d2d6a715bc870bdffa6759e62c419000dd51 +popd || exit clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv From 627b89f1dbffd55573607491ce9c65c56760522e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:47:08 -0400 Subject: [PATCH 111/118] Updates to Seerr image fetching (#1086) ## Description This PR makes changes to how images are fetched for discover/Seerr items. If the discovered item is available on the Jellyfin server, the images will be fetched from the Jellyfin server. Then if the Seerr server has image caching enabled, the images will be fetched from the Seerr server. Otherwise, images are fetched from TMDB . TMDB was the previous behavior for all images. ### Dev notes This PR also updates the Seerr server URLs stored in the database, so it's not backwards compatible. ### Related issues Fixes #1079 ### Testing Emulator against Jellyseer 2.7.3 & Seerr 3.0.1 ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 13 +- .../wholphin/data/model/DiscoverItem.kt | 113 +------ .../wholphin/services/AppUpgradeHandler.kt | 302 ++++++++++-------- .../damontecres/wholphin/services/SeerrApi.kt | 3 +- .../services/SeerrServerRepository.kt | 25 +- .../wholphin/services/SeerrService.kt | 202 +++++++++++- .../detail/discover/DiscoverMovieViewModel.kt | 10 +- .../ui/detail/discover/DiscoverPersonPage.kt | 4 +- .../discover/DiscoverSeriesViewModel.kt | 10 +- .../ui/detail/series/SeriesViewModel.kt | 4 +- .../wholphin/ui/discover/SeerrRequestsPage.kt | 4 +- .../wholphin/ui/main/SearchPage.kt | 2 +- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 47 ++- app/src/main/seerr/seerr-api.yml | 2 + .../damontecres/wholphin/test/TestSeerr.kt | 122 +++++-- 15 files changed, 536 insertions(+), 327 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 3877c26c..9cb740ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -93,9 +93,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var updateChecker: UpdateChecker - @Inject - lateinit var appUpgradeHandler: AppUpgradeHandler - @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver @@ -136,11 +133,7 @@ class MainActivity : AppCompatActivity() { instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) - if (savedInstanceState == null) { - lifecycleScope.launchIO { - appUpgradeHandler.copySubfont(false) - } - } + viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -249,7 +242,6 @@ class MainActivity : AppCompatActivity() { Timber.d("onResume") lifecycleScope.launchDefault { screensaverService.pulse() - appUpgradeHandler.run() } } @@ -384,10 +376,13 @@ class MainActivityViewModel private val navigationManager: SetupNavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, + private val appUpgradeHandler: AppUpgradeHandler, ) : ViewModel() { fun appStart() { viewModelScope.launchIO { try { + appUpgradeHandler.run() + appUpgradeHandler.copySubfont(false) val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val userHasPin = serverRepository.currentUser.value?.hasPin == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index 3144b014..0cf88ff1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -3,25 +3,16 @@ package com.github.damontecres.wholphin.data.model import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.api.seerr.model.CreditCast -import com.github.damontecres.wholphin.api.seerr.model.CreditCrew -import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieResult -import com.github.damontecres.wholphin.api.seerr.model.TvDetails -import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.services.SeerrSearchResult import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.toLocalDate import com.github.damontecres.wholphin.util.LocalDateSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.UUIDSerializer -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.time.LocalDate import java.util.UUID @@ -85,17 +76,14 @@ data class DiscoverItem( val overview: String?, val availability: SeerrAvailability, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, - val posterPath: String?, - val backdropPath: String?, + val posterUrl: String?, + val backDropUrl: String?, val jellyfinItemId: UUID?, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = title ?: "" - val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" } - val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } - val destination: Destination get() { val jfType = @@ -114,103 +102,6 @@ data class DiscoverItem( Destination.DiscoveredItem(this) } } - - constructor(movie: MovieResult) : this( - id = movie.id, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterPath = movie.posterPath, - backdropPath = movie.backdropPath, - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(movie: MovieDetails) : this( - id = movie.id ?: -1, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterPath = movie.posterPath, - backdropPath = movie.backdropPath, - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(tv: TvResult) : this( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterPath = tv.posterPath, - backdropPath = tv.backdropPath, - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(tv: TvDetails) : this( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterPath = tv.posterPath, - backdropPath = tv.backdropPath, - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(search: SeerrSearchResult) : this( - id = search.id, - type = SeerrItemType.fromString(search.mediaType), - title = search.title ?: search.name, - subtitle = null, - overview = search.overview, - availability = - SeerrAvailability.from(search.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), - posterPath = search.posterPath, - backdropPath = search.backdropPath, - jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(credit: CreditCast) : this( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.character, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), - posterPath = credit.posterPath ?: credit.profilePath, - backdropPath = credit.backdropPath, - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(credit: CreditCrew) : this( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.job, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), - posterPath = credit.posterPath ?: credit.profilePath, - backdropPath = credit.backdropPath, - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) } data class DiscoverRating( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 0331f04b..ca720af3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,6 +6,7 @@ import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ScreensaverPreference @@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.model.api.BaseItemKind @@ -35,9 +37,14 @@ class AppUpgradeHandler constructor( @param:ApplicationContext private val context: Context, private val appPreferences: DataStore<AppPreferences>, + private val seerrServerDao: SeerrServerDao, ) { suspend fun run() { - val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0) + val pkgInfo = + WholphinApplication.instance.packageManager.getPackageInfo( + WholphinApplication.instance.packageName, + 0, + ) val prefs = PreferenceManager.getDefaultSharedPreferences(context) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) @@ -62,14 +69,16 @@ class AppUpgradeHandler try { copySubfont(true) upgradeApp( - context, - Version.Companion.fromString(previousVersion ?: "0.0.0"), - Version.Companion.fromString(newVersion), + Version.fromString(previousVersion ?: "0.0.0"), + Version.fromString(newVersion), appPreferences, ) } catch (ex: Exception) { Timber.e(ex, "Exception during app upgrade") } + Timber.i("App upgrade complete") + } else { + Timber.d("No app update needed") } } @@ -100,110 +109,108 @@ class AppUpgradeHandler const val VERSION_NAME_CURRENT_KEY = "version.current.name" const val VERSION_CODE_CURRENT_KEY = "version.current.code" } - } -suspend fun upgradeApp( - context: Context, - previous: Version, - current: Version, - appPreferences: DataStore<AppPreferences>, -) { - if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { - appPreferences.updateData { - it.updatePlaybackOverrides { - ac3Supported = AppPreference.Ac3Supported.defaultValue - downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue - directPlayPgs = AppPreference.DirectPlayPgs.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - showClock = AppPreference.ShowClock.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { - PreferencesViewModel.resetSubtitleSettings(appPreferences) - } - if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - margin = SubtitleSettings.Margin.defaultValue.toInt() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { - appPreferences.updateData { - it.updateAdvancedPreferences { - if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { - imageDiskCacheSizeBytes = - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + suspend fun upgradeApp( + previous: Version, + current: Version, + appPreferences: DataStore<AppPreferences>, + ) { + if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + ac3Supported = AppPreference.Ac3Supported.defaultValue + downmixStereo = AppPreference.DownMixStereo.defaultValue + directPlayAss = AppPreference.DirectPlayAss.defaultValue + directPlayPgs = AppPreference.DirectPlayPgs.defaultValue + } } } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = AppPreference.MpvGpuNext.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { - appPreferences.updateData { - it.update { - signInAutomatically = AppPreference.SignInAuto.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - if (edgeThickness < 1) { - edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue + } } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { - appPreferences.updateData { - it.updateLiveTvPreferences { - showHeader = AppPreference.LiveTvShowHeader.defaultValue - favoriteChannelsAtBeginning = - AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue - sortByRecentlyWatched = - AppPreference.LiveTvChannelSortByWatched.defaultValue - colorCodePrograms = - AppPreference.LiveTvColorCodePrograms.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { - if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showClock = AppPreference.ShowClock.defaultValue + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { + PreferencesViewModel.resetSubtitleSettings(appPreferences) + } + if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + margin = SubtitleSettings.Margin.defaultValue.toInt() + } } } - } - } - // TODO temporarily disabled until some MPV bugs are fixed + if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { + appPreferences.updateData { + it.updateAdvancedPreferences { + if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { + imageDiskCacheSizeBytes = + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = AppPreference.MpvGpuNext.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { + appPreferences.updateData { + it.update { + signInAutomatically = AppPreference.SignInAuto.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + if (edgeThickness < 1) { + edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + } + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { + appPreferences.updateData { + it.updateLiveTvPreferences { + showHeader = AppPreference.LiveTvShowHeader.defaultValue + favoriteChannelsAtBeginning = + AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue + sortByRecentlyWatched = + AppPreference.LiveTvChannelSortByWatched.defaultValue + colorCodePrograms = + AppPreference.LiveTvColorCodePrograms.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { + if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } + } + + // TODO temporarily disabled until some MPV bugs are fixed // if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { // appPreferences.updateData { // it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } @@ -211,56 +218,67 @@ suspend fun upgradeApp( // showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) // } - if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - subtitlesPreferences = - subtitlesPreferences - .toBuilder() - .apply { - imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() - }.build() - // Copy current subtitle prefs as HDR ones - hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = + SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { - appPreferences.updateData { - it.updatePhotoPreferences { - slideshowDuration = AppPreference.SlideshowDuration.defaultValue + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { - appPreferences.updateData { - it.updateHomePagePreferences { - maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { - appPreferences.updateData { - it.updateScreensaverPreferences { - startDelay = ScreensaverPreference.DEFAULT_START_DELAY - duration = ScreensaverPreference.DEFAULT_DURATION - animate = ScreensaverPreference.Animate.defaultValue - maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE - clearItemTypes() - addItemTypes(BaseItemKind.MOVIE.serialName) - addItemTypes(BaseItemKind.SERIES.serialName) + if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) { + seerrServerDao.getServers().forEach { + val server = it.server + seerrServerDao.updateServer( + server.copy(url = migrateSeerrUrl(server.url)), + ) + } } } } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt index c68cd3d2..5c62655f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import okhttp3.OkHttpClient /** @@ -24,6 +25,6 @@ class SeerrApi( baseUrl: String, apiKey: String?, ) { - api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient) } } 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 80eafb7a..0f073af6 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 @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -163,7 +165,7 @@ class SeerrServerRepository val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } val api = SeerrApiClient( - url, + createSeerrApiUrl(url), apiKey, okHttpClient .newBuilder() @@ -331,3 +333,24 @@ class UserSwitchListener } } } + +fun CurrentSeerr?.imageUrlBuilder( + imageType: ImageType, + path: String?, +): String? { + if (this == null) return null + val cacheImages = serverConfig.cacheImages == true + val base = + if (cacheImages) { + server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 14aa0b9a..0c1801ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -1,12 +1,25 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.CreditCast +import com.github.damontecres.wholphin.api.seerr.model.CreditCrew +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.toLocalDate import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import javax.inject.Inject import javax.inject.Singleton @@ -23,6 +36,7 @@ class SeerrService constructor( private val seerApi: SeerrApi, private val seerrServerRepository: SeerrServerRepository, + private val imageUrlService: ImageUrlService, ) { val api: SeerrApiClient get() = seerApi.api @@ -41,35 +55,35 @@ class SeerrService api.searchApi .discoverTvGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun trending(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTrendingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTvUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() /** @@ -90,14 +104,14 @@ class SeerrService api.moviesApi .movieMovieIdSimilarGet(movieId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { api.tvApi .tvTvIdSimilarGet(tvId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.PERSON -> { @@ -107,12 +121,12 @@ class SeerrService val cast = credits.cast ?.take(25) - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() val crew = credits.crew ?.take(25) - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() cast + crew } @@ -138,4 +152,174 @@ class SeerrService } else { null } + + private suspend fun createImageUrl( + imageType: ImageType, + path: String?, + mediaInfo: MediaInfo?, + ): String? { + if (mediaInfo != null) { + val itemId = + if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) { + mediaInfo.jellyfinMediaId.toUUIDOrNull() + } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) { + mediaInfo.jellyfinMediaId4k.toUUIDOrNull() + } else { + null + } + if (itemId != null) { + return imageUrlService.getItemImageUrl( + itemId = itemId, + imageType = imageType, + ) + } + } + val current = seerrServerRepository.current.firstOrNull() ?: return null + val cacheImages = current.serverConfig.cacheImages == true + val base = + if (cacheImages) { + current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" + } + + suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = + DiscoverItem( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = + DiscoverItem( + id = movie.id ?: -1, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = + DiscoverItem( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = + SeerrAvailability.from(tv.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = + DiscoverItem( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = + SeerrAvailability.from(tv.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = + DiscoverItem( + id = search.id, + type = SeerrItemType.fromString(search.mediaType), + title = search.title ?: search.name, + subtitle = null, + overview = search.overview, + availability = + SeerrAvailability.from(search.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = + DiscoverItem( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.character, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = + DiscoverItem( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.job, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index a50e848d..3edd7dd6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -103,7 +103,7 @@ class DiscoverMovieViewModel ) { Timber.v("Init for movie %s", item.id) val movie = fetchAndSetItem().await() - val discoveredItem = DiscoverItem(movie) + val discoveredItem = seerrService.createDiscoverItem(movie) backdropService.submit(discoveredItem) updateCanCancel() @@ -121,7 +121,7 @@ class DiscoverMovieViewModel seerrService.api.moviesApi .movieMovieIdSimilarGet(movieId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() similar.setValueOnMain(result) } @@ -130,7 +130,7 @@ class DiscoverMovieViewModel seerrService.api.moviesApi .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() recommended.setValueOnMain(result) } @@ -138,11 +138,11 @@ class DiscoverMovieViewModel val people = movie.credits ?.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() + movie.credits ?.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() this@DiscoverMovieViewModel.people.setValueOnMain(people) val trailers = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt index 32d38aa5..f7d50f31 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -67,11 +67,11 @@ class DiscoverPersonViewModel .let { credits -> val cast = credits.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() val crew = credits.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() cast + crew } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index da6550ec..54b3d98f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -102,7 +102,7 @@ class DiscoverSeriesViewModel ) { Timber.v("Init for tv %s", item.id) val tv = fetchAndSetItem().await() - val discoveredItem = DiscoverItem(tv) + val discoveredItem = seerrService.createDiscoverItem(tv) backdropService.submit(discoveredItem) updateSeasonStatus() @@ -121,7 +121,7 @@ class DiscoverSeriesViewModel seerrService.api.tvApi .tvTvIdSimilarGet(tvId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() similar.setValueOnMain(result) } @@ -130,7 +130,7 @@ class DiscoverSeriesViewModel seerrService.api.tvApi .tvTvIdRecommendationsGet(tvId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() recommended.setValueOnMain(result) } @@ -138,11 +138,11 @@ class DiscoverSeriesViewModel val people = tv.credits ?.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() + tv.credits ?.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() this@DiscoverSeriesViewModel.people.setValueOnMain(people) 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 2b1b5976..2d044670 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 @@ -239,7 +239,9 @@ class SeriesViewModel val tv = if (active) { try { - seerrService.getTvSeries(item)?.let { DiscoverItem(it) } + seerrService + .getTvSeries(item) + ?.let { seerrService.createDiscoverItem(it) } } catch (ex: Exception) { Timber.e(ex) null diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 85863650..6ed79a06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -85,13 +85,13 @@ class SeerrRequestsViewModel seerrService.api.moviesApi .movieMovieIdGet( movieId = request.media.tmdbId, - ).let { DiscoverItem(it) } + ).let { seerrService.createDiscoverItem(it) } } SeerrItemType.TV -> { seerrService.api.tvApi .tvTvIdGet(tvId = request.media.tmdbId) - .let { DiscoverItem(it) } + .let { seerrService.createDiscoverItem(it) } } SeerrItemType.PERSON -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index b5839aad..a7fade4e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -165,7 +165,7 @@ class SearchViewModel val results = seerrService .search(query) - .map { DiscoverItem(it) } + .map { seerrService.createDiscoverItem(it) } .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) } 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 02e97ff5..7c05194a 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 @@ -127,37 +127,50 @@ class SwitchSeerrViewModel } fun createUrls(url: String): List<HttpUrl> { - val urls = mutableListOf<String>() + val urls = mutableListOf<HttpUrl>() if (url.startsWith("http://") || url.startsWith("https://")) { - urls.add(url) val httpUrl = url.toHttpUrl() + urls.add(httpUrl) if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { - urls.add("$url:5055") + urls.add(httpUrl.newBuilder().port(5055).build()) } } else { - urls.add("http://$url") val httpUrl = "http://$url".toHttpUrl() + urls.add(httpUrl) if (httpUrl.port == 80) { - urls.add("https://$url") - urls.add("http://$url:5055") - urls.add("https://$url:5055") + urls.add(httpUrl.newBuilder().scheme("https").build()) + urls.add(httpUrl.newBuilder().port(5055).build()) + urls.add( + httpUrl + .newBuilder() + .scheme("https") + .port(5055) + .build(), + ) } else { - urls.add("https://$url") + urls.add(httpUrl.newBuilder().scheme("https").build()) } } - return urls.map { cleanUrl(it).toHttpUrl() } + return urls } -private fun cleanUrl(url: String) = - if (!url.endsWith("/api/v1")) { +fun createSeerrApiUrl(url: String): String = + if (url.isBlank()) { + url + } else if (url.endsWith("/api/v1") || url.endsWith("/api/v1/")) { + url + } else { url .toHttpUrl() .newBuilder() - .apply { - addPathSegment("api") - addPathSegment("v1") - }.build() + .addPathSegment("api") + .addPathSegment("v1") + .build() .toString() - } else { - url } + +fun migrateSeerrUrl(url: String): String { + var url = url.removeSuffix("/api/v1/").removeSuffix("/api/v1") + if (!url.endsWith("/")) url += "/" + return url +} diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d6b719ec..4c21b699 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -711,6 +711,8 @@ components: type: boolean series4kEnabled: type: boolean + cacheImages: + type: boolean MovieResult: type: object required: 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 a4acf93f..c19ffb81 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 @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.ui.setup.seerr.createUrls -import org.junit.Assert +import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl +import org.junit.Assert.assertEquals import org.junit.Test class TestSeerr { @@ -13,12 +15,12 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "https://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "https://jellyseerr.com/", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -29,10 +31,10 @@ class TestSeerr { val expected = listOf( - "https://jellyseerr.com/api/v1", - "https://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -43,10 +45,10 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "http://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -57,10 +59,10 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -71,10 +73,10 @@ class TestSeerr { val expected = listOf( - "http://10.0.0.2:443/api/v1", - "https://10.0.0.2/api/v1", + "http://10.0.0.2:443/", + "https://10.0.0.2/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -85,9 +87,87 @@ class TestSeerr { val expected = listOf( - "http://10.0.0.2:8080/api/v1", - "https://10.0.0.2:8080/api/v1", + "http://10.0.0.2:8080/", + "https://10.0.0.2:8080/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls7() { + val urls = + createUrls("http://10.0.0.2:80") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2/", + "http://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls8() { + val urls = + createUrls("https://10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "https://10.0.0.2/", + "https://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test createUrls for path`() { + val urls = + createUrls("https://jellyseerr.com/seerr/") + .map { it.toString() } + + val expected = + listOf( + "https://jellyseerr.com/seerr/", + "https://jellyseerr.com:5055/seerr/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test build api url`() { + var url = "https://jellyseerr.com/" + assertEquals("https://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "https://jellyseerr.com/path" + assertEquals("https://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:5055/" + assertEquals("http://jellyseerr.com:5055/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:7878/path/" + assertEquals("http://jellyseerr.com:7878/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1" + assertEquals("http://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1/" + assertEquals("http://jellyseerr.com/api/v1/", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/path/api/v1" + assertEquals("http://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + } + + @Test + fun `Test migration`() { + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1")) + assertEquals("https://10.0.0.2/", migrateSeerrUrl("https://10.0.0.2/api/v1")) + assertEquals("http://10.0.0.2:5055/", migrateSeerrUrl("http://10.0.0.2:5055/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1/")) + assertEquals("http://10.0.0.2/path/", migrateSeerrUrl("http://10.0.0.2/path/api/v1")) + assertEquals("http://10.0.0.2/api/v1/", migrateSeerrUrl("http://10.0.0.2/api/v1/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2")) } } From 23e5225c190f761835986e02fb6a90d893a84c79 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:35:32 -0400 Subject: [PATCH 112/118] Debug installs should use same when updating (#1103) ## Description Fixes debug install updates from trying to install the release APK and use debug ones instead. ### Related issues Fixes #1098 ### Testing Unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/UpdateChecker.kt | 73 ++- .../wholphin/test/TestUpdateChecker.kt | 56 +++ app/src/test/resources/release_develop.json | 475 ++++++++++++++++++ 3 files changed, 566 insertions(+), 38 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt create mode 100644 app/src/test/resources/release_develop.json diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index f4aa80dc..709549cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -14,7 +14,9 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.UpdateChecker.Companion.ASSET_NAME import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.showToast @@ -51,9 +53,8 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - // TODO apk names - private const val ASSET_NAME = "Wholphin" - private const val APK_NAME = "$ASSET_NAME.apk" + const val ASSET_NAME = "Wholphin" + const val APK_NAME = "$ASSET_NAME.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" @@ -118,11 +119,6 @@ class UpdateChecker suspend fun getLatestRelease(updateUrl: String): Release? { return withContext(Dispatchers.IO) { - val preferRelease = - PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean("updatePreferRelease", true) - val request = Request .Builder() @@ -140,7 +136,7 @@ class UpdateChecker val downloadUrl = result.jsonObject["assets"] ?.jsonArray - ?.let { assets -> getDownloadUrl(assets, preferRelease) } + ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } Timber.v("version=$version, downloadUrl=$downloadUrl") if (version != null) { val notes = @@ -165,35 +161,6 @@ class UpdateChecker } } - private fun getDownloadUrl( - assets: JsonArray, - preferRelease: Boolean, - ): String? { - val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" } - val releaseSuffix = if (preferRelease) "-release" else "-debug" - val preferredNames = - listOf( - "$ASSET_NAME${releaseSuffix}$abiSuffix.apk", - "$ASSET_NAME$releaseSuffix.apk", - "$ASSET_NAME.apk", - ) - var preferredAsset: JsonObject? = null - outer@ for (name in preferredNames) { - for (asset in assets) { - val assetName = - asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull - if (name == assetName) { - preferredAsset = asset.jsonObject - break@outer - } - } - } - return preferredAsset - ?.get("browser_download_url") - ?.jsonPrimitive - ?.contentOrNull - } - suspend fun installRelease( release: Release, callback: DownloadCallback, @@ -387,3 +354,33 @@ suspend fun copyTo( } return bytesCopied } + +fun getDownloadUrl( + assets: JsonArray, + debug: Boolean, + supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), +): String? { + val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" } + val releaseSuffix = if (debug) "-debug" else "-release" + val preferredNames = + buildList { + add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk") + add("$ASSET_NAME$releaseSuffix.apk") + if (!debug) add("$ASSET_NAME.apk") + } + var preferredAsset: JsonObject? = null + outer@ for (name in preferredNames) { + for (asset in assets) { + val assetName = + asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull + if (name == assetName) { + preferredAsset = asset.jsonObject + break@outer + } + } + } + return preferredAsset + ?.get("browser_download_url") + ?.jsonPrimitive + ?.contentOrNull +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt new file mode 100644 index 00000000..ca8ef324 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.services.getDownloadUrl +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestUpdateChecker { + lateinit var releaseJson: JsonObject + val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } + + @Before + fun setup() { + val resource = javaClass.classLoader?.getResource("release_develop.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + releaseJson = Json.parseToJsonElement(fileContents).jsonObject + } + + @Test + fun `Release chooses release`() { + val url = getDownloadUrl(assetsJson, false, listOf()) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + } + + @Test + fun `Choose abi`() { + val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) + } + + @Test + fun `Choose unknown abi`() { + val url = getDownloadUrl(assetsJson, false, listOf("unknown")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + } + + @Test + fun `Debug chooses debug`() { + val url = getDownloadUrl(assetsJson, true, listOf()) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) + } + + @Test + fun `Choose debug abi`() { + val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) + } +} diff --git a/app/src/test/resources/release_develop.json b/app/src/test/resources/release_develop.json new file mode 100644 index 00000000..df48106f --- /dev/null +++ b/app/src/test/resources/release_develop.json @@ -0,0 +1,475 @@ +{ + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448", + "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448/assets", + "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/297064448/assets{?name,label}", + "html_url": "https://github.com/damontecres/Wholphin/releases/tag/develop", + "id": 297064448, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOPzwRps4RtNgA", + "tag_name": "develop", + "target_commitish": "main", + "name": "v0.5.3-8-g627b89f1", + "draft": false, + "immutable": false, + "prerelease": true, + "created_at": "2026-03-14T20:47:08Z", + "updated_at": "2026-03-14T20:54:31Z", + "published_at": "2026-03-14T20:54:31Z", + "assets": [ + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937404", + "id": 373937404, + "node_id": "RA_kwDOPzwRps4WSdT8", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937407", + "id": 373937407, + "node_id": "RA_kwDOPzwRps4WSdT_", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:25Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937406", + "id": 373937406, + "node_id": "RA_kwDOPzwRps4WSdT-", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937405", + "id": 373937405, + "node_id": "RA_kwDOPzwRps4WSdT9", + "name": "Wholphin-debug-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937408", + "id": 373937408, + "node_id": "RA_kwDOPzwRps4WSdUA", + "name": "Wholphin-debug-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937422", + "id": 373937422, + "node_id": "RA_kwDOPzwRps4WSdUO", + "name": "Wholphin-debug.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937424", + "id": 373937424, + "node_id": "RA_kwDOPzwRps4WSdUQ", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937427", + "id": 373937427, + "node_id": "RA_kwDOPzwRps4WSdUT", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937431", + "id": 373937431, + "node_id": "RA_kwDOPzwRps4WSdUX", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 0, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937439", + "id": 373937439, + "node_id": "RA_kwDOPzwRps4WSdUf", + "name": "Wholphin-release-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 1, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937452", + "id": 373937452, + "node_id": "RA_kwDOPzwRps4WSdUs", + "name": "Wholphin-release-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:30Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937453", + "id": 373937453, + "node_id": "RA_kwDOPzwRps4WSdUt", + "name": "Wholphin-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 1, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:31Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk" + } + ], + "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/develop", + "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/develop", + "body": "### `main` development build\n\nThis pre-release tracks the development build of Wholphin from the `main` unstable branch.\n\nSee https://github.com/damontecres/Wholphin/releases/latest for the latest stable release.\n\nIf you want to update to this version in-app, change Settings->Advanced->Update URL to https://api.github.com/repos/damontecres/Wholphin/releases/tags/develop (replace `latest` with `tags/develop`).\n\n[See changes since `v0.5.3`](https://github.com/damontecres/Wholphin/compare/v0.5.3...develop)\n" +} From 9057dceef6ab5ad5f02b256e95745a6b2d8a9eb5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:31:18 -0400 Subject: [PATCH 113/118] Various fixes to screensaver, subtitle order, & playlist modifications (#1104) ## Description Several small tweaks: - Always put screensaver logo on left instead of random - Disable playback speed option if audio is being passed through - When opening playback settings such as speed or scale, focus on the current value instead of top of the list; doesn't apply to subtitles so it's easy to toggle them off - If the user has a preferred subtitle language, show tracks in that language first/top - Add toast message after creating or adding to a playlist ### Related issues Playback speed audio pass through: #164 ### Testing Emulator & shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/AppScreensaver.kt | 7 ++- .../wholphin/ui/components/Dialogs.kt | 43 ++++++++++++------- .../wholphin/ui/data/AddPlaylistViewModel.kt | 12 +++++- .../ui/detail/episode/EpisodeDetails.kt | 8 ++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 8 ++++ .../ui/detail/series/SeriesOverview.kt | 8 ++++ .../wholphin/ui/playback/PlaybackControls.kt | 15 +++++-- .../wholphin/ui/playback/PlaybackDialog.kt | 16 +++++++ .../wholphin/ui/playback/PlaybackPage.kt | 3 ++ .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +++++- 10 files changed, 107 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt index d26675b1..fe4ae115 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -135,7 +135,6 @@ fun AppScreensaverContent( ) var logoError by remember(currentItem) { mutableStateOf(false) } - val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random() if (!logoError) { AsyncImage( model = @@ -150,7 +149,7 @@ fun AppScreensaverContent( }, modifier = Modifier - .align(alignment) + .align(Alignment.BottomStart) .size(width = 240.dp, height = 120.dp) .padding(16.dp), ) @@ -158,7 +157,7 @@ fun AppScreensaverContent( Box( modifier = Modifier - .align(alignment) + .align(Alignment.BottomStart) .padding(16.dp) .fillMaxWidth(.5f) .fillMaxHeight(.3f), @@ -169,7 +168,7 @@ fun AppScreensaverContent( style = MaterialTheme.typography.displaySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(alignment), + modifier = Modifier.align(Alignment.BottomStart), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index a96bc859..82aab6ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -632,6 +632,7 @@ fun chooseStream( streams: List<MediaStream>, currentIndex: Int?, type: MediaStreamType, + preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( @@ -669,22 +670,32 @@ fun chooseStream( ) } addAll( - streams.filter { it.type == type }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(context, stream, true) - DialogItem( - selected = currentIndex == stream.index, - leadingContent = { - SelectedLeadingContent(currentIndex == stream.index) - }, - headlineContent = { - Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) - }, - supportingContent = { - if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) - }, - onClick = { onClick.invoke(stream.index) }, - ) - }, + streams + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { + it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } + } else { + it + } + }.mapIndexed { index, stream -> + val simpleStream = SimpleMediaStream.from(context, stream, true) + DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, + headlineContent = { + Text( + text = simpleStream.streamTitle ?: simpleStream.displayTitle, + ) + }, + supportingContent = { + if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) + }, + onClick = { onClick.invoke(stream.index) }, + ) + }, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index 009c4268..9e2141b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.launchIO @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -43,7 +45,13 @@ class AddPlaylistViewModel itemId: UUID, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - playlistCreator.addToServerPlaylist(playlistId, itemId) + try { + playlistCreator.addToServerPlaylist(playlistId, itemId) + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) + } catch (ex: Exception) { + Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId) + showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT) + } } } @@ -55,6 +63,8 @@ class AddPlaylistViewModel val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId)) if (playlistId == null) { showToast(context, "Error creating playlist", Toast.LENGTH_LONG) + } else { + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) } } } 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 ee3cc4a2..e341923b 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 @@ -86,6 +86,13 @@ fun EpisodeDetails( var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference + val moreActions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -200,6 +207,7 @@ fun EpisodeDetails( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, 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 a59ecde1..87a5dd75 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 @@ -114,6 +114,13 @@ fun MovieDetails( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference + val moreActions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -246,6 +253,7 @@ fun MovieDetails( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, 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 68cfa308..84180f20 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 @@ -138,6 +138,13 @@ fun SeriesOverview( } val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference + when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) @@ -252,6 +259,7 @@ fun SeriesOverview( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 75ac0951..8ad9282c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -8,7 +8,6 @@ import androidx.annotation.OptIn import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -37,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -64,6 +61,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.skipStringRes @@ -470,6 +468,14 @@ fun <T> BottomDialog( gravity: Int, currentChoice: BottomDialogItem<T>? = null, ) { + val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + if (currentChoice != null) { + LaunchedEffect(Unit) { + choices.indexOfFirstOrNull { it == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -504,6 +510,7 @@ fun <T> BottomDialog( val interactionSource = remember { MutableInteractionSource() } ListItem( selected = choice == currentChoice, + enabled = choice.enabled, onClick = { onDismissRequest() onSelectChoice(index, choice) @@ -524,6 +531,7 @@ fun <T> BottomDialog( } }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(focusRequesters[index]), ) } } @@ -539,6 +547,7 @@ data class BottomDialogItem<T>( val data: T, val headline: String, val supporting: String?, + val enabled: Boolean = true, ) @PreviewTvSpec diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index c6d7b622..a305023c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -14,9 +14,12 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -32,6 +35,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlin.time.Duration enum class PlaybackDialogType { @@ -54,6 +59,7 @@ data class PlaybackSettings( val contentScale: ContentScale, val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, + val playbackSpeedEnabled: Boolean, ) @Composable @@ -137,6 +143,7 @@ fun PlaybackDialog( data = PlaybackDialogType.PLAYBACK_SPEED, headline = stringResource(R.string.playback_speed), supporting = settings.playbackSpeed.toString(), + enabled = settings.playbackSpeedEnabled, ), ) if (enableVideoScale) { @@ -395,6 +402,14 @@ fun StreamChoiceBottomDialog( gravity: Int, currentChoice: Int? = null, ) { + val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + if (currentChoice != null) { + LaunchedEffect(Unit) { + choices.indexOfFirstOrNull { it.index == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -445,6 +460,7 @@ fun StreamChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(focusRequesters[index]), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 3b9958b6..8a6b488f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -596,6 +596,9 @@ fun PlaybackPageContent( subtitleDelay = subtitleDelay, hasSubtitleDownloadPermission = remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, + // TODO Passing through audio prevents changing playback speed + // See https://github.com/damontecres/Wholphin/issues/164 + playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, ), onDismissRequest = { playbackDialog = null diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 0d23f4bf..d3be4324 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -446,11 +446,20 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - + val subtitleLanguagePreference = + serverRepository.currentUserDto.value + ?.configuration + ?.subtitleLanguagePreference val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { + .let { + if (subtitleLanguagePreference.isNotNullOrBlank()) { + it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } + } else { + it + } + }?.map { SimpleMediaStream.from(context, it, true) }.orEmpty() From ee0df25b6fb893fa8b5b13ba92ef5d1f8f59490c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:59:04 -0400 Subject: [PATCH 114/118] Update Gradle to v9.4.0 (#1038) --- gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a659d17295f1de7c53e24fdf13ad755c379..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch delta 39855 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 delta 37345 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6a..dbc3ce4a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685a..0262dcbd 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From 31db1f59b94f63665b117cea69ebbbfaa9d33fb6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:59:34 -0400 Subject: [PATCH 115/118] Update Dependencies (#1082) --- gradle/libs.versions.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b60ca106..0533d21e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,9 @@ hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.10" ksp = "2.3.6" -coreKtx = "1.17.0" +coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.02.01" +composeBom = "2026.03.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -20,10 +20,10 @@ okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" -tvFoundation = "1.0.0-alpha12" +tvFoundation = "1.0.0-beta01" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" +activityCompose = "1.13.0" androidx-media3 = "1.9.2" coil = "3.4.0" jellyfin-sdk = "1.7.1" @@ -31,7 +31,7 @@ nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" -datastore = "1.2.0" +datastore = "1.2.1" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.34.0" hilt = "2.59.2" From f3c0606ebf466776678e40fc1ebcbfae171a7ab5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:59:52 -0400 Subject: [PATCH 116/118] Update Kotlin to v2.3.20 (#1105) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0533d21e..7f4e037a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" -kotlin = "2.3.10" +kotlin = "2.3.20" ksp = "2.3.6" coreKtx = "1.18.0" appcompat = "1.7.1" From bc7b751b830632ad5d39b8c1c003be00478a944a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:18:40 -0400 Subject: [PATCH 117/118] Update Androidx Media3 to v1.9.3 (#1106) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f4e037a..280d183e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ tvFoundation = "1.0.0-beta01" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -androidx-media3 = "1.9.2" +androidx-media3 = "1.9.3" coil = "3.4.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.1" From 3af97755924fcc9beb39b83376c1abba60bc28c3 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 17 Mar 2026 12:47:46 -0400 Subject: [PATCH 118/118] Release v0.5.4