From 273a6138c62a5a3f8749889c8e64efdf2deb26e4 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 30 May 2026 16:56:33 -0400 Subject: [PATCH 001/475] Add more sort options to different types (#1488) ## Description Adds more sorting options, notably premier date & ratings, to different media types. ### Related issues Closes #1417 Closes #1429 Replaces #1420 ### Testing Emulator on movie genre's items and music video libraries ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 4 ++ .../wholphin/services/HomeSettingsService.kt | 3 ++ .../wholphin/ui/components/GenreCardGrid.kt | 3 ++ .../wholphin/ui/data/SortAndDirection.kt | 42 +++++++++++++++++++ .../ui/detail/CollectionFolderMovie.kt | 2 + .../ui/detail/CollectionFolderMusic.kt | 2 + .../wholphin/ui/detail/CollectionFolderTv.kt | 2 + .../wholphin/ui/detail/HomeRowGrid.kt | 2 + .../wholphin/ui/nav/Destination.kt | 1 + .../wholphin/ui/nav/DestinationContent.kt | 2 + 10 files changed, 63 insertions(+) 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 e6e70625..18337e1b 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 @@ -24,6 +24,7 @@ import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale import java.util.UUID @@ -283,6 +284,7 @@ fun createGenreDestination( parentId: UUID, parentName: String?, includeItemTypes: List?, + collectionType: CollectionType, ) = Destination.FilteredCollection( itemId = parentId, parentType = BaseItemKind.GENRE, @@ -301,6 +303,7 @@ fun createGenreDestination( useSavedLibraryDisplayInfo = false, ), recursive = true, + collectionType = collectionType, ) fun createStudioDestination( @@ -327,6 +330,7 @@ fun createStudioDestination( useSavedLibraryDisplayInfo = false, ), recursive = true, + collectionType = CollectionType.UNKNOWN, ) val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() 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 4288e337..50c8ae83 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 @@ -693,6 +693,9 @@ class HomeSettingsService listOf(it) } }, + collectionType = + library?.collectionType + ?: CollectionType.UNKNOWN, ), ) } 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 ac19cf30..18e335f1 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 @@ -51,6 +51,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -255,6 +256,7 @@ data class Genre( fun GenreCardGrid( itemId: UUID, includeItemTypes: List?, + collectionType: CollectionType, modifier: Modifier = Modifier, initialPosition: Int = 0, viewModel: GenreViewModel = @@ -308,6 +310,7 @@ fun GenreCardGrid( parentId = itemId, parentName = item.title, includeItemTypes = includeItemTypes, + collectionType = collectionType, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 30edee62..6044ffe0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -1,8 +1,12 @@ package com.github.damontecres.wholphin.ui.data import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.github.damontecres.wholphin.R import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -46,6 +50,7 @@ val SeriesSortOptions = ItemSortBy.DATE_LAST_CONTENT_ADDED, ItemSortBy.DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RANDOM, ) @@ -53,19 +58,27 @@ val SeriesSortOptions = val EpisodeSortOptions = listOf( ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, ItemSortBy.AIRED_EPISODE_ORDER, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, + ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, ) val VideoSortOptions = listOf( ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, @@ -80,6 +93,7 @@ val PlaylistSortOptions = ItemSortBy.DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, @@ -180,3 +194,31 @@ fun getStringRes(sort: ItemSortBy): Int = else -> throw IllegalArgumentException("Unsupported sort option: $sort") } + +@Composable +fun rememberSortOptions(collectionType: CollectionType): List = + remember(collectionType) { + when (collectionType) { + CollectionType.MOVIES -> MovieSortOptions + CollectionType.TVSHOWS -> SeriesSortOptions + CollectionType.MUSIC -> AlbumSortOptions + CollectionType.BOXSETS -> BoxSetSortOptions + else -> VideoSortOptions + } + } + +@Composable +fun rememberSortOptions(type: BaseItemKind): List = + remember(type) { + when (type) { + BaseItemKind.MOVIE -> MovieSortOptions + BaseItemKind.SERIES -> SeriesSortOptions + BaseItemKind.SEASON -> SeriesSortOptions + BaseItemKind.EPISODE -> EpisodeSortOptions + BaseItemKind.AUDIO -> SongSortOptions + BaseItemKind.MUSIC_ALBUM -> AlbumSortOptions + BaseItemKind.MUSIC_ARTIST -> ArtistSortOptions + BaseItemKind.BOX_SET -> BoxSetSortOptions + else -> VideoSortOptions + } + } 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 564ac66a..6651ae9d 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 @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType @Composable fun CollectionFolderMovie( @@ -173,6 +174,7 @@ fun CollectionFolderMovie( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MOVIE), + collectionType = CollectionType.MOVIES, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 421e7ad3..274e8343 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -53,6 +53,7 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID @@ -267,6 +268,7 @@ fun CollectionFolderMusic( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + collectionType = CollectionType.MUSIC, modifier = Modifier .fillMaxSize() 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 af75b638..35875351 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 @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType @Composable fun CollectionFolderTv( @@ -148,6 +149,7 @@ fun CollectionFolderTv( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.SERIES), + collectionType = CollectionType.TVSHOWS, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index e2288281..20351c57 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -73,6 +73,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -299,6 +300,7 @@ fun HomeRowGrid( includeItemTypes = null, modifier = Modifier.fillMaxSize(), initialPosition = destination.initialPosition, + collectionType = CollectionType.UNKNOWN, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 761978ef..206ce221 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -106,6 +106,7 @@ sealed class Destination( val parentType: BaseItemKind, val filter: CollectionFolderFilter, val recursive: Boolean, + val collectionType: CollectionType, ) : Destination(false) @Serializable 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 15c95717..0b10a1fd 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 @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.rememberSortOptions import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie @@ -286,6 +287,7 @@ fun DestinationContent( BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") }, + sortOptions = rememberSortOptions(destination.collectionType), modifier = modifier, ) } From 7660c53e3a30677ebae6924d3c3ae3daecfe41a3 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 30 May 2026 16:56:52 -0400 Subject: [PATCH 002/475] Don't show home row w/ 404 Not Found (#1487) ## Description If a home page row returns 404 Not Found, treat it as empty and don't show it. It will still show as an error on home row settings, since this page doesn't hide empty rows. Also fixes a bug where you can't scroll up if there are 3+ empty rows before the current row. ### Related issues Closes #1469 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/main/HomePage.kt | 17 +++++++++------- .../wholphin/ui/main/HomeViewModel.kt | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) 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 4aa52ade..098da3e2 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 @@ -361,7 +361,7 @@ fun HomePageContent( ) { LazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), contentPadding = PaddingValues( bottom = Cards.height2x3, @@ -371,6 +371,10 @@ fun HomePageContent( .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> + val rowModifier = + Modifier + .animateItem() + .padding(bottom = 8.dp) CompositionLocalProvider( LocalBringIntoViewSpec provides defaultBringIntoViewSpec, ) { @@ -381,7 +385,7 @@ fun HomePageContent( FocusableItemRow( title = r.title.getString(), subtitle = stringResource(R.string.loading), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } @@ -390,7 +394,7 @@ fun HomePageContent( title = r.title.getString(), subtitle = r.localizedMessage, isError = true, - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } @@ -422,11 +426,10 @@ fun HomePageContent( } }, modifier = - Modifier + rowModifier .fillMaxWidth() .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), + .focusRequester(rowFocusRequesters[rowIndex]), horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> val onFocus = @@ -502,7 +505,7 @@ fun HomePageContent( FocusableItemRow( title = r.title.getString(), subtitle = stringResource(R.string.no_results), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } } 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 5ce045fd..d6dd37e2 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 @@ -42,6 +42,7 @@ import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.util.UUID @@ -129,6 +130,25 @@ class HomeViewModel limit = prefs.maxItemsPerRow, isRefresh = refresh, ) + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + Timber.w(ex, "404 on row %s", row) + HomeRowLoadingState.Success( + row.title, + emptyList(), + ) + } else { + Timber.e( + ex, + "Error %s on row %s", + ex.status, + row, + ) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } } catch (ex: Exception) { Timber.e(ex, "Error on row %s", row) HomeRowLoadingState.Error( From 68836bb8346c7f05f12745a063b1053808950ab1 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 30 May 2026 17:41:27 -0400 Subject: [PATCH 003/475] Fix click/long click issues on some view more grid pages (#1486) ## Description On a library recommended tab, if you clicked View More, clicking a card on the grid would directly play the item instead of the regular behavior. This PR fixes that and simplifies the code required to support simple context menus. ### Related issues Fixes #1480 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 4 + .../ui/components/CollectionFolderView.kt | 126 +++----------- .../ui/components/ContextMenuUtils.kt | 163 ++++++++++++++++++ .../wholphin/ui/components/ItemGrid.kt | 120 ++++++++++++- .../wholphin/ui/detail/HomeRowGrid.kt | 149 +++------------- .../wholphin/ui/nav/DestinationContent.kt | 1 + 6 files changed, 334 insertions(+), 229 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt 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 18337e1b..9d3fdfa1 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 @@ -198,6 +198,10 @@ data class BaseItem( */ fun destination(index: Int? = null): Destination { if (destinationOverride != null) return destinationOverride + if (data.extraType != null || type == BaseItemKind.TRAILER) { + // Extras including trailers should always play directly + return Destination.Playback(id, 0) + } val result = // Redirect episodes & seasons to their series if possible when (type) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index ffb9cf92..091edd21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -53,11 +53,7 @@ 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.SlimItemFields -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault @@ -95,7 +91,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest @@ -128,7 +123,8 @@ class CollectionFolderViewModel @Assisted private val collectionFilter: CollectionFolderFilter, @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, @Assisted defaultViewOptions: ViewOptions, - ) : ViewModel() { + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { fun create( @@ -461,25 +457,29 @@ class CollectionFolderViewModel result.totalRecordCount } - fun setWatched( + override fun setWatched( position: Int, itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (state.value.items as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } - fun setFavorite( + override fun setFavorite( position: Int, itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (state.value.items as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } @@ -489,7 +489,7 @@ class CollectionFolderViewModel } } - fun navigateTo(destination: Destination) { + override fun navigateTo(destination: Destination) { release() navigationManager.navigateTo(destination) } @@ -509,7 +509,7 @@ class CollectionFolderViewModel } } - fun deleteItem( + override fun deleteItem( index: Int, item: BaseItem, ) { @@ -520,7 +520,7 @@ class CollectionFolderViewModel } } - fun canDelete( + override fun canDelete( item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) @@ -529,6 +529,10 @@ class CollectionFolderViewModel item: BaseItem, index: Int, ) = addToQueue(api, musicService, item, index) + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true } data class CollectionFolderState( @@ -637,7 +641,6 @@ fun CollectionFolderView( useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, focusRequesterOnEmpty: FocusRequester? = null, - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel( key = viewModelKey, @@ -655,41 +658,9 @@ fun CollectionFolderView( val state by viewModel.state.collectAsState() var position by rememberInt(viewModel.position) - var showContextMenu by remember { mutableStateOf(null) } - var overviewDialog by remember { mutableStateOf(null) } - var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() + val contextMenu = rememberContextMenu(preferences, viewModel) var showViewOptions by rememberSaveable { mutableStateOf(false) } - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(viewModel.position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(viewModel.position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(viewModel.position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - } - val gridActions = remember(actions) { GridClickActions( @@ -702,18 +673,7 @@ fun CollectionFolderView( if (actions.onLongClickItem != null) { actions.onLongClickItem.invoke(index, item) } else { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) + contextMenu.showContextMenu(index, item) } }, onClickPlayAll = @@ -912,41 +872,7 @@ fun CollectionFolderView( } } } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } + contextMenu.Compose() AnimatedVisibility(showViewOptions) { ViewOptionsDialog( viewOptions = state.viewOptions, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt new file mode 100644 index 00000000..f588851f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.ui.components + +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.MediaType +import java.util.UUID + +/** + * Simplifies showing a basic context menu + */ +@Composable +fun rememberContextMenu( + preferences: UserPreferences, + provider: ContextMenuProvider, +): ContextMenuUtils = remember(preferences, provider) { ContextMenuUtils(preferences, provider) } + +class ContextMenuUtils( + private val preferences: UserPreferences, + private val provider: ContextMenuProvider, +) { + private var showContextMenu by mutableStateOf?>(null) + + /** + * Show the context menu, typically from a long click + */ + fun showContextMenu( + position: Int, + item: BaseItem, + ) { + showContextMenu = Pair(position, item) + } + + /** + * Composes the context menu when needed + */ + @Composable + fun Compose(playlistViewModel: AddPlaylistViewModel = hiltViewModel()) { + var overviewDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = provider.isAdministrator(), + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { (position, item) -> + val contextActions = + remember { + ContextMenuActions( + navigateTo = provider::navigateTo, + onClickWatch = { itemId, watched -> + provider.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + provider.setFavorite(position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = provider::sendReportFor, + onDeleteItem = { provider.deleteItem(position, it) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + ) + } + val contextMenu = + remember { + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = provider.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + } + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + } +} + +interface ContextMenuProvider { + fun isAdministrator(): Boolean + + fun navigateTo(destination: Destination) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean + + fun deleteItem( + index: Int, + item: BaseItem, + ) + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) + + fun sendReportFor(itemId: UUID) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 65329026..d4609d79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -7,49 +8,73 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.preferences.UserPreferences +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.cards.GridCard +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.detail.CardGrid +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.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RequestHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber +import java.util.UUID @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) class ItemGridViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, private val navigationManager: NavigationManager, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val mediaReportService: MediaReportService, @Assisted private val destination: Destination.ItemGrid<*>, - ) : ViewModel() { - private val _state = MutableStateFlow(ItemGridState()) - val state: StateFlow = _state - + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel } + private val _state = MutableStateFlow(ItemGridState()) + val state: StateFlow = _state + init { viewModelScope.launchIO { try { @@ -77,9 +102,78 @@ class ItemGridViewModel } } - fun navigateTo(destination: Destination) { + override fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s", it) + 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", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) } data class ItemGridState( @@ -91,13 +185,17 @@ data class ItemGridState( */ @Composable fun ItemGrid( + preferences: UserPreferences, destination: Destination.ItemGrid<*>, modifier: Modifier = Modifier, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: ItemGridViewModel = hiltViewModel( creationCallback = { it.create(destination) }, ), ) { + val contextMenu = rememberContextMenu(preferences, viewModel) + val state by viewModel.state.collectAsState() when (val st = state.items) { is DataLoadingState.Error -> { @@ -111,20 +209,25 @@ fun ItemGrid( } is DataLoadingState.Success> -> { + var position by rememberInt(destination.initialPosition) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column(modifier = modifier) { GridTitle(destination.title.getString()) CardGrid( pager = st.data, onClickItem = { index: Int, item: BaseItem -> - // TODO handle more types - viewModel.navigateTo(Destination.Playback(item.id, 0)) + position = index + viewModel.navigateTo(item.destination(index)) + }, + onLongClickItem = { index: Int, item: BaseItem -> + position = index + contextMenu.showContextMenu(index, item) }, - onLongClickItem = { index: Int, item: BaseItem -> }, onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, @@ -148,4 +251,5 @@ fun ItemGrid( } } } + contextMenu.Compose() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 20351c57..51c496dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -10,17 +10,13 @@ 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.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig @@ -40,23 +36,17 @@ 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.cards.GridCard -import com.github.damontecres.wholphin.ui.components.ContextMenu -import com.github.damontecres.wholphin.ui.components.ContextMenuActions -import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.StudioCardGrid -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.components.rememberContextMenu 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.playback.scale -import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider @@ -74,7 +64,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -97,7 +86,8 @@ class HomeRowGridViewModel val mediaReportService: MediaReportService, @Assisted private val title: StringProvider, @Assisted private val rowConfig: HomeRowConfig, - ) : ViewModel() { + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { fun create( @@ -171,44 +161,52 @@ class HomeRowGridViewModel } } - fun setWatched( + override fun setWatched( position: Int, itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (state.value.loading as? HomeRowLoadingState.Success)?.let { - (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } - fun setFavorite( + override fun setFavorite( position: Int, itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (state.value.loading as? HomeRowLoadingState.Success)?.let { - (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) } } - fun navigateTo(destination: Destination) { + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } - fun canDelete( + override fun canDelete( item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) - fun deleteItem( + override fun deleteItem( index: Int, item: BaseItem, ) { @@ -234,47 +232,12 @@ fun HomeRowGrid( hiltViewModel( creationCallback = { it.create(destination.title, destination.config) }, ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - var position by rememberInt(destination.initialPosition) + val contextMenu = rememberContextMenu(preferences, viewModel) val gridFocusRequester = remember { FocusRequester() } val viewOptions = destination.config.viewOptions - var showContextMenu by remember { mutableStateOf(null) } - var overviewDialog by remember { mutableStateOf(null) } - var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - } - Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -320,32 +283,11 @@ fun HomeRowGrid( viewModel.navigateTo(item.destination(index)) } } - val onLongClickItem = - remember { - { index: Int, item: BaseItem -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), - canRemoveContinueWatching = false, - canRemoveNextUp = false, // TODO - actions = contextActions, - ) - } - } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( pager = st.items, onClickItem = onClickItem, - onLongClickItem = onLongClickItem, + onLongClickItem = contextMenu::showContextMenu, onClickPlay = { _, _ -> }, letterPosition = { -1 }, gridFocusRequester = gridFocusRequester, @@ -354,7 +296,6 @@ fun HomeRowGrid( modifier = Modifier.fillMaxSize(), initialPosition = destination.initialPosition, positionCallback = { columns, newPosition -> - position = newPosition }, cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( @@ -378,39 +319,5 @@ fun HomeRowGrid( } } } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } + contextMenu.Compose() } 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 0b10a1fd..3e5ab947 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 @@ -314,6 +314,7 @@ fun DestinationContent( is Destination.ItemGrid<*> -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( + preferences, destination, modifier, ) From 6c64470c28e9ea7d549c8c67734cbdbbb8b7f20d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 30 May 2026 17:41:44 -0400 Subject: [PATCH 004/475] Only show view more card if row has more items (#1493) ## Description Only show the view more card at the end of row if there are actually more items (or at the limit). Also reduces the size of the arrow a bit ### Related issues Related to #1392 ### Testing Emulator with different max items setting ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 23 ++++++++++++++----- .../wholphin/ui/cards/ViewMoreCard.kt | 7 ++++-- .../ui/components/RecommendedContent.kt | 10 +++++++- .../detail/collection/CollectionViewModel.kt | 6 ++++- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- .../damontecres/wholphin/util/LoadingState.kt | 1 + 6 files changed, 38 insertions(+), 11 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 50c8ae83..da0a7ab6 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 @@ -592,6 +592,7 @@ class HomeSettingsService items = resume, viewOptions = row.viewOptions, rowType = row, + showViewMore = resume.size >= limit, ) } @@ -611,6 +612,7 @@ class HomeSettingsService items = nextUp, viewOptions = row.viewOptions, rowType = row, + showViewMore = nextUp.size >= limit, ) } @@ -631,17 +633,14 @@ class HomeSettingsService prefs.maxDaysNextUp, row.viewOptions.useSeries, ) + val combined = latestNextUpService.buildCombined(resume, nextUp) Success( title = ResStringProvider(R.string.continue_watching), - items = - latestNextUpService - .buildCombined( - resume, - nextUp, - ).take(limit), + items = combined.take(limit), viewOptions = row.viewOptions, rowType = row, + showViewMore = combined.size >= limit, ) } @@ -705,6 +704,7 @@ class HomeSettingsService genres, viewOptions = row.viewOptions, rowType = row, + showViewMore = genres.size >= limit, ) } @@ -756,6 +756,7 @@ class HomeSettingsService title, studios, viewOptions = row.viewOptions, + showViewMore = studios.size >= limit, ) } @@ -784,6 +785,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } latest @@ -838,6 +840,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -891,6 +894,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -928,6 +932,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -957,6 +962,7 @@ class HomeSettingsService title, it, row.viewOptions, + showViewMore = it.size >= limit, ) } } else { @@ -988,6 +994,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -1023,6 +1030,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -1049,6 +1057,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -1079,6 +1088,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -1111,6 +1121,7 @@ class HomeSettingsService suggestions.items, row.viewOptions, rowType = row, + showViewMore = suggestions.items.size >= limit, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt index 56748473..32265e70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt @@ -95,12 +95,15 @@ fun ViewMoreCard( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), ), ) { - Box { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { Icon( imageVector = Icons.Default.ArrowForward, tint = MaterialTheme.colorScheme.onSurface, contentDescription = "View more", - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(.66f), ) } } 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 88e86197..dd264d0f 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 @@ -120,10 +120,12 @@ class RecommendedViewModel viewModelScope.launchIO { val result = try { + val items = execute(row, limit) HomeRowLoadingState.Success( title, - execute(row, limit), + items, viewOptions, + showViewMore = items.size >= limit, ) } catch (ex: Exception) { Timber.e(ex, "Exception fetching %s", title) @@ -172,6 +174,10 @@ class RecommendedViewModel private fun fetchSuggestions() { viewModelScope.launch(Dispatchers.IO) { + val limit = + userPreferencesService + .getCurrent() + .appPreferences.homePagePreferences.maxItemsPerRow val title = ResStringProvider(R.string.suggestions) try { suggestionService @@ -188,6 +194,7 @@ class RecommendedViewModel title, resource.items, viewOptions, + showViewMore = resource.items.size >= limit, ) } @@ -196,6 +203,7 @@ class RecommendedViewModel title, emptyList(), viewOptions, + showViewMore = false, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index f5cbd741..0cc58bea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -214,7 +214,11 @@ class CollectionViewModel val result = try { val pager = fetchItems(sort, filter, listOf(type)) - HomeRowLoadingState.Success(title, pager) + HomeRowLoadingState.Success( + title, + pager, + showViewMore = false, + ) } catch (ex: Exception) { Timber.e( ex, 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 098da3e2..1ccfb429 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 @@ -472,7 +472,7 @@ fun HomePageContent( .onKeyEvent { onKey(it) }, ) }, - showViewMore = showViewMore, + showViewMore = showViewMore && row.showViewMore, viewMoreCardContent = { mod -> HomePageViewMoreCard( isEpisode = row.items.last()?.type == BaseItemKind.EPISODE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index f42eb6ac..7c618290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -67,6 +67,7 @@ sealed interface HomeRowLoadingState { val items: List, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val rowType: HomeRowConfig? = null, + val showViewMore: Boolean = true, ) : HomeRowLoadingState data class Error( From 5ae452155ed8bc261ab7ba880deaa00b0d5b3e95 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 3 Jun 2026 12:40:53 -0400 Subject: [PATCH 005/475] Fix showing live tv programs on home page (#1504) ## Description Fixes the inconsistent live tv programs shown on the home page by using a different API endpoint & filtering. Also some UI fixes: - Use the "Watch live" string consistently for the row instead of it sometimes switching to "Live TV" - Adjust the "Ends at" in the home page header to show the actual end time - Show the channel name in "quick details" - Show season & episode number, if applicable, in "quick details" ### Related issues Fixes #1080 ### Testing Nvidia shield w/ fake EPG data ## Screenshots N/A, just UI fixes, nothing new ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 18 ++++++- .../wholphin/services/HomeSettingsService.kt | 53 ++++++++++++------- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../wholphin/ui/components/QuickDetails.kt | 32 ++++++++++- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 1 + .../damontecres/wholphin/ui/main/HomePage.kt | 5 +- .../main/settings/HomeLibraryRowTypeList.kt | 2 +- 7 files changed, 88 insertions(+), 25 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 9d3fdfa1..4b3c248f 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 @@ -92,7 +92,12 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks + val timeRemainingOrRuntime: Duration? + get() = + when (type) { + BaseItemKind.PROGRAM -> null + else -> data.timeRemaining ?: data.runTimeTicks?.ticks + } /** * Contains pre computed UI elements that would be expensive to create on the main thread @@ -137,6 +142,17 @@ data class BaseItem( } else if (type == BaseItemKind.BOX_SET) { data.productionYear?.let { add(it.toString()) } data.childCount?.let { add("$it items") } + } else if (type == BaseItemKind.PROGRAM) { + data.channelName?.let(::add) + if (data.isSeries == true) { + // TV episode + data.seasonEpisode?.let(::add) + data.premiereDate?.let { + add(getDateFormatter().format(it)) + } + } else { + data.productionYear?.let { add(it.toString()) } + } } else { data.productionYear?.let { add(it.toString()) } } 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 da0a7ab6..7a0dcc9b 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 @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ProgramItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library @@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetProgramsDtoHandler import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -54,9 +56,11 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -66,7 +70,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber @@ -281,7 +284,7 @@ class HomeSettingsService if (it.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { @@ -367,7 +370,7 @@ class HomeSettingsService if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { @@ -520,7 +523,7 @@ class HomeSettingsService is HomeRowConfig.TvPrograms -> { HomeRowConfigDisplay( id = id, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config, ) } @@ -1037,29 +1040,39 @@ class HomeSettingsService is HomeRowConfig.TvPrograms -> { val request = - GetRecommendedProgramsRequest( + GetProgramsDto( userId = userDto.id, - fields = DefaultItemFields, + fields = ProgramItemFields, limit = limit, enableUserData = true, enableImages = true, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), imageTypeLimit = 1, + isAiring = true, + minEndDate = DateTime.now().plusMinutes(1), ) - // paging not supported - api.liveTvApi - .getRecommendedPrograms(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - ResStringProvider(R.string.live_tv), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetProgramsDtoHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.watch_live), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } } is HomeRowConfig.TvChannels -> { 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 ab0f69f8..8ec10cea 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 @@ -83,6 +83,8 @@ val PhotoItemFields = ItemFields.HEIGHT, ) +val ProgramItemFields = DefaultItemFields + listOf(ItemFields.CHANNEL_INFO) + object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 2c30f337..e9f34133 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString @@ -29,6 +28,7 @@ import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import org.jellyfin.sdk.model.DateTime import kotlin.time.Duration @Composable @@ -37,6 +37,7 @@ fun QuickDetails( timeRemaining: Duration?, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, + endsAt: DateTime? = null, ) { val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val inlineContentMap = rememberQuickDetailsContentMap(textStyle) @@ -53,7 +54,11 @@ fun QuickDetails( QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) } } - timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + if (timeRemaining != null) { + TimeRemaining(timeRemaining, textStyle = textStyle) + } else if (endsAt != null) { + EndsAt(endsAt, textStyle = textStyle) + } } } @@ -150,3 +155,26 @@ fun TimeRemaining( modifier = modifier, ) } + +@Composable +fun EndsAt( + endsAt: DateTime, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val resources = LocalResources.current + val remainingStr = + remember(endsAt, resources) { + val endTimeStr = getTimeFormatter().format(endsAt) + buildAnnotatedString { + dot() + append(resources.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = remainingStr, + style = textStyle, + maxLines = 1, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 6751a154..5825cc13 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -300,6 +300,7 @@ fun SeerrDiscoverPage( overviewTwoLines = true, quickDetails = details, timeRemaining = null, + endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, logoImageUrl = null, // TODO modifier = 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 1ccfb429..db76fb3e 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 @@ -88,6 +88,7 @@ import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber @@ -550,6 +551,7 @@ fun HomePageHeader( overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, timeRemaining = item?.timeRemainingOrRuntime, + endsAt = item?.data?.endDate, showLogo = showLogo, logoImageUrl = rememberLogoUrl(item), modifier = modifier, @@ -564,6 +566,7 @@ fun HomePageHeader( overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, timeRemaining: Duration?, + endsAt: DateTime?, showLogo: Boolean, logoImageUrl: String?, modifier: Modifier = Modifier, @@ -587,7 +590,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) val overviewModifier = Modifier .padding(0.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 8c603911..1e5fe4ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -136,7 +136,7 @@ enum class LibraryRowType( GENRES(R.string.genres), STUDIOS(R.string.studios), TV_CHANNELS(R.string.channels), - TV_PROGRAMS(R.string.live_tv), + TV_PROGRAMS(R.string.watch_live), RECENTLY_RECORDED(R.string.recently_recorded), COLLECTION(R.string.collection), PLAYLIST(R.string.playlist), From 471dbd62db3b00a39567e1cbb1ae2acd42607a69 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 3 Jun 2026 12:41:13 -0400 Subject: [PATCH 006/475] Fixes a crash during playback (#1514) ## Description Fixes a crash that could occur during the beginning of playback Also adds better handling for clicking next extremely fast ### Technical details The playlist objects would be created on the IO thread and it had a `mutableIntStateOf` to track the current index. This meant that there is a race conditions for the IO thread to commit the snapshot for the main thread to see them. If the main thread is faster, it will crash. To fix that, this PR moves the mutable state in `Playlist` into the `PlaybackState` flow in the view model. This enures both the background threads and re-compositions have the right state. ### Related issues Fixes #1509 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Playlist.kt | 34 +------ .../wholphin/services/PlaylistCreator.kt | 8 +- .../wholphin/ui/playback/PlaybackPage.kt | 10 +- .../wholphin/ui/playback/PlaybackState.kt | 17 +++- .../wholphin/ui/playback/PlaybackViewModel.kt | 93 ++++++++++++------- .../ui/playback/overlay/ChapterRowOverlay.kt | 5 +- .../ui/playback/overlay/PlaybackOverlay.kt | 14 +-- .../ui/playback/overlay/QueueRowOverlay.kt | 7 +- 8 files changed, 99 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 3e3cbe18..68771f6a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.data.model -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.setValue import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -11,36 +8,9 @@ import java.util.UUID * * This is not the same thing as a Jellyfin server playlist */ -class Playlist( - items: List, - startIndex: Int = 0, +data class Playlist( + val items: List, ) { - val items = items.subList(startIndex, items.size) - - var index by mutableIntStateOf(0) - - fun hasPrevious(): Boolean = index > 0 - - fun hasNext(): Boolean = (index + 1) < items.size - - fun getPreviousAndReverse(): PlaylistItem = items[--index] - - fun getAndAdvance(): PlaylistItem = items[++index] - - fun peek(): PlaylistItem? = items.getOrNull(index + 1) - - fun upcomingItems(): List = items.subList(index + 1, items.size) - - fun advanceTo(id: UUID): PlaylistItem? { - while (hasNext()) { - val potential = getAndAdvance() - if (potential.id == id) { - return potential - } - } - return null - } - companion object { const val MAX_SIZE = 100 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index bf5a1269..055c9186 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -78,7 +78,7 @@ class PlaylistCreator .convertAndAddParts(false) val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes, startIndex) + return Playlist(episodes.subList(startIndex, episodes.size)) } /** @@ -104,7 +104,7 @@ class PlaylistCreator ), ) val items = GetItemsRequestHandler.execute(api, request).content.items - return Playlist(items.convertAndAddParts(), 0) + return Playlist(items.convertAndAddParts()) } private suspend fun createFromCollection( @@ -147,7 +147,7 @@ class PlaylistCreator .execute(api, request) .content.items .convertAndAddParts() - return Playlist(items, 0) + return Playlist(items) } /** @@ -263,7 +263,7 @@ class PlaylistCreator }.let(::addAll) } } - PlaylistCreationResult.Success(Playlist(list, 0)) + PlaylistCreationResult.Success(Playlist(list)) } // Not support yet 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 d25375a2..6fe79290 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 @@ -287,7 +287,7 @@ fun PlaybackPageContent( PlaybackAction.Previous -> { val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) { + if (pos < player.maxSeekToPreviousPosition && state.hasPrevious) { viewModel.playPrevious() } else { player.seekToPrevious() @@ -401,7 +401,7 @@ fun PlaybackPageContent( controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, - nextEnabled = state.playlist.hasNext(), + nextEnabled = state.hasNext, seekEnabled = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -414,10 +414,8 @@ fun PlaybackPageContent( chapters = state.currentMediaInfo.chapters, trickplayInfo = state.currentMediaInfo.trickPlayInfo, trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = state.playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, + queue = remember(state.playlistIndex, state.playlist) { state.upcomingItems }, + onClickPlaylist = viewModel::playItemInPlaylist, currentSegment = state.currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, analyticsState = state.analyticsState, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index a520df2e..b42b7151 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -6,6 +6,7 @@ import androidx.media3.common.text.Cue import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.util.LoadingState @@ -21,8 +22,20 @@ data class PlaybackState( val analyticsState: AnalyticsState = AnalyticsState(), val subtitleCues: List = emptyList(), val nextUp: BaseItem? = null, - val playlist: Playlist = Playlist(listOf()), -) + val playlistIndex: Int = 0, + val playlist: Playlist = Playlist(emptyList()), +) { + val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size + val hasPrevious: Boolean get() = playlistIndex > 0 + val upcomingItems: List + get() = + playlist.items.subList( + (playlistIndex + 1).coerceAtMost(playlist.items.size), + playlist.items.size, + ) + + fun nextItem(): PlaylistItem? = playlist.items.getOrNull(playlistIndex + 1) +} data class PlayerInstance( val player: Player, 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 631aacc4..696e2eb9 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 @@ -92,6 +92,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -164,7 +165,10 @@ class PlaybackViewModel internal lateinit var player: Player private var mediaSession: MediaSession? = null - internal val mutex = Mutex() + + // Mutex & Job for changing playlist index + private val playlistMutex = Mutex() + private var playlistJob: Job? = null val controllerViewState = ControllerViewState( @@ -345,7 +349,6 @@ class PlaybackViewModel viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - playlistItem.item.type == BaseItemKind.TRAILER val intros = // If not resuming playback & cinema mode is enabled, get potential intros if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) { @@ -1017,7 +1020,7 @@ class PlaybackViewModel if (playbackState == Player.STATE_ENDED) { Timber.v("Playback state is STATE_ENDED") viewModelScope.launchDefault { - when (val nextItem = state.value.playlist.peek()) { + when (val nextItem = state.value.nextItem()) { is PlaylistItem.Intro -> { Timber.v("Next item is intro, so playing immediately") playNextUp() @@ -1093,14 +1096,14 @@ class PlaybackViewModel currentSegment.type, ) } - val playlist = state.value.playlist + val state = state.value if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist.hasNext() && + state.hasNext && outroShownSegments.add(currentSegment.id) ) { - val nextItem = playlist.peek() + val nextItem = state.nextItem() if (nextItem is PlaylistItem.Media) { Timber.v("Setting next up during outro to ${nextItem?.id}") _state.update { it.copy(nextUp = nextItem.item) } @@ -1218,30 +1221,50 @@ class PlaybackViewModel } fun playNextUp() { - state.value.playlist.let { - if (it.hasNext()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasNext) { cancelUpNextEpisode() - val item = it.getAndAdvance() - val played = play(item, 0) - if (!played) { - playNextUp() - } + val nextIndex = state.playlistIndex + 1 + val item = state.playlist.items[nextIndex] + _state.update { it.copy(playlistIndex = nextIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playNextUp() + } + } + } else { + Timber.w("Attempting to play next, but there are no more items") + return@launchDefault } } } } fun playPrevious() { - state.value.playlist.let { - if (it.hasPrevious()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasPrevious) { cancelUpNextEpisode() - val item = it.getPreviousAndReverse() - val played = play(item, 0) - if (!played) { - playPrevious() - } + val previousIndex = state.playlistIndex - 1 + val item = state.playlist.items[previousIndex] + _state.update { it.copy(playlistIndex = previousIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playPrevious() + } + } + } else { + Timber.w("Attempting to play next, but there are no more items") + return@launchDefault } } } @@ -1252,16 +1275,24 @@ class PlaybackViewModel } fun playItemInPlaylist(item: BaseItem) { - state.value.playlist.let { playlist -> - viewModelScope.launchIO { - val toPlay = playlist.advanceTo(item.id) - if (toPlay != null) { - val played = play(toPlay, 0) - if (!played) { - playNextUp() - } + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + val index = state.playlist.items.indexOfFirst { it.id == item.id } + if (index in state.playlist.items.indices) { + val toPlay = state.playlist.items[index] + _state.update { it.copy(playlistIndex = index) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(toPlay, 0) + if (!played) { + playNextUp() + } + } } else { - // TODO + Timber.w("Item not found in playlist %s", item.id) + return@launchDefault } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt index 30254061..45e335ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -31,7 +31,6 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -44,7 +43,7 @@ fun ChapterRowOverlay( player: Player, controllerViewState: ControllerViewState, chapters: List, - playlist: Playlist, + hasNext: Boolean, aspectRatio: Float, onChangeState: (OverlayViewState) -> Unit, modifier: Modifier = Modifier, @@ -147,7 +146,7 @@ fun ChapterRowOverlay( ) } } - if (playlist.hasNext()) { + if (hasNext) { Text( text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 96adc4df..2c2aefe8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -50,7 +50,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios @@ -91,10 +91,10 @@ fun PlaybackOverlay( currentPlayback: CurrentPlayback?, currentSegment: MediaSegmentDto?, analyticsState: AnalyticsState, + queue: List, modifier: Modifier = Modifier, trickplayInfo: TrickplayInfo? = null, trickplayUrlFor: (Int) -> String? = { null }, - playlist: Playlist = Playlist(listOf(), 0), onClickPlaylist: (BaseItem) -> Unit = {}, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { @@ -180,10 +180,10 @@ fun PlaybackOverlay( } } val nextState = - remember(chapters, playlist) { + remember(chapters, nextEnabled) { if (chapters.isNotEmpty()) { OverlayViewState.CHAPTERS - } else if (playlist.hasNext()) { + } else if (nextEnabled) { OverlayViewState.QUEUE } else { null @@ -225,7 +225,7 @@ fun PlaybackOverlay( player = player, controllerViewState = controllerViewState, chapters = chapters, - playlist = playlist, + hasNext = nextEnabled, aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, onChangeState = onChangeState, modifier = @@ -237,9 +237,9 @@ fun PlaybackOverlay( } OverlayViewState.QUEUE -> { - if (playlist.hasNext()) { + if (nextEnabled) { QueueRowOverlay( - playlist = playlist, + queue = queue, controllerViewState = controllerViewState, nextState = remember(chapters) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt index a44d1fc7..92b7afd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -22,7 +22,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -31,14 +31,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun QueueRowOverlay( - playlist: Playlist, + queue: List, controllerViewState: ControllerViewState, nextState: OverlayViewState, onChangeState: (OverlayViewState) -> Unit, onClickPlaylist: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { - val items = remember { playlist.upcomingItems() } val focusRequester = remember { FocusRequester() } val hiddenFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -70,7 +69,7 @@ fun QueueRowOverlay( } }, ) { - itemsIndexed(items) { index, item -> + itemsIndexed(queue) { index, item -> val interactionSource = remember { MutableInteractionSource() } val isFocused = From a1b372667b2ca08f581caafbb3b49e520ae40daf Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 3 Jun 2026 20:46:57 -0400 Subject: [PATCH 007/475] Fix highlighting the wrong 'more' nav drawer item (#1515) ## Description Fixes an issue where the wrong nav drawer item would be highlighted in the "More" section Previously, displaying the discover nav item was partially handled in the view model state and partially within the UI code which caused this bug. This PR moves it all into the view model state. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/NavDrawerService.kt | 70 ++++---- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 152 ++++++++---------- .../ui/preferences/NavDrawerPreference.kt | 13 +- 3 files changed, 107 insertions(+), 128 deletions(-) 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 cbf2e955..21253e1e 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 @@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination @@ -14,16 +15,14 @@ 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.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest 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 @@ -52,35 +51,36 @@ class NavDrawerService private val seerrServerRepository: SeerrServerRepository, private val musicService: MusicService, ) { - private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + private val _state = MutableStateFlow(NavDrawerItemState()) val state: StateFlow = _state init { // Handle updating the nav drawer when the user changes - serverRepository.currentUserFlow - .combine(serverRepository.currentUserDtoFlow) { user, userDto -> - Pair(user, userDto) - }.onEach { (user, userDto) -> - Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) - _state.update { - it.copy( - items = emptyList(), - moreItems = emptyList(), - ) - } + combine( + serverRepository.currentUserFlow, + serverRepository.currentUserDtoFlow, + seerrServerRepository.active, + ) { user, userDto, discoverActive -> + Triple(user, userDto, discoverActive) + }.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) -> + Timber.d( + "User updated: user=%s, userDto=%s, discoverActive=%s", + user?.id, + userDto?.id, + discoverActive, + ) + _state.update { NavDrawerItemState() } + try { if (user != null && userDto != null && user.id == userDto.id) { - updateNavDrawer(user, userDto) + updateNavDrawer(user, userDto, discoverActive) } - }.catch { ex -> + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { Timber.e(ex, "Error updating nav drawer") showToast(context, "Error fetching user's views") - }.launchIn(coroutineScope) - - // Handle when the user has logged into a Seerr server - seerrServerRepository.active - .onEach { discoverActive -> - _state.update { it.copy(discoverEnabled = discoverActive) } - }.launchIn(coroutineScope) + } + } // Handle when music is actively playing or not coroutineScope.launchDefault { @@ -178,8 +178,13 @@ class NavDrawerService suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, + discoverActive: Boolean, ) { - val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + val builtins = + buildList { + add(NavDrawerItem.Favorites) + if (discoverActive) add(NavDrawerItem.Discover) + } val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val libraries = allLibraries @@ -235,15 +240,10 @@ class NavDrawerService } data class NavDrawerItemState( - val items: List, - val moreItems: List, - val discoverEnabled: Boolean, - val nowPlayingEnabled: Boolean, - val nowPlayingTitle: String?, -) { - companion object { - val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null) - } -} + val items: List = emptyList(), + val moreItems: List = emptyList(), + val nowPlayingEnabled: Boolean = false, + val nowPlayingTitle: String? = null, +) val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index af4ec63f..0d66fd8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -67,7 +67,6 @@ import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -120,7 +119,6 @@ class NavDrawerViewModel index: Int, item: NavDrawerItem, ) { - if (item !is NavDrawerItem.More) setShowMore(false) when (item) { NavDrawerItem.Favorites -> { setIndex(index) @@ -163,48 +161,40 @@ class NavDrawerViewModel fun updateSelectedIndex() { viewModelScope.launchDefault { val asDestinations = - ( - serviceState.value.items + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + serviceState.value.moreItems - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null + buildList { + addAll(serviceState.value.items) + addAll(serviceState.value.moreItems) + }.map { + when (it) { + is ServerNavDrawerItem -> it.destination + is NavDrawerItem.Favorites -> Destination.Favorites + is NavDrawerItem.Discover -> Destination.Discover + else -> null } } val backstack = navigationManager.backStack.toList().reversed() for (i in 0..= 0) { + idx } else { - val idx = asDestinations.indexOf(key) - if (idx >= 0) { - idx - } else { - null - } + null } - Timber.v("Found $index => $key") - if (index != null) { - _state.update { it.copy(selectedIndex = index) } - break } + Timber.v("Found $index => $key") + if (index != null) { + _state.update { it.copy(selectedIndex = index) } + break } } } @@ -455,25 +445,23 @@ fun NavDrawer( ) } itemsIndexed(serviceState.items) { index, it -> - if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) } if (serviceState.moreItems.isNotEmpty()) { item { @@ -481,7 +469,7 @@ fun NavDrawer( val interactionSource = remember { MutableInteractionSource() } NavItem( library = NavDrawerItem.More, - selected = selectedIndex == index, + selected = false, moreExpanded = moreExpanded, drawerOpen = isOpen, interactionSource = interactionSource, @@ -500,35 +488,33 @@ fun NavDrawer( if (moreExpanded) { itemsIndexed(serviceState.moreItems) { index, it -> val adjustedIndex = - remember(serviceState) { (index + serviceState.items.size + 1) } - if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - onClick = { - viewModel.onClickDrawerItem( - adjustedIndex, - it, - ) + remember(serviceState) { (index + serviceState.items.size) } + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surface.copy(alpha = .5f) + } else { + Color.Unspecified }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - selectedIndex == adjustedIndex, - Modifier.focusRequester(focusRequester), - ), - ) - } + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) } } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index e8c768a4..4f2fb968 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,9 +43,7 @@ import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavDrawerItemState import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository @@ -60,7 +57,6 @@ import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -69,7 +65,6 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import javax.inject.Inject data class NavDrawerPin( @@ -304,11 +299,8 @@ class NavDrawerPreferencesViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - private val api: ApiClient, - val preferenceDataStore: DataStore, val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, @@ -321,7 +313,7 @@ class NavDrawerPreferencesViewModel val state = navDrawerService.state.value val user = serverRepository.currentUser val seerr = seerrServerRepository.active.firstOrNull() - if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + if (user == null || seerr == null) { return@launchDefault } val navDrawerPins = @@ -372,7 +364,8 @@ class NavDrawerPreferencesViewModel ) } serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - navDrawerService.updateNavDrawer(user, userDto) + val discoverActive = seerrServerRepository.active.firstOrNull() == true + navDrawerService.updateNavDrawer(user, userDto, discoverActive) } else { throw IllegalStateException("User IDs do not match") } From a0869ca6e480151f4804c5a4a9296704b94043fc Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 3 Jun 2026 20:47:07 -0400 Subject: [PATCH 008/475] Several minor fixes (#1505) ## Description Collecting some minor fixes together: - Adds error handling when submitting a discover/seerr request, previously there was no visual feedback if the request failed - Fix MpvPlayer reporting that it was buffering forever - Fix discover results row on regular search page not being clickable - Fix focus loss when returning to movie details after playback advanced enough to switch the button from Play to Resume - Fix some focus issues when returning to the TV guide page ### Related issues Related to #1503 for MPV Fixes #1500 Fixes #1508 Fixes #1501 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/PlayButtons.kt | 48 ++++++++--------- .../ui/detail/CollectionFolderLiveTv.kt | 1 + .../detail/discover/DiscoverMovieViewModel.kt | 18 ++++++- .../discover/DiscoverSeriesViewModel.kt | 54 ++++++++++++------- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 39 ++++++++------ .../wholphin/ui/main/SearchPage.kt | 1 + .../wholphin/util/mpv/MpvPlayer.kt | 8 +-- 7 files changed, 103 insertions(+), 66 deletions(-) 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 ca4c11b8..a0c427b9 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 @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh 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 @@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import kotlin.time.Duration @@ -85,6 +87,14 @@ fun ExpandablePlayButtons( modifier: Modifier = Modifier, ) { val firstFocus = remember { FocusRequester() } + val restartInteractionSource = remember { MutableInteractionSource() } + val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState() + LaunchedEffect(resumePosition) { + // Reset focus if the restart button was focuses and it is going to disappear + if (restartButtonFocused && resumePosition == Duration.ZERO) { + firstFocus.tryRequestFocus() + } + } LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -93,19 +103,19 @@ fun ExpandablePlayButtons( .focusGroup() .focusRestorer(firstFocus), ) { + item("play") { + ExpandablePlayButton( + title = if (resumePosition > Duration.ZERO) R.string.resume else R.string.play, + resume = resumePosition, + icon = Icons.Default.PlayArrow, + onClick = playOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), + ) + } if (resumePosition > Duration.ZERO) { - item("play") { - ExpandablePlayButton( - title = R.string.resume, - resume = resumePosition, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), - ) - } item("restart") { ExpandablePlayButton( title = R.string.restart, @@ -114,19 +124,7 @@ fun ExpandablePlayButtons( onClick = playOnClick, modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, - ) - } - } else { - item("play") { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), + interactionSource = restartInteractionSource, ) } } 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 f1e575b3..b6f5b19a 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 @@ -141,6 +141,7 @@ fun CollectionFolderLiveTv( when (selectedTabIndex) { 0 -> { TvGuideGrid( + preferences = preferences, onRowPosition = { showHeader = it <= 0 }, 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 f80457fc..60e6a816 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 @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -168,7 +169,7 @@ class DiscoverMovieViewModel is4k: Boolean, ) { viewModelScope.launchIO { - val request = + try { seerrService.api.requestApi.requestPost( RequestPostRequest( is4k = is4k, @@ -176,6 +177,12 @@ class DiscoverMovieViewModel mediaType = RequestPostRequest.MediaType.MOVIE, ), ) + } catch (ex: kotlinx.coroutines.CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } @@ -185,7 +192,14 @@ class DiscoverMovieViewModel viewModelScope.launchIO { state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: kotlinx.coroutines.CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } 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 fc40b0c4..4a13c65f 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 @@ -20,6 +20,7 @@ import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -209,27 +210,34 @@ class DiscoverSeriesViewModel 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( + try { + 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, - mediaType = RequestRequestIdPutRequest.MediaType.TV, + mediaId = id, + mediaType = RequestPostRequest.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(), - ), - ) + ) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") } fetchAndSetItem().await()?.let { @@ -244,7 +252,15 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } + fetchAndSetItem().await()?.let { updateSeasonStatus(it) updateCanCancel() 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 54e32aa9..55a8c515 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 @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -30,7 +29,6 @@ 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.onFocusChanged import androidx.compose.ui.graphics.Color @@ -43,8 +41,8 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.LiveTvPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams @@ -57,6 +55,7 @@ import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -75,14 +74,14 @@ import kotlin.math.abs @Composable fun TvGuideGrid( + preferences: UserPreferences, onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() - val preferences by viewModel.preferences.data - .collectAsState(AppPreferences.getDefaultInstance()) + val preferences by viewModel.preferences.data.collectAsState(preferences.appPreferences) val tvPrefs = preferences.interfacePreferences.liveTvPreferences var showViewOptions by remember { mutableStateOf(false) } var showChannelDialog by remember { mutableStateOf?>(null) } @@ -196,11 +195,11 @@ fun TvGuideGrid( showItemDialog = index } }, + focusRequester = focusRequester, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .focusRequester(focusRequester), + .background(MaterialTheme.colorScheme.background), ) } } @@ -308,6 +307,7 @@ fun TvGuideGridContent( onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, + focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -315,8 +315,10 @@ fun TvGuideGridContent( val scope = rememberCoroutineScope() val guideStart = remember(guideTimes) { guideTimes.first() } + var focusedChannelIndex by rememberInt(-1) var focusedItem by rememberPosition(RowColumn(0, 0)) - var focusedProgramIndex by remember { mutableIntStateOf(0) } + var focusedProgramIndex by rememberInt(0) + LaunchedEffect(onFocus, focusedProgramIndex) { withContext(Dispatchers.Default) { val program = programs.programs.getOrNull(focusedProgramIndex) @@ -358,16 +360,13 @@ fun TvGuideGridContent( } } - Box(modifier = modifier) { + Box( + modifier = modifier, + ) { ProgramGuide( state = state, dimensions = tvGuideDimensions, - modifier = - Modifier - .fillMaxSize() - .focusProperties { - onEnter = { programFocusRequester.tryRequestFocus() } - }, + modifier = Modifier.fillMaxSize(), ) { guideStartHour = 0f currentTime( @@ -465,8 +464,12 @@ fun TvGuideGridContent( .ifElse( programIndex == focusedProgramIndex, Modifier.focusRequester(programFocusRequester), + ).ifElse( + focusedChannelIndex < 0 && programIndex == focusedProgramIndex, + Modifier.focusRequester(focusRequester), ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = -1 focusedProgramIndex = programIndex scope.launch { try { @@ -497,8 +500,12 @@ fun TvGuideGridContent( onLongClick = {}, modifier = Modifier - .onFocusChanged { + .ifElse( + focusedChannelIndex == channelIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = channelIndex scope.launch { try { state.animateToChannel( 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 9eef0972..6b4df7e1 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 @@ -808,6 +808,7 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onClickDiscover = onClickDiscover, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 1766de82..3057866d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -628,12 +628,11 @@ class MpvPlayer( MPVProperty.PAUSED_FOR_CACHE -> { Timber.v("paused-for-cache %s", value) + val newState = if (value) STATE_BUFFERING else STATE_READY playbackState.update { - it.copy( - state = if (value) STATE_BUFFERING else it.state, - ) + it.copy(state = newState) } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(playbackState.load().state) } + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(newState) } } } } @@ -842,6 +841,7 @@ class MpvPlayer( ) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_READY) } val url = media.mediaItem.localConfiguration ?.uri From 0842c35e341f11ae7ae08a3e5bf2baf0f71107a5 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Fri, 5 Jun 2026 08:54:06 -0500 Subject: [PATCH 009/475] Fix combined search results losing keyboard focus when typing (#1524) ## Description Minor tweak to prevent the keyboard from losing focus when typing search queries when "combine search results" is enabled ### Related issues Fixes #1523 ### Testing Emulator only ## Screenshots N/A ## AI or LLM usage None --------- Co-authored-by: Damontecres --- .../com/github/damontecres/wholphin/ui/main/SearchPage.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 6b4df7e1..c3ff42da 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 @@ -77,7 +77,6 @@ import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard @@ -405,7 +404,7 @@ fun SearchPage( var showViewOptions by rememberSaveable { mutableStateOf(false) } var position by rememberPosition(0, 0) - var searchClicked by rememberSaveable { mutableStateOf(false) } + var searchClicked by rememberSaveable(query) { mutableStateOf(false) } var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } LifecycleResumeEffect(Unit) { @@ -1017,7 +1016,6 @@ private fun SearchGrid( positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, ) { - RequestOrRestoreFocus(focusRequester) Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, From e2a6ae6be4bddfddc99044a2f65179ec1096d823 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 5 Jun 2026 10:01:08 -0400 Subject: [PATCH 010/475] Two more bug fixes (#1521) ## Description Fixes bugs: - Follow up to #1487 since the 404 exception can be ignored in some cases - Fixes a only-on-main-thread exception when clearing the secure flag in some cases ### Related issues Related to #1469 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 24 ++++++++++--------- .../wholphin/services/HomeSettingsService.kt | 15 +++++++++--- 2 files changed, 25 insertions(+), 14 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 80f86954..b5bc119f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -50,7 +50,6 @@ 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.collectLatestIn import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault @@ -169,16 +168,19 @@ class MainActivity : AppCompatActivity() { navigationManager.backStack = NavBackStack(startDestination) } - viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user -> - if (user?.hasPin == true) { - window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - } else { - window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } + viewModel.serverRepository.currentUserFlow + .onEach { user -> + withContext(Dispatchers.Main) { + if (user?.hasPin == true) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + }.catch { ex -> + Timber.e(ex, "Error with settings flag secure") + }.launchIn(lifecycleScope) + screensaverService.keepScreenOn .onEach { keepScreenOn -> Timber.v("keepScreenOn: %s", keepScreenOn) 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 7a0dcc9b..4b03c676 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 @@ -553,8 +553,10 @@ class HomeSettingsService ): StringProvider = try { api.userLibraryApi - .getItem(itemId = itemId) - .content.name + .getItem( + userId = serverRepository.currentUser?.id, + itemId = itemId, + ).content.name ?.let { if (stringRes == null) { StringStringProvider(it) @@ -876,8 +878,15 @@ class HomeSettingsService fields = DefaultItemFields, ) + // Not using getItemName because we want to throw the 404 val title = - getItemName(null, row.parentId, ResStringProvider(R.string.collection)) + api.userLibraryApi + .getItem( + userId = serverRepository.currentUser?.id, + itemId = row.parentId, + ).content.name + ?.let { StringStringProvider(it) } + ?: ResStringProvider(R.string.collection) if (usePaging) { ApiRequestPager( api, From 2cc25d068d93d9ffdb709156455692585e032281 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 27 May 2026 12:39:45 -0400 Subject: [PATCH 011/475] Release v1.0.0 From 7bd555aaa752a11798d6d84bc33a6dd309777fd6 Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Wed, 27 May 2026 20:42:20 +0000 Subject: [PATCH 012/475] Translated using Weblate (Swedish) Currently translated at 86.6% (498 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 128 ++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c84a46c3..1e44bdf8 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -171,7 +171,7 @@ Bekräfta Fortsätt titta Kritikerbetyg - %.1f sekunder + %.2f sekunder Standard Radera Regisserad av %1$s @@ -362,7 +362,7 @@ Skicka apploggar till aktuell server Markera alla Seerr-server tillagd - Logga in på Seerr-server + Logga in på Seerr Lokal användare Jellyfin användare Steg för sökreglage @@ -420,11 +420,133 @@ Fyll Anpassa startsidan Välj rader och bilder på startsidan - Är du säker på att du vill ta bort detta objekt? + Är du säker på att du vill radera detta objekt? Upptäck Wholphin Kompakt Wholphin Standard Visningsförinställningar Inbyggda förinställningar för att snabbt styla alla rader Avsnittsminiatyrer + Fortsätt + Turkos + Purpurröd + Stjärnor + Skugga + AVC + Lägsta + Låg + Medel + Hög + Full + Åskblå + Använd endast FFmpeg om det inte finns någon inbyggd avkodare + Föredra att använda FFmpeg framför inbyggda avkodare + Använd aldrig FFmpeg-avkodare + Aldrig + Kontur + Radbryt + Inramad + Föredra MPV + Extern spelare + Använd ExoPlayer för HDR-uppspelning + Affisch (2:3) + 16:9 + 4:3 + Kvadratisk (1:1) + Rutnät + Lista + Kompakt lista + + Inga externa undertexter hittades + Använd skärmsläckare i appen + Textförfattare + Arrangör + Mixare + Artist + Albumartist + Album + Populära låtar + Spela nästa + Musikvideor + Låttexter + Döjt låttexter + Visa låttexter + Sortera + Undertextfördröjning + Artister + Starta efter + Tillämpa på alla rader + Spela vid klick + Visa detaljer vid klick + Tomater + Dela upp i separata rader + Kontrast + SDR + Gå till album + Lägg till i kön + Album + Lägg till rad för %1$s + Spelas nu + Dold + Ta bort från kön + Kräv inloggning för profil + Ta bort serien från Fortsätt titta + Visa serier som tagits bort från Fortsätt titta + Visa mer + Upptäck TV-serier + Upptäck filmer + + %s dag + %s dagar + + Layout + Kanaler + Resultat + Ignorerar kontroller av enhetskompatibilitet + Pendlar + Spela trailer + Synlig + Kombinerade sökresultat + Visa alla resultat i en enda lista + Visa resultat efter kategori + Visa knapp för röstsökning + Slutar %1$s + Inga servrar hittades + Endast tvingade undertexter + Inga trailrar + + Bildfrekvens + Bitdjup + Videoomfång + Typ av videoomfång + Färgrymd + + Genrer i %1$s + Ta bort från Fortsätt titta + Lokal + Spela med + Omkodning + Språk för användargränssnitt + Visning av betyg + Anpassa vilka som ska visas + Profilskydd + Klickbeteende för Fortsätt titta/kommande avsnitt + Röstsökning + Startar + Tala för att söka + Tidsgräns för nätverk överskreds + Inget tal kändes igen + Röstigenkänning upptagen + Kommer att ta bort PIN-koden + Spela som? + Visa favoritkanaler först + Sortera kanaler efter senaste visade + Favoritmarkera %s + Spela bildspel + Sånger + Föredra att visa logotyper för titlar + Ljusstyrka + Bioläge + Förslag för %1$s + Systemstandard From 0035367c83e8283f751620bf1060b1147ab4375e Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 28 May 2026 09:04:55 +0000 Subject: [PATCH 013/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e79705c3..4523a787 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -603,4 +603,6 @@ 列表 緊湊列表 介面語言 + 排序 + 分開為不同列 From 5375c279ab86d4ca2d24fec7d113dfab43d26079 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 28 May 2026 02:26:03 +0000 Subject: [PATCH 014/475] Translated using Weblate (Italian) Currently translated at 100.0% (575 of 575 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 d81f34a5..4c017645 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -629,4 +629,16 @@ Mostra dettagli al click Stelle Pomodori + Mostra pulsante di ricerca vocale + Visibile + Nascosto + Griglia + Lista + Dividi in righe separate + Lista compatta + Lingua dell\'interfaccia + Visualizzazione valutazioni + Personalizza quali mostrare + Comportamento click su Continua a guardare / Prossimo + Ordina From 5fb207ce085dfbd812f3baa416dfbcde1ac8b797 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Thu, 28 May 2026 10:45:01 +0000 Subject: [PATCH 015/475] Translated using Weblate (Polish) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 625a172b..5eebccae 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -657,4 +657,6 @@ Lista kompaktowa Język interfejsu Siatka + Sortuj + Podziel na osobne rzędy From 5cc9d3697d9ed2788b689edbde1262906aa68b8e Mon Sep 17 00:00:00 2001 From: tal_sarid Date: Fri, 29 May 2026 09:38:09 +0000 Subject: [PATCH 016/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 9acfb772..f2efcdb9 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -613,4 +613,22 @@ הצג את כל התוצאות ברשימה אחת הצג תוצאות לפי קטגוריה תוצאות + דרוש אימות בכניסה לפרופיל + הצג כפתור לחיפוש קולי + מוצג + חבוי + רשת + רשימה + רשימה קומפקטית + שפת ממשק המשתמש + תצוגת דירוגים + התאם אישית מה להציג + אבטחת פרופיל + התנהגות לחיצה על המשך צפייה / הבא בתור + נגן בלחיצה + הצג פרטים בלחיצה + כוכבים + עגבניות + מיון + פצל לשורות נפרדות From ac2e1e8f64cfbab14cc6f2c851ff66ab8710a5eb Mon Sep 17 00:00:00 2001 From: pavel-miniutka Date: Fri, 29 May 2026 21:46:00 +0000 Subject: [PATCH 017/475] Translated using Weblate (Belarusian) Currently translated at 1.5% (9 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/be/ --- app/src/main/res/values-be/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 045e125f..d7509408 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -1,3 +1,6 @@ + Аб праграме + Дадаць сервер + Двдаць карыстальніка From 9e15db41430176d407db9b0940393fe16e4702ba Mon Sep 17 00:00:00 2001 From: nofkclue Date: Mon, 1 Jun 2026 05:22:40 +0000 Subject: [PATCH 018/475] Translated using Weblate (German) Currently translated at 99.8% (574 of 575 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, 2 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 60387734..2bfcf2da 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -621,4 +621,6 @@ Tomaten Zeige Bewertungen Anpassen was angezeigt wird + Sortieren + In unterschiedliche Zeilen aufteilen From 8a5988b95e5e92e7e62cc5934c69c9071646455f Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 1 Jun 2026 05:14:14 +0000 Subject: [PATCH 019/475] Translated using Weblate (French) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d6343b9e..b0c7dcd7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -638,4 +638,6 @@ Afficher les détails en cliquant Étoiles Tomates + Trier + Diviser en lignes distinctes From 33dd630a59c9af0682223aab841c115af1352625 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Tue, 2 Jun 2026 11:45:29 +0000 Subject: [PATCH 020/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 59913f81..fd038556 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -623,4 +623,22 @@ Mostrar todos os resultados numa única lista Mostrar resultados por categoria Resultados + Exigir início de sessão para o perfil + Mostrar botão de pesquisa por voz + Visível + Escondido + Grelha + Lista + Lista Compacta + Língua do interface + Visualização de avaliações + Personalizar o que mostrar + Protecção do perfil + Comportamento ao clicar em «Continuar a ver»/«A Seguir» + Reproduzir ao clicar + Mostrar detalhes ao clicar + Estrelas + Tomates + Ordenar + Dividir em linhas separadas From be1b0c0a3514f7fa41f2dd84542e1d0d4e3695be Mon Sep 17 00:00:00 2001 From: Wit Krittawit Date: Wed, 3 Jun 2026 03:45:42 +0000 Subject: [PATCH 021/475] Translated using Weblate (Thai) Currently translated at 96.8% (557 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index e1ebe0ad..51760b13 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -173,7 +173,7 @@ ลำดับตอนที่ออกอากาศ สร้างเพลย์ลิสต์ใหม่ เพิ่มชื่อเพลย์ลิสต์ - หยุดเกมด้วยการคลิกเพียงครั้งเดียว + หยุดเล่นด้วยการคลิกเพียงครั้งเดียว กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น ตัวเอียง ฟอนต์ From 01081b1985247f7c3dbfae9390e7a34cc1e6c48d Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Thu, 4 Jun 2026 07:38:57 +0000 Subject: [PATCH 022/475] Translated using Weblate (German) Currently translated at 100.0% (575 of 575 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 2bfcf2da..9c784fdb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -605,7 +605,7 @@ Alle Ergebnisse in einer gemeinsamen Liste anzeigen Ergebnisse nach Kategorie anzeigen Ergebnisse - Anmeldung erforderlich für das Profil + Verlange Anmeldung für Profil Sichtbar Versteckt Raster From 99fab9a383af62ed5f897a61125b469342e02d1a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 5 Jun 2026 12:30:39 -0400 Subject: [PATCH 023/475] Release v1.0.1 From d1b8c0961520d7fa8506b3b6bf17a9216df8dd5d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 7 Jun 2026 07:15:30 -0400 Subject: [PATCH 024/475] Add a shortcut to chosing the version of an item (#1522) ## Description Based on feedback from users, many felt it was difficult to 1) tell if multiple version of an item existed and 2) choose the version they want So this PR adds a new expandable button next to Play/Resume/Restart to choose the version. It is a shortcut to the More->Choose Version dialog, but also serves as a more prominent indicator that there are multiple versions. The button is only shown if there are 2+ versions to choose from. Also adds displaying the bitrate in the choose version dialog just in case there are identically named versions with the same resolution. Dev note: I also added some docs for classes & functions related to choosing a version ### Related issues Related to #652 ### Testing Emulator ## Screenshots choose_version_button_unfocused
Large choose_version_button Large ## AI or LLM usage None --- .../wholphin/data/ItemPlaybackRepository.kt | 35 +++++++++++++++ .../wholphin/ui/components/ContextMenu.kt | 2 + .../wholphin/ui/components/Dialogs.kt | 10 ++++- .../wholphin/ui/components/PlayButtons.kt | 43 +++++++++++++++++++ .../wholphin/ui/data/ChooseVersionParams.kt | 13 ++++++ .../ui/detail/episode/EpisodeDetails.kt | 12 ++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 12 ++++++ .../ui/detail/series/FocusedEpisodeFooter.kt | 11 +++++ .../ui/detail/series/SeriesOverview.kt | 1 + .../ui/detail/series/SeriesOverviewContent.kt | 3 ++ 10 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 80a2c45f..b2dedf26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -19,6 +19,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +/** + * Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams] + */ @Singleton class ItemPlaybackRepository @Inject @@ -28,6 +31,13 @@ class ItemPlaybackRepository private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the user ([ItemPlayback]) or the default choices + * + * @see getChosenItemFromPlayback + */ suspend fun getSelectedTracks( itemId: UUID, item: BaseItem, @@ -40,6 +50,11 @@ class ItemPlaybackRepository return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) } + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, @@ -86,6 +101,9 @@ class ItemPlaybackRepository } } + /** + * Save the choice for a specific media source for an item + */ suspend fun savePlayVersion( itemId: UUID, sourceId: UUID, @@ -103,6 +121,11 @@ class ItemPlaybackRepository } } + /** + * Save the chosen track (index & type) for an item + * + * Additionally, if the item is part of a TV Series, store the language as the preferred one for this series + */ suspend fun saveTrackSelection( item: BaseItem, itemPlayback: ItemPlayback?, @@ -180,6 +203,9 @@ class ItemPlaybackRepository return toSave.copy(rowId = id) } + /** + * Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any + */ suspend fun getTrackModifications( itemId: UUID, trackIndex: Int, @@ -188,6 +214,9 @@ class ItemPlaybackRepository itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) } + /** + * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem] + */ suspend fun saveTrackModifications( itemId: UUID, trackIndex: Int, @@ -206,6 +235,9 @@ class ItemPlaybackRepository } } + /** + * Clear the user's source/stream choices for an item + */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) chosenStreams?.plc?.let { @@ -219,6 +251,9 @@ class ItemPlaybackRepository } } +/** + * Info about which media source and streams the user/app has chosen for playback + */ data class ChosenStreams( val itemPlayback: ItemPlayback?, val plc: PlaybackLanguageChoice?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index c41c27f0..4e86ae21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -274,6 +274,7 @@ fun ContextMenu( onDismissRequest = { chooseVersion = null }, dismissOnClick = true, waitToLoad = params.fromLongClick, + elevation = 3.dp, ) } } @@ -285,6 +286,7 @@ fun ContextMenu( actions.onDeleteItem.invoke(item) onDismissRequest.invoke() }, + elevation = 3.dp, ) } if (showPlayWithDialog) { 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 6bd3dfaa..2224a010 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 @@ -64,6 +64,7 @@ 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.formatBitrate import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream @@ -630,7 +631,14 @@ fun chooseVersionParams( SelectedLeadingContent(uuid != null && uuid == chosenSourceId) }, supportingContent = { - videoStream?.displayTitle?.let { Text(text = it) } + val text = + remember { + buildList { + videoStream?.displayTitle?.let(::add) + source.bitrate?.let(::formatBitrate)?.let(::add) + }.joinToString(", ") + } + Text(text) }, onClick = { onClick.invoke(index) }, ) 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 a0c427b9..eea3c9f9 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 @@ -43,6 +43,7 @@ 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.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -56,6 +57,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource @@ -63,6 +65,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -83,9 +86,11 @@ fun ExpandablePlayButtons( moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onConfirmDelete: () -> Unit, + chooseVersionParams: ChooseVersionParams?, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current val firstFocus = remember { FocusRequester() } val restartInteractionSource = remember { MutableInteractionSource() } val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState() @@ -95,6 +100,9 @@ fun ExpandablePlayButtons( firstFocus.tryRequestFocus() } } + + var chooseVersion by remember { mutableStateOf(null) } + LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -129,6 +137,30 @@ fun ExpandablePlayButtons( } } + if (chooseVersionParams != null && chooseVersionParams.mediaSources.size > 1) { + item { + ExpandableFaButton( + title = R.string.version, + iconStringRes = R.string.fa_file_video, + onClick = { + chooseVersion = + chooseVersionParams( + resources, + chooseVersionParams.mediaSources.orEmpty(), + chooseVersionParams.chosenStreams + ?.source + ?.id + ?.toUUIDOrNull(), + ) { idx -> + val source = chooseVersionParams.mediaSources[idx] + chooseVersionParams.onChooseVersion.invoke(source) + } + }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + // Watched button item("watched") { ExpandableFaButton( @@ -182,6 +214,16 @@ fun ExpandablePlayButtons( ) } } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } val MinButtonSize = 40.dp @@ -451,6 +493,7 @@ private fun ExpandablePlayButtonsPreview() { trailerOnClick = {}, canDelete = true, onConfirmDelete = {}, + chooseVersionParams = null, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt new file mode 100644 index 00000000..397a2126 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt @@ -0,0 +1,13 @@ +package com.github.damontecres.wholphin.ui.data + +import com.github.damontecres.wholphin.data.ChosenStreams +import org.jellyfin.sdk.model.api.MediaSourceInfo + +/** + * Necessary details & action for choosing a version of some content + */ +data class ChooseVersionParams( + val chosenStreams: ChosenStreams?, + val mediaSources: List, + val onChooseVersion: (MediaSourceInfo) -> Unit, +) 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 fa45058d..dd91e00b 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 @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -47,6 +48,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -169,6 +171,7 @@ fun EpisodeDetails( }, canDelete = canDelete, onConfirmDelete = { viewModel.deleteItem(ep) }, + onChooseVersion = { contextActions.onChooseVersion.invoke(ep, it) }, modifier = modifier, ) } @@ -224,6 +227,7 @@ fun EpisodeDetailsContent( moreOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -284,6 +288,14 @@ fun EpisodeDetailsContent( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() 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 785ca74e..42742c85 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 @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.PersonContextActions import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -68,6 +69,7 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -236,6 +238,7 @@ fun MovieDetails( }, canDelete = state.canDelete, onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, + onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, modifier = modifier, ) } @@ -304,6 +307,7 @@ fun MovieDetailsContent( onClickDiscover: (Int, DiscoverItem) -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -369,6 +373,14 @@ fun MovieDetailsContent( }, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(state.chosenStreams, movie, onChooseVersion) { + ChooseVersionParams( + chosenStreams = state.chosenStreams, + mediaSources = movie.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, 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 83987524..600dee28 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 @@ -13,6 +13,8 @@ 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.components.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -27,6 +29,7 @@ fun FocusedEpisodeFooter( favoriteOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -52,6 +55,14 @@ fun FocusedEpisodeFooter( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier.fillMaxWidth(), ) } 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 7532be7a..fed2d342 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 @@ -311,6 +311,7 @@ fun SeriesOverview( }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, onConfirmDelete = viewModel::deleteItem, + onChooseVersion = contextActions.onChooseVersion, 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 999ed7c4..5d071387 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 @@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PersonKind import kotlin.time.Duration @@ -97,6 +98,7 @@ fun SeriesOverviewContent( canDelete: (BaseItem) -> Boolean, onConfirmDelete: (BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -323,6 +325,7 @@ fun SeriesOverviewContent( }, canDelete = canDelete.invoke(ep), onConfirmDelete = { onConfirmDelete.invoke(ep) }, + onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier .padding(top = 4.dp) From a4ee1e517585cb0c9a9cb3c2780e70f24ab1fb47 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 7 Jun 2026 07:15:40 -0400 Subject: [PATCH 025/475] Only show relevant parental ratings when filtering (#1529) ## Description Updates the parental rating filter so it only shows the relevant options instead of all of them. This is now the same behavior as the web UI. Additionally, the filter options are cached for a short time so the UI doesn't need to load them every time the menu is opened. Dev note: Refactors `FilterUtils` into a service which allows for the caching ### Related issues Closes #410 ### Testing Emulator with movie & tv show libraries ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/FilterOptionCache.kt | 183 ++++++++++++++++++ .../ui/components/CollectionFolderView.kt | 7 +- .../wholphin/ui/detail/PlaylistDetails.kt | 7 +- .../detail/collection/CollectionViewModel.kt | 7 +- .../wholphin/ui/util/FilterUtils.kt | 126 ------------ 5 files changed, 192 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt new file mode 100644 index 00000000..d2c141b3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.showToast +import com.mayakapps.kache.InMemoryKache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.filterApi +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.studiosApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +/** + * Get the possible values for filters in a library + */ +@Singleton +class FilterOptionCache + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + ) { + private val cache = + InMemoryKache>(16) { + creationScope = ioCoroutineScope + expireAfterWriteDuration = 1.hours + } + + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ): List { + val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) + return try { + cache + .getOrPut(cacheKey) { (userId, parentId, filterOption) -> + getFilterOptionValues(userId, parentId, filterOption) + }.orEmpty() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching options for %s", filterOption) + showToast(context, "Error occurred: ${ex.localizedMessage}") + emptyList() + } + } + + private suspend fun getFilterOptionValues( + userId: UUID?, + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ) = when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + StudioFilter -> { + api.studiosApi + .getStudios( + parentId = parentId, + userId = userId, + includeItemTypes = listOf(BaseItemKind.SERIES), + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + val ratings = + api.localizationApi + .getParentalRatings() + .content + .associate { + it.name to it.value + } + api.filterApi + .getQueryFiltersLegacy( + parentId = parentId, + userId = userId, + ).content.officialRatings + ?.mapNotNull { r -> + val value = ratings[r] + value?.let { FilterValueOption(r, value) } + }?.sortedBy { it.value as Int } + .orEmpty() + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + + private data class FilterOptionCacheKey( + val userId: UUID?, + val parentId: UUID?, + val filterOption: ItemFilterBy<*>, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 091edd21..d94d4f5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -44,6 +44,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.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService @@ -63,7 +64,6 @@ import com.github.damontecres.wholphin.ui.rememberInt 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 import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -117,6 +117,7 @@ class CollectionFolderViewModel private val musicService: MusicService, val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted val itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @@ -430,9 +431,7 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId.toUUID(), filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7a78701a..0557edbc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -64,6 +64,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.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService @@ -101,7 +102,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes 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.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -142,6 +142,7 @@ class PlaylistViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory @@ -301,9 +302,7 @@ class PlaylistViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 0cc58bea..3b778ce6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.MediaManagementService @@ -33,7 +34,6 @@ 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.toServerString -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -90,6 +90,7 @@ class CollectionViewModel private val imageUrlService: ImageUrlService, private val musicService: MusicService, val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted private val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -352,9 +353,7 @@ class CollectionViewModel } suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt deleted file mode 100644 index a39cae25..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter -import com.github.damontecres.wholphin.data.filter.FavoriteFilter -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter -import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.StudioFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.studiosApi -import org.jellyfin.sdk.api.client.extensions.yearsApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import timber.log.Timber -import java.util.TreeSet -import java.util.UUID - -object FilterUtils { - /** - * Gets the possible values for a filter - * - * For example, the possible genres in the parent ID - */ - suspend fun getFilterOptionValues( - api: ApiClient, - userId: UUID?, - parentId: UUID?, - filterOption: ItemFilterBy<*>, - ): List = - withContext(Dispatchers.IO) { - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = parentId, - userId = userId, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - StudioFilter -> { - api.studiosApi - .getStudios( - parentId = parentId, - userId = userId, - includeItemTypes = listOf(BaseItemKind.SERIES), - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet() - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } - } -} From b49926f80b1f61f552effa9b10c381399827918b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 8 Jun 2026 13:45:38 -0400 Subject: [PATCH 026/475] Store remembered tabs in app database instead of preferences (#1541) ## Description Moves storing the remembered tabs from app preferences to the app database. This means deleting the user will remove the remembered tabs and the app preferences file is smaller and faster to load. The remembered tabs are migrated automatically when the app is updated. There are no significant user facing changes in this PR. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../34.json | 720 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 7 +- .../wholphin/data/RememberedTabDao.kt | 22 + .../wholphin/data/model/RememberedTab.kt | 24 + .../wholphin/services/AppUpgradeHandler.kt | 59 ++ .../wholphin/services/RememberedTabService.kt | 34 + .../wholphin/services/hilt/AppModule.kt | 52 -- .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../ui/components/CircularProgress.kt | 44 ++ .../wholphin/ui/components/TabRow.kt | 35 +- .../wholphin/ui/components/TabbedPage.kt | 140 ++++ .../ui/detail/CollectionFolderLiveTv.kt | 222 +++--- .../ui/detail/CollectionFolderMovie.kt | 82 +- .../ui/detail/CollectionFolderMusic.kt | 95 +-- .../wholphin/ui/detail/CollectionFolderTv.kt | 109 +-- .../wholphin/ui/detail/FavoritesPage.kt | 133 ++-- .../ui/detail/series/SeriesOverviewContent.kt | 21 +- .../wholphin/ui/discover/DiscoverPage.kt | 87 +-- .../wholphin/ui/main/SearchPage.kt | 17 +- .../wholphin/ui/nav/DestinationContent.kt | 1 + .../ui/preferences/PreferencesViewModel.kt | 5 +- .../wholphin/util/RememberTabManager.kt | 45 -- app/src/main/proto/WholphinDataStore.proto | 2 +- .../damontecres/wholphin/ui/TestModule.kt | 17 +- 24 files changed, 1379 insertions(+), 599 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json new file mode 100644 index 00000000..a5709ec2 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json @@ -0,0 +1,720 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "278e485064fc9f184a2181599f0f8efb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 69f2e745..43676509 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions @@ -46,8 +47,9 @@ import java.util.UUID ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + RememberedTab::class, ], - version = 33, + version = 34, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -64,6 +66,7 @@ import java.util.UUID AutoMigration(30, 31), AutoMigration(31, 32), AutoMigration(32, 33), + AutoMigration(33, 34), ], ) @TypeConverters(Converters::class) @@ -81,6 +84,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun seerrServerDao(): SeerrServerDao abstract fun playbackEffectDao(): PlaybackEffectDao + + abstract fun rememberedTabDao(): RememberedTabDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt new file mode 100644 index 00000000..13398228 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.RememberedTab + +@Dao +interface RememberedTabDao { + @Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId") + suspend fun getRememberedTab( + userId: Int, + itemId: String, + ): RememberedTab? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(item: RememberedTab): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAll(items: List) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt new file mode 100644 index 00000000..2fc6f702 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + indices = [Index("userId", "itemId", unique = true)], + primaryKeys = ["userId", "itemId"], +) +data class RememberedTab( + val userId: Int, + val itemId: String, + val index: Int, +) 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 281eddc2..018b50ba 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,7 +6,11 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ScreensaverPreference @@ -28,9 +32,12 @@ 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 kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -44,6 +51,8 @@ class AppUpgradeHandler @param:ApplicationContext private val context: Context, private val appPreferences: DataStore, private val seerrServerDao: SeerrServerDao, + private val serverDao: JellyfinServerDao, + private val rememberedTabDao: RememberedTabDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -350,5 +359,55 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) { + Timber.i("Starting tab migration") + val users = mutableMapOf() + val tabs = + appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) -> + // "serverId_userId_itemId", itemId can have underscores + try { + val firstUnderScore = key.indexOf("_").takeIf { it >= 1 } + if (firstUnderScore != null) { + val secondUnderscore = + key + .indexOf("_", startIndex = firstUnderScore + 1) + .takeIf { it >= 1 } + if (secondUnderscore != null) { + val serverId = key.substring(0, firstUnderScore).toUUIDOrNull() + val userId = + key + .substring(firstUnderScore + 1, secondUnderscore) + .toUUIDOrNull() + val itemId = key.substring(secondUnderscore + 1, key.length) + if (userId != null && serverId != null) { + return@mapNotNull users + .getOrPut(userId) { + serverDao.getUser(serverId, userId) + }?.let { + RememberedTab(it.rowId, itemId, tabIndex) + } + } + } + } + } catch (ex: Exception) { + Timber.w(ex, "Error parsing '%s'", key) + } + null + } + Timber.v("Migrating %s tabs", tabs.size) + try { + if (tabs.isNotEmpty()) { + rememberedTabDao.saveAll(tabs) + } + appPreferences.updateData { + it.updateInterfacePreferences { + clearRememberedTabs() + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error saving migrated tabs") + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt new file mode 100644 index 00000000..515caad4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt @@ -0,0 +1,34 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.RememberedTabDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.RememberedTab +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RememberedTabService + @Inject + constructor( + private val serverRepository: ServerRepository, + private val rememberedTabDao: RememberedTabDao, + ) { + suspend fun getRememberedTab(itemId: String): Int? = + withContext(Dispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.getRememberedTab(userId, itemId)?.index + } + } + + suspend fun saveRememberedTab( + itemId: String, + tabIndex: Int, + ): Unit = + withContext(Dispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex)) + } + } + } 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 d1ed9eac..25dd1be3 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 @@ -1,18 +1,12 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context -import androidx.datastore.core.DataStore import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R 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.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 import dagger.Provides import dagger.hilt.InstallIn @@ -22,7 +16,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.android.androidDevice @@ -160,51 +153,6 @@ object AppModule { @Singleton fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore, - @IoCoroutineScope scope: CoroutineScope, - ) = object : RememberTabManager { - fun key(itemId: String): String = - serverRepository.current.value.let { - "${it?.server?.id}_${it?.user?.id}_$itemId" - } - - override fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - return preferences.appPreferences.interfacePreferences - .getRememberedTabsOrDefault(key(itemId), defaultTab) - } else { - return defaultTab - } - } - - override fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - scope.launch(ExceptionHandler()) { - appPreference.updateData { - it.updateInterfacePreferences { - putRememberedTabs(key(itemId), tabIndex) - } - } - } - } - } - } - @Provides @Singleton @IoDispatcher diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 62609628..97718089 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -16,6 +16,7 @@ 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.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences @@ -74,6 +75,10 @@ object DatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt index 4e3b13ab..e00a2185 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt @@ -7,7 +7,10 @@ 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.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.focus.FocusRequester @@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun CircularProgress(modifier: Modifier = Modifier) { @@ -58,3 +64,41 @@ fun LoadingPage( ) } } + +/** + * Fill the space and take focus, showing a loading indicator after the specified delay + */ +@Composable +fun DelayedLoadingPage( + modifier: Modifier = Modifier, + focusEnabled: Boolean = true, + delay: Duration = 300.milliseconds, +) { + val focusRequester = remember { FocusRequester() } + if (focusEnabled) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + var show by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(delay) + show = true + } + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable(focusEnabled), + ) { + if (show) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .align(Alignment.Center) + .size(48.dp), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index f54fda5b..293005dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusDirection 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 import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned @@ -42,26 +41,26 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import timber.log.Timber @Composable fun TabRow( selectedTabIndex: Int, - tabs: List, - focusRequesters: List, + tabs: List, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - if (selectedTabIndex >= 0) { + if (selectedTabIndex in tabs.indices) { state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } } var rowHasFocus by remember { mutableStateOf(false) } val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) - val currentFocusRequesters by rememberUpdatedState(focusRequesters) +// val currentFocusRequesters by rememberUpdatedState(focusRequesters) val currentOnClick by rememberUpdatedState(onClick) LazyRow( @@ -76,21 +75,21 @@ fun TabRow( // If entering from left or right, use last or first tab // Otherwise use the selected tab val index = currentSelectedTabIndex - val requesters = currentFocusRequesters +// val requesters = currentFocusRequesters Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") - val focusRequester = + val tab = if (requestedFocusDirection == FocusDirection.Left) { - requesters.lastOrNull() + tabs.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - requesters.firstOrNull() + tabs.firstOrNull() } else { - requesters.getOrNull(index) + tabs.getOrNull(index) } - (focusRequester ?: FocusRequester.Default).tryRequestFocus() + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() } }, ) { - itemsIndexed(tabs) { index, tabTitle -> + itemsIndexed(tabs) { index, tab -> val interactionSource = remember { MutableInteractionSource() } val onTabClick = remember(index) { @@ -99,12 +98,12 @@ fun TabRow( } } Tab( - title = tabTitle, + title = tab.title.getString(), selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, onClick = onTabClick, - modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), + modifier = Modifier.focusRequester(tab.tabFocusRequester), ) } } @@ -195,9 +194,11 @@ private fun TabRowPreview() { WholphinTheme { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TabRow( - selectedTabIndex = 1, - tabs = listOf("Tab 1", "Tab 2", "Tab 3"), - focusRequesters = listOf(), + selectedTabIndex = -1, + tabs = + remember { + listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) } + }, onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt new file mode 100644 index 00000000..7d89e666 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -0,0 +1,140 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import timber.log.Timber + +@HiltViewModel(assistedFactory = TabViewModel.Factory::class) +class TabViewModel + @AssistedInject + constructor( + private val userPreferencesService: UserPreferencesService, + private val rememberedTabService: RememberedTabService, + private val backdropService: BackdropService, + @param:Assisted private val itemId: String, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: String): TabViewModel + } + + private val _state = MutableStateFlow(UNSET) + val state: StateFlow = _state + + init { + viewModelScope.launchIO { + val startingTab = + if (shouldRememberTabs()) { + Timber.v("Getting remembered tab for %s", itemId) + try { + rememberedTabService.getRememberedTab(itemId) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tab for %s", itemId) + null + } ?: 0 + } else { + 0 + } + _state.value = startingTab + } + } + + private suspend fun shouldRememberTabs(): Boolean = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + fun updateSelectedTabIndex(tabIndex: Int) { + _state.value = tabIndex + viewModelScope.launchIO { + backdropService.clearBackdrop() + if (shouldRememberTabs()) { + Timber.v("Saving remembered tab for %s: %s", itemId, tabIndex) + rememberedTabService.saveRememberedTab(itemId, tabIndex) + } + } + } + } + +data class TabDetails( + val title: StringProvider, + val tabFocusRequester: FocusRequester = FocusRequester(), + val contentFocusRequester: FocusRequester = FocusRequester(), +) { + constructor( + @StringRes stringResId: Int, + ) : this(ResStringProvider(stringResId)) +} + +const val UNSET = -1234 + +/** + * A page showing multiple tabs + * + * This handles remembering the selected tabs via [TabViewModel] if needed + */ +@Composable +fun TabbedPage( + itemId: String, + tabs: List, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + viewModel: TabViewModel = + hiltViewModel( + key = itemId, + creationCallback = { it.create(itemId) }, + ), + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val selectedTabIndex by viewModel.state.collectAsState() + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(vertical = 16.dp), + tabs = tabs, + onClick = viewModel::updateSelectedTabIndex, + ) + } + selectedTabIndex.let { tabIndex -> + if (tabIndex >= 0) { + tabContent.invoke(tabIndex, tabs[tabIndex]) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} 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 b6f5b19a..18af298b 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,25 +1,15 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,20 +22,23 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.DataLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -56,24 +49,35 @@ class LiveTvCollectionViewModel val api: ApiClient, val serverRepository: ServerRepository, val navigationManager: NavigationManager, - val rememberTabManager: RememberTabManager, val backdropService: BackdropService, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val recordingFolders = MutableStateFlow>(emptyList()) + ) : ViewModel() { + val state = MutableStateFlow(LiveTvRecordingFolderState()) init { viewModelScope.launchIO { - val folders = - api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser?.id) - .content.items - .map { TabId(it.name ?: "Recordings", it.id) } - recordingFolders.value = folders + try { + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser?.id) + .content.items + .map { TabId(it.name ?: "Recordings", it.id) } + state.value = + LiveTvRecordingFolderState(DataLoadingState.Success(folders)) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching recording folders") + state.value = + LiveTvRecordingFolderState(DataLoadingState.Error(ex)) + } } } } +data class LiveTvRecordingFolderState( + val folders: DataLoadingState> = DataLoadingState.Pending, +) + data class TabId( val title: String, val id: UUID, @@ -86,103 +90,91 @@ fun CollectionFolderLiveTv( modifier: Modifier = Modifier, viewModel: LiveTvCollectionViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val folders by viewModel.recordingFolders.collectAsState() + val state by viewModel.state.collectAsState() - val tvGuideStr = stringResource(R.string.tv_guide) - val tvDvrStr = stringResource(R.string.tv_dvr_schedule) - val tabs = - remember(folders) { - listOf( - TabId(tvGuideStr, UUID.randomUUID()), - TabId(tvDvrStr, UUID.randomUUID()), - ) + folders + when (val st = state.folders) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("livetv", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - val onClickItem = { position: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs.map { it.title }, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) } - when (selectedTabIndex) { - 0 -> { - TvGuideGrid( - preferences = preferences, - onRowPosition = { - showHeader = it <= 0 - }, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - 1 -> { - DvrSchedule( - requestFocusAfterLoading = true, - focusRequesterOnEmpty = tabFocusRequesters[1], - modifier = - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } + is DataLoadingState.Success> -> { + val tabs = + remember { + listOf( + TabDetails(R.string.tv_guide), + TabDetails(R.string.tv_dvr_schedule), + ) + + st.data.map { + TabDetails(StringStringProvider(it.title)) + } + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } - else -> { - val folderIndex = selectedTabIndex - 2 - if (folderIndex in folders.indices) { - CollectionFolderView( - preferences = preferences, - onClickItem = onClickItem, - itemId = folders[folderIndex].id, - initialFilter = CollectionFolderFilter(), - showTitle = false, - recursive = false, - sortOptions = VideoSortOptions, - modifier = Modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - defaultViewOptions = ViewOptions(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } else { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + var showHeader by rememberSaveable { mutableStateOf(true) } + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { + 0 -> { + TvGuideGrid( + preferences = preferences, + onRowPosition = { + showHeader = it <= 0 + }, + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + 1 -> { + DvrSchedule( + requestFocusAfterLoading = true, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + else -> { + val folderIndex = tabIndex - 2 + if (folderIndex in st.data.indices) { + CollectionFolderView( + preferences = preferences, + onClickItem = onClickItem, + itemId = st.data[folderIndex].id, + initialFilter = CollectionFolderFilter(), + showTitle = false, + recursive = false, + sortOptions = VideoSortOptions, + modifier = Modifier.focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + defaultViewOptions = ViewOptions(), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } else { + ErrorMessage("Invalid tab index $tabIndex", null) + } + } } } } 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 6651ae9d..cb1d1812 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,24 +1,14 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.CollectionFolderFilter @@ -28,11 +18,11 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.BaseItemKind @@ -45,51 +35,25 @@ fun CollectionFolderMovie( modifier: Modifier = Modifier, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.collections), - stringResource(R.string.genres), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("movie", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.collections), + TabDetails(R.string.genres), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMovie( @@ -101,7 +65,7 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -128,12 +92,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -160,12 +124,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -178,12 +142,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 274e8343..431e8e8e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -1,24 +1,14 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -36,17 +26,16 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.RecommendedMusic -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.AlbumSortOptions import com.github.damontecres.wholphin.ui.data.ArtistSortOptions import com.github.damontecres.wholphin.ui.data.SongSortOptions import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -66,10 +55,8 @@ class CollectionFolderMusicViewModel private val musicService: MusicService, private val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, @Assisted private val itemId: UUID, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { @AssistedFactory interface Factory { fun create(itemId: UUID): CollectionFolderMusicViewModel @@ -132,30 +119,16 @@ fun CollectionFolderMusic( creationCallback = { it.create(destination.itemId) }, ), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.albums), - stringResource(R.string.artists), - stringResource(R.string.genres), - stringResource(R.string.songs), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("music", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.albums), + TabDetails(R.string.artists), + TabDetails(R.string.genres), + TabDetails(R.string.songs), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } val actions = @@ -167,27 +140,13 @@ fun CollectionFolderMusic( onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, ) } - - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMusic( @@ -199,7 +158,7 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -224,12 +183,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -254,12 +213,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -272,7 +231,7 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -297,17 +256,17 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } 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 35875351..2f8fb909 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,102 +1,77 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions 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.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.StudioCardGrid -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderTvViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun CollectionFolderTv( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: CollectionFolderTvViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.genres), - stringResource(R.string.studios), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("tv", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.genres), + TabDetails(R.string.studios), + ) + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedTvShow( @@ -108,7 +83,7 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -132,15 +107,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + onClickItem = onClickItem, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -153,7 +126,7 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -165,12 +138,12 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } 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 b0124313..f9aedff3 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,36 +1,30 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions -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.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GridClickActions -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.components.ViewOptionsWide @@ -39,86 +33,55 @@ import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun FavoritesPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: FavoritesViewModel = hiltViewModel(), ) { - val uiPrefs = preferences.appPreferences.interfacePreferences - val rememberedTabIndex = + val tabs = remember { - preferencesViewModel.getRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - 0, + listOf( + TabDetails(R.string.movies), + TabDetails(R.string.tv_shows), + TabDetails(R.string.episodes), + TabDetails(R.string.videos), + TabDetails(R.string.playlists), + TabDetails(R.string.people), + ) + } + val actions = + remember { + GridClickActions( + onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, ) } - val tabs = - listOf( - stringResource(R.string.movies), - stringResource(R.string.tv_shows), - stringResource(R.string.episodes), - stringResource(R.string.videos), - stringResource(R.string.playlists), - stringResource(R.string.people), - ) - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(selectedTabIndex) { - logTab("favorites", selectedTabIndex) - preferencesViewModel.saveRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - selectedTabIndex, - ) - preferencesViewModel.backdropService.clearBackdrop() - } var showHeader by rememberSaveable { mutableStateOf(true) } - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( +// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + TabbedPage( + itemId = NavDrawerItem.Favorites.id, + tabs = tabs, + showTabs = showHeader, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - val actions = - remember { - GridClickActions( - onClickItem = { _, item -> onClickItem.invoke(item) }, - ) - } - + ) { tabIndex, tabDetails -> // TODO playEnabled = true for movies & episodes - when (selectedTabIndex) { + when (tabIndex) { // Movies 0 -> { CollectionFolderView( @@ -141,13 +104,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -173,13 +136,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -206,13 +169,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -238,13 +201,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -270,13 +233,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -307,18 +270,18 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = listOf(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } 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 5d071387..a3ca1358 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 @@ -57,6 +57,7 @@ import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.ifElse @@ -64,6 +65,7 @@ import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -120,13 +122,21 @@ fun SeriesOverviewContent( var requestFocusAfterSeason by remember { mutableStateOf(false) } val seasonStr = stringResource(R.string.tv_season) + val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabs = - seasons.map { season -> - season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } - ?: "" + seasons.mapIndexed { index, season -> + TabDetails( + title = + StringStringProvider( + season?.name + ?: season?.data?.indexNumber?.let { "$seasonStr $it" } + ?: "", + ), + tabFocusRequester = tabFocusRequesters[index], + contentFocusRequester = contentFocusRequesters[index], + ) } - val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) @@ -172,7 +182,6 @@ fun SeriesOverviewContent( requestFocusAfterSeason = true } }, - focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) 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 eba02b62..9751a6d0 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,111 +1,78 @@ package com.github.damontecres.wholphin.ui.discover -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.focus.focusRequester import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @Composable fun DiscoverPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } - val tabs = - listOf( - stringResource(R.string.discover), - stringResource(R.string.request), - stringResource(R.string.search), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } - - LaunchedEffect(selectedTabIndex) { - logTab("discover", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) - } - OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, + remember { + listOf( + TabDetails(R.string.discover), + TabDetails(R.string.request), + TabDetails(R.string.search), ) } - when (selectedTabIndex) { + var showHeader by rememberSaveable { mutableStateOf(true) } + + TabbedPage( + itemId = NavDrawerItem.Discover.id, + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Discover 0 -> { SeerrDiscoverPage( preferences = preferences, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Requests 1 -> { SeerrRequestsPage( - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Search 2 -> { - LaunchedEffect(Unit) { - preferencesViewModel.backdropService.clearBackdrop() - } DiscoverSearchPage( preferences = preferences, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } 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 c3ff42da..aec58ce1 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 @@ -86,6 +86,7 @@ import com.github.damontecres.wholphin.ui.cards.ItemRowTitle import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceSearchButton @@ -518,6 +519,15 @@ fun SearchPage( } } } + + val tabs = + remember { + listOf( + TabDetails(R.string.library), + TabDetails(R.string.discover), + ) + } + Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -610,12 +620,7 @@ fun SearchPage( ) { TabRow( selectedTabIndex = selectedTab, - tabs = - listOf( - stringResource(R.string.library), - stringResource(R.string.discover), - ), - focusRequesters = tabFocusRequesters, + tabs = tabs, onClick = { selectedTab = it val row = 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 3e5ab947..db723522 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 @@ -359,6 +359,7 @@ fun DestinationContent( } Destination.Discover -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPage( preferences = preferences, modifier = modifier, 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 718febf3..f0e396cb 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 @@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -52,14 +51,12 @@ class PreferencesViewModel val navigationManager: NavigationManager, val backdropService: BackdropService, val screensaverService: ScreensaverService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { val currentUser get() = serverRepository.currentUserFlow.stateIn( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt deleted file mode 100644 index 46ec3606..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.damontecres.wholphin.util - -import com.github.damontecres.wholphin.preferences.UserPreferences -import java.util.UUID - -/** - * Functions to remember tabs choices for a library - */ -interface RememberTabManager { - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: UUID, - defaultTab: Int, - ): Int = getRememberedTab(preferences, itemId.toString(), defaultTab) - - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: UUID, - tabIndex: Int, - ) = saveRememberedTab(preferences, itemId.toString(), tabIndex) - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) -} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index bf98744a..12631e17 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -180,7 +180,7 @@ enum DisplayToggle{ message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; - map remembered_tabs = 3; + map remembered_tabs = 3 [deprecated = true]; AppThemeColors app_theme_colors = 4; bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; 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 index 8e43a4b0..e2026a4d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -19,6 +19,7 @@ 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.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository @@ -35,7 +36,6 @@ 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 @@ -166,17 +166,6 @@ object TestModule { return api } - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore, - @IoCoroutineScope scope: CoroutineScope, - ): RememberTabManager = mockk() - @Provides @Singleton @IoDispatcher @@ -267,6 +256,10 @@ object TestDatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore( From 93d601e7bfcb32e5f49368a5b36baa877d83d748 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 8 Jun 2026 13:45:48 -0400 Subject: [PATCH 027/475] Improvements to debug page (#1538) ## Description Makes some improvements to the debug page. Also adds some audio device listing for debugging. ### Related issues N/A ### Testing Emulator & shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/detail/DebugPage.kt | 466 ++++++++++++------ 1 file changed, 328 insertions(+), 138 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 0f859bb8..67c9be5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -2,6 +2,10 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import android.hardware.display.DisplayManager +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display @@ -12,11 +16,14 @@ 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.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,7 +32,6 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -33,16 +39,18 @@ import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +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 import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.acra.util.versionCodeLong import org.jellyfin.sdk.api.client.ApiClient @@ -59,17 +67,18 @@ import javax.inject.Inject class DebugViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { + val state = MutableStateFlow(DebugState()) val itemPlaybacks = MutableStateFlow>(emptyList()) val logcat = MutableStateFlow>(emptyList()) val supportedModes by lazy { - val displayManager = - MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) display.supportedModes.orEmpty() } @@ -103,6 +112,59 @@ class DebugViewModel } init { + viewModelScope.launchDefault { + val buildTime = Date(BuildConfig.BUILD_TIME) + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val installInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val installSource = + context.packageManager.getInstallSourceInfo(context.packageName) + buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add("Install source: ${installSource.packageSource}") + } + add("Installer: ${installSource.installingPackageName}") + add("Initiator: ${installSource.initiatingPackageName}") + } + } else { + listOf( + context.packageManager + .getInstallerPackageName(context.packageName) + .toString(), + ) + } + + val appInfo = + listOf( + "Version Name: ${pkgInfo.versionName}", + "Version Code: ${pkgInfo.versionCodeLong}", + "ClientInfo: $clientInfo", + "Build type: ${BuildConfig.BUILD_TYPE}", + "Build flavor: ${BuildConfig.FLAVOR}", + "Build time: $buildTime", + "FFMPEG included: $ffmpegIncluded", + "AV1 included: $av1Included", + "libmpv loaded: $libMpvLoaded", + "Debug enabled: ${BuildConfig.DEBUG}", + "ABIs: ${Build.SUPPORTED_ABIS.toList()}", + ) + installInfo + state.update { it.copy(appInfo = appInfo) } + } + viewModelScope.launchDefault { + val deviceInfoList = + listOf( + "DeviceInfo: $deviceInfo", + "Manufacturer: ${Build.MANUFACTURER}", + "Model: ${Build.MODEL}", + "API Level: ${Build.VERSION.SDK_INT}", + ) + state.update { + it.copy( + deviceInfo = deviceInfoList, + displayModes = supportedModes.map { it.toString() }, + ) + } + } viewModelScope.launchIO { serverRepository.currentUser?.rowId?.let { val results = itemPlaybackDao.getItems(it) @@ -113,6 +175,39 @@ class DebugViewModel val logcat = getLogCatLines() this@DebugViewModel.logcat.value = logcat } + viewModelScope.launchDefault { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + addAll( + addedDevices + .filter { it.isSink } + .map { it.details }, + ) + }, + ) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + removeAll(removedDevices.map { it.details }) + }, + ) + } + } + } + audioManager.registerAudioDeviceCallback(callback, null) + addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } + } } companion object { @@ -189,18 +284,144 @@ data class LogcatLine( val text: String, ) +data class DebugState( + val appInfo: List = emptyList(), + val deviceInfo: List = emptyList(), + val displayModes: List = emptyList(), + val audioInfo: List = emptyList(), +) + +val AudioDeviceInfo.details: String + get() { + val typeName = + when (type) { + AudioDeviceInfo.TYPE_HDMI -> "HDMI" + AudioDeviceInfo.TYPE_HDMI_ARC -> "ARC" + AudioDeviceInfo.TYPE_HDMI_EARC -> "eARC" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "Speaker Safe" + else -> "N/A" + } + val addressStr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + address + } else { + null + } + val encodings = + encodings.map { enc -> + when (enc) { + AudioFormat.ENCODING_INVALID -> "INVALID" + + AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT" + + AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT" + + AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT" + + AudioFormat.ENCODING_AC3 -> "AC3" + + AudioFormat.ENCODING_E_AC3 -> "E_AC3" + + AudioFormat.ENCODING_DTS -> "DTS" + + AudioFormat.ENCODING_DTS_HD -> "DTS_HD" + + AudioFormat.ENCODING_MP3 -> "MP3" + + AudioFormat.ENCODING_AAC_LC -> "AAC_LC" + + AudioFormat.ENCODING_AAC_HE_V1 -> "AAC_HE_V1" + + AudioFormat.ENCODING_AAC_HE_V2 -> "AAC_HE_V2" + + AudioFormat.ENCODING_IEC61937 -> "IEC61937" + + AudioFormat.ENCODING_DOLBY_TRUEHD -> "DOLBY_TRUEHD" + + AudioFormat.ENCODING_AAC_ELD -> "AAC_ELD" + + AudioFormat.ENCODING_AAC_XHE -> "AAC_XHE" + + AudioFormat.ENCODING_AC4 -> "AC4" + + // AudioFormat.ENCODING_AC4_L4->"AC4_L4" + + AudioFormat.ENCODING_E_AC3_JOC -> "E_AC3_JOC" + + AudioFormat.ENCODING_DOLBY_MAT -> "DOLBY_MAT" + + AudioFormat.ENCODING_OPUS -> "OPUS" + + AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM_24BIT_PACKED" + + AudioFormat.ENCODING_PCM_32BIT -> "PCM_32BIT" + + AudioFormat.ENCODING_MPEGH_BL_L3 -> "MPEGH_BL_L3" + + AudioFormat.ENCODING_MPEGH_BL_L4 -> "MPEGH_BL_L4" + + AudioFormat.ENCODING_MPEGH_LC_L3 -> "MPEGH_LC_L3" + + AudioFormat.ENCODING_MPEGH_LC_L4 -> "MPEGH_LC_L4" + + AudioFormat.ENCODING_DTS_UHD_P1 -> "DTS_UHD_P1" + + AudioFormat.ENCODING_DRA -> "DRA" + + AudioFormat.ENCODING_DTS_HD_MA -> "DTS_HD_MA" + + AudioFormat.ENCODING_DTS_UHD_P2 -> "DTS_UHD_P2" + + AudioFormat.ENCODING_DSD -> "DSD" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_AAC -> "IAMF_BASE_ENHANCED_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_FLAC -> "IAMF_BASE_ENHANCED_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_OPUS -> "IAMF_BASE_ENHANCED_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_PCM -> "IAMF_BASE_ENHANCED_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_AAC -> "IAMF_BASE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_FLAC -> "IAMF_BASE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_OPUS -> "IAMF_BASE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_PCM -> "IAMF_BASE_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_AAC -> "IAMF_SIMPLE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_FLAC -> "IAMF_SIMPLE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_OPUS -> "IAMF_SIMPLE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_PCM -> "IAMF_SIMPLE_PROFILE_PCM" + + else -> "invalid encoding $enc" + } + } + return "AudioDeviceInfo(id=$id, type=$type ($typeName), " + + "channelCounts=${channelCounts.contentToString()}, " + + "encodings=$encodings, " + + "productName=$productName, address=$addressStr)" + } + @Composable fun DebugPage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: DebugViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val scrollAmount = 100f val columnState = rememberLazyListState() val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() - fun scroll(reverse: Boolean = false) { + fun scroll( + reverse: Boolean = false, + scrollAmount: Float = 100f, + ) { scope.launch(ExceptionHandler()) { columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount) } @@ -209,10 +430,12 @@ fun DebugPage( val itemPlaybacks by viewModel.itemPlaybacks.collectAsState() val logcat by viewModel.logcat.collectAsState() + val padding = remember { PaddingValues(bottom = 32.dp) } + LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier .focusable() @@ -230,150 +453,94 @@ fun DebugPage( scroll(true) return@onKeyEvent true } + if (it.key == Key.MediaFastForward || it.key == Key.PageDown) { + scroll(false, 300f) + return@onKeyEvent true + } + if (it.key == Key.MediaSkipBackward || it.key == Key.PageUp) { + scroll(true, 300f) + return@onKeyEvent true + } return@onKeyEvent false }, ) { item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "App Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - val buildTime = Date(BuildConfig.BUILD_TIME) - val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val installInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val installSource = - context.packageManager.getInstallSourceInfo(context.packageName) - buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - add("Install source: ${installSource.packageSource}") - } - add("Installer: ${installSource.installingPackageName}") - add("Initiator: ${installSource.initiatingPackageName}") - } - } else { - listOf(context.packageManager.getInstallerPackageName(context.packageName)) - } - ( - listOf( - "Version Name: ${pkgInfo.versionName}", - "Version Code: ${pkgInfo.versionCodeLong}", - "ClientInfo: ${viewModel.clientInfo}", - "Build type: ${BuildConfig.BUILD_TYPE}", - "Build flavor: ${BuildConfig.FLAVOR}", - "Build time: $buildTime", - "FFMPEG included: ${viewModel.ffmpegIncluded}", - "AV1 included: ${viewModel.av1Included}", - "libmpv loaded: ${viewModel.libMpvLoaded}", - "Debug enabled: ${BuildConfig.DEBUG}", - "ABIs: ${Build.SUPPORTED_ABIS.toList()}", - ) + installInfo - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("App Information") + state.appInfo.forEach { + BodyText(it) } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Device Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Device Information") + state.deviceInfo.forEach { + BodyText(it) + } - listOf( - "DeviceInfo: ${viewModel.deviceInfo}", - "Manufacturer: ${Build.MANUFACTURER}", - "Model: ${Build.MODEL}", - "API Level: ${Build.VERSION.SDK_INT}", - "Display Modes:", - *viewModel.supportedModes, - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SubSectionTitle("Display Modes") + state.displayModes.forEach { + BodyText(it) + } + + SubSectionTitle("Audio Devices") + state.audioInfo.forEach { + BodyText(it) } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "AppPreferences", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = preferences.appPreferences.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("AppPreferences") + BodyText(preferences.appPreferences.toString()) } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "User Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current server: ${viewModel.serverRepository.currentServer}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current user: ${viewModel.serverRepository.currentUser}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("User Information") + BodyText("Current server: ${viewModel.serverRepository.currentServer}") + BodyText("Current user: ${viewModel.serverRepository.currentUser}") + BodyText("User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}") } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Database", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "ItemPlayback", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Database") + + SubSectionTitle("ItemPlayback") itemPlaybacks.forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + BodyText(it.toString()) } } } @@ -382,32 +549,55 @@ fun DebugPage( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "Logcat", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - logcat.forEach { (level, line) -> - val color = - when (level) { - Log.VERBOSE -> MaterialTheme.colorScheme.onSurface - Log.DEBUG -> Color(0xff2bc4cf) - Log.INFO -> Color(0xff2bcf8b) - Log.WARN -> Color(0xffdde663) - Log.ERROR -> Color(0xffe67063) - else -> MaterialTheme.colorScheme.onSurface - } - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = color, - ) - } + SectionTitle("Logcat") } } + items(logcat) { (level, line) -> + val color = + when (level) { + Log.VERBOSE -> MaterialTheme.colorScheme.onSurface + Log.DEBUG -> Color(0xff2bc4cf) + Log.INFO -> Color(0xff2bcf8b) + Log.WARN -> Color(0xffdde663) + Log.ERROR -> Color(0xffe67063) + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = color, + ) + } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun SubSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun BodyText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) +} + private val THIRD_PARTY_TAGS = listOf( "libc:F", From 01bd3249faa750f5e8a56960351742f51821138a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 8 Jun 2026 17:23:09 -0400 Subject: [PATCH 028/475] Fixes two row bouncing UI issues (#1543) ## Description Fixes row bounce using `SeasonCard` when are two exactly two items. This was done by calculating the y-offset in the `layout` modifier instead of `offset`. This has the side effect of probably being very slightly more efficient since only 1 animation state is needed now. Fixes row bounce on the second row of the home page if the first row is empty and not shown. ### Related issues Fixes #1528 Fixes #1271 ### Testing Emulator in different situations. Also checked the layout inspector to make sure the optimizations from #1184 weren't affected ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/cards/SeasonCard.kt | 24 ++++++++++--------- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 962ab727..5caa45bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -24,7 +23,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -104,6 +102,9 @@ fun SeasonCard( ) } +private val spaceBetweenFocused = 4.dp +private val spaceBetweenUnfocused = 12.dp + /** * A Card for a TV Show Season, but can generically show most items */ @@ -129,8 +130,11 @@ fun SeasonCard( ) { val focused by interactionSource.collectIsFocusedAsState() // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) @@ -179,14 +183,12 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetween.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelow.value.roundToPx() + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) } }.fillMaxWidth(), ) { 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 db76fb3e..afaaac56 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 @@ -374,7 +374,7 @@ fun HomePageContent( itemsIndexed(homeRows) { rowIndex, row -> val rowModifier = Modifier - .animateItem() + .animateItem(placementSpec = null) .padding(bottom = 8.dp) CompositionLocalProvider( LocalBringIntoViewSpec provides defaultBringIntoViewSpec, From f7f0ea9cca8758927c7fc32f44fa4829b339f3cf Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 10 Jun 2026 19:29:49 -0400 Subject: [PATCH 029/475] Fixes missing parental ratings & focus loss when changing filters (#1559) ## Description - Ensures all parent ratings are shown in the filter drop down - Fixes focus loss after filtering library and there are no results ### Related issues Fixes #1535 (along w/ #1529) Fixes #1534 ### Testing Emulator w/ a UK movie library ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/FilterOptionCache.kt | 6 +++--- .../ui/components/CollectionFolderGrid.kt | 12 +++++++++--- .../ui/components/CollectionFolderView.kt | 17 +++++++++++++---- .../wholphin/ui/components/ContextMenuUtils.kt | 2 ++ .../wholphin/ui/components/FilterByButton.kt | 7 ++++++- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt index d2c141b3..710fbeba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -124,9 +124,9 @@ class FilterOptionCache parentId = parentId, userId = userId, ).content.officialRatings - ?.mapNotNull { r -> - val value = ratings[r] - value?.let { FilterValueOption(r, value) } + ?.map { r -> + val value = ratings[r] ?: 0 + FilterValueOption(r, value) }?.sortedBy { it.value as Int } .orEmpty() } 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 773bdbee..bdf454a8 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 @@ -21,6 +21,8 @@ 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.focus.focusRestorer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -270,6 +272,8 @@ fun CollectionFolderHeader( currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, ) { AnimatedVisibility( showHeader, @@ -292,13 +296,14 @@ fun CollectionFolderHeader( modifier = Modifier .padding(start = 16.dp, end = endPadding) + .focusRestorer() .fillMaxWidth(), ) { if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, + modifier = Modifier.focusRestorer(), ) { if (sortOptions.isNotEmpty()) { SortByButton( @@ -314,7 +319,8 @@ fun CollectionFolderHeader( current = currentFilter, onFilterChange = onFilterChange, getPossibleValues = getPossibleFilterValues, - modifier = Modifier, + modifier = Modifier.focusRequester(filterButtonFocusRequester), + onShow = onShowFilterDropdown, ) } ExpandableFaButton( @@ -329,7 +335,7 @@ fun CollectionFolderHeader( Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, + modifier = Modifier.focusRestorer(), ) { ExpandablePlayButton( title = R.string.play, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index d94d4f5c..eb769626 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -659,6 +659,9 @@ fun CollectionFolderView( val contextMenu = rememberContextMenu(preferences, viewModel) var showViewOptions by rememberSaveable { mutableStateOf(false) } + var filterDropdownShowing by remember { mutableStateOf(false) } + val headerRowFocusRequester = remember { FocusRequester() } + val filterButtonFocusRequester = remember { FocusRequester() } val gridActions = remember(actions) { @@ -741,7 +744,6 @@ fun CollectionFolderView( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Column(modifier = Modifier.fillMaxSize()) { - val headerRowFocusRequester = remember { FocusRequester() } LifecycleResumeEffect(itemId) { viewModel.onResumePage() @@ -780,6 +782,8 @@ fun CollectionFolderView( onClickPlayAll = gridActions.onClickPlayAll!!, onClickShowViewOptions = { showViewOptions = true }, modifier = Modifier.focusRequester(headerRowFocusRequester), + onShowFilterDropdown = { filterDropdownShowing = it }, + filterButtonFocusRequester = filterButtonFocusRequester, ) when (val pager = state.items) { @@ -798,9 +802,14 @@ fun CollectionFolderView( is DataLoadingState.Success> -> { LaunchedEffect(Unit) { - if (pager.data.isNotEmpty()) { - gridFocusRequester.tryRequestFocus() - } + val focusRequester = + when { + contextMenu.isShowing -> null + filterDropdownShowing -> filterButtonFocusRequester + pager.data.isNotEmpty() -> gridFocusRequester + else -> focusRequesterOnEmpty ?: headerRowFocusRequester + } + focusRequester?.tryRequestFocus() } Box(Modifier.fillMaxSize()) { if (state.viewOptions.type == ViewOptionsType.GRID) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt index f588851f..0e930b65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -36,6 +36,8 @@ class ContextMenuUtils( ) { private var showContextMenu by mutableStateOf?>(null) + val isShowing: Boolean get() = showContextMenu != null + /** * Show the context menu, typically from a long click */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index e881bb98..378c8807 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -71,6 +71,7 @@ fun FilterByButton( onFilterChange: (GetItemsFilter) -> Unit, getPossibleValues: suspend (ItemFilterBy<*>) -> List, modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, ) { var dropDown by remember { mutableStateOf(false) } var nestedDropDown by remember { mutableStateOf?>(null) } @@ -79,7 +80,10 @@ fun FilterByButton( Box(modifier = modifier) { ExpandableFilterButton( filterCount = filterCount, - onClick = { dropDown = true }, + onClick = { + onShow?.invoke(true) + dropDown = true + }, modifier = Modifier, ) @@ -87,6 +91,7 @@ fun FilterByButton( expanded = dropDown, containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), onDismissRequest = { + onShow?.invoke(false) dropDown = false nestedDropDown = null }, From 38b4a8c4de11951b1765ecc8d945795381732512 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 10 Jun 2026 19:30:07 -0400 Subject: [PATCH 030/475] Play or shuffle series, season, collections, & playlists from context menu (#1544) ## Description Adds options to the context menu to play or shuffle items in a series, season, collection, or playlist. So you can long click a TV show and play it directly. Playing a TV show will play the next up episode (same as the play button on the series detail page). Playing a season, collection, or playlist will start from the beginning. Also, removes the green play arrows and uses the same neutral color as other icons. ### Related issues Closes #1451 Closes #1507 ### Testing Emulator from library pages & series detail pages ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/PlaylistCreator.kt | 77 +++++-- .../wholphin/ui/components/ContextMenu.kt | 23 ++- .../wholphin/ui/detail/DetailUtils.kt | 193 ------------------ .../wholphin/ui/detail/PlaylistList.kt | 2 - .../detail/discover/DiscoverSeriesDetails.kt | 2 - .../ui/detail/livetv/ProgramDialog.kt | 1 - .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 1 - .../ui/detail/series/SeriesDetails.kt | 1 - .../wholphin/ui/playback/PlaybackViewModel.kt | 28 ++- .../damontecres/wholphin/util/Constants.kt | 12 ++ 10 files changed, 107 insertions(+), 233 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 055c9186..0b2e85e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -23,6 +23,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -202,28 +203,72 @@ class PlaylistCreator BaseItemKind.SEASON -> { val seriesId = item.seriesId if (seriesId != null) { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = seriesId, - seasonId = item.id, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = seriesId, + seasonId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DefaultItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = item.id, + episodeId = null, + shuffled = shuffled, + ), + ) + } } else { PlaylistCreationResult.Error(null, "Episode has no seriesId") } } BaseItemKind.SERIES -> { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = item.id, - seasonId = null, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DefaultItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + val result by api.tvShowsApi.getNextUp(seriesId = item.id) + val nextUp = + result.items.firstOrNull() ?: api.tvShowsApi + .getEpisodes( + item.id, + limit = 1, + ).content.items + .firstOrNull() + if (nextUp != null) { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = item.id, + seasonId = null, + episodeId = nextUp.id, + shuffled = shuffled, + ), + ) + } else { + PlaylistCreationResult.Error( + null, + "Could not determine next up episode for series: " + item.id, + ) + } + } } BaseItemKind.PLAYLIST -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index 4e86ae21..3a592bbc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.supportedPlayableTypes +import com.github.damontecres.wholphin.util.supportedShufflableTypes import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -355,7 +356,6 @@ private fun buildContextMenuItems( DialogItem( resources.getString(R.string.resume), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -385,7 +385,6 @@ private fun buildContextMenuItems( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -398,6 +397,22 @@ private fun buildContextMenuItems( ) } } + if (item.type in supportedShufflableTypes) { + add( + DialogItem( + resources.getString(R.string.shuffle), + R.string.fa_shuffle, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.PlaybackList( + itemId = item.id, + shuffle = true, + ), + ) + }, + ) + } if (showStreamChoices) { item.data.mediaSources?.letNotEmpty { sources -> val source = @@ -734,7 +749,6 @@ fun buildContextForMusic( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -744,7 +758,6 @@ fun buildContextForMusic( DialogItem( resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) @@ -867,7 +880,6 @@ fun buildContextForMusicQueue( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -877,7 +889,6 @@ fun buildContextForMusicQueue( DialogItem( resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) 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 deleted file mode 100644 index 73ad4a2e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import android.content.res.Resources -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.ui.graphics.Color -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.supportedPlayableTypes -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -data class MoreDialogActions( - val navigateTo: (Destination) -> Unit, - val onClickWatch: (UUID, Boolean) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, - val onClickAddPlaylist: (UUID) -> Unit, - val onSendMediaInfo: (UUID) -> Unit, - val onClickDelete: (BaseItem) -> Unit, - val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, - val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, - val onClickAddToQueue: (BaseItem) -> Unit = {}, -) - -fun buildMoreDialogItemsForHome( - resources: Resources, - item: BaseItem, - seriesId: UUID?, - playbackPosition: Duration, - watched: Boolean, - favorite: Boolean, - canDelete: Boolean, - actions: MoreDialogActions, - canRemoveContinueWatching: Boolean = false, - canRemoveNextUp: Boolean = false, -): List = - buildList { - val itemId = item.id - add( - DialogItem( - resources.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.onClickGoTo(item) - }, - ) - if (item.type in supportedPlayableTypes) { - if (playbackPosition >= 1.seconds) { - add( - DialogItem( - resources.getString(R.string.resume), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - playbackPosition.inWholeMilliseconds, - ), - ) - }, - ) - add( - DialogItem( - resources.getString(R.string.restart), - Icons.Default.Refresh, -// iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } else { - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } - } - if (item.type == BaseItemKind.MUSIC_ALBUM) { - add( - DialogItem( - resources.getString(R.string.add_to_queue), - Icons.Default.Add, - ) { - actions.onClickAddToQueue(item) - }, - ) - } - add( - DialogItem( - text = R.string.add_to_playlist, - iconStringRes = R.string.fa_list_ul, - ) { - actions.onClickAddPlaylist.invoke(itemId) - }, - ) - if (canDelete) { - add( - DialogItem( - resources.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - ) { - actions.onClickDelete.invoke(item) - }, - ) - } - if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) { - add( - DialogItem( - text = R.string.remove_continue_watching, - iconStringRes = R.string.fa_eye, - ) { - actions.onClickWatch.invoke(itemId, false) - }, - ) - } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { - add( - DialogItem( - text = R.string.remove_next_up, - iconStringRes = R.string.fa_tag, - ) { - actions.onClickRemoveFromNextUp.invoke(item) - }, - ) - } - add( - DialogItem( - text = if (watched) R.string.mark_unwatched else R.string.mark_watched, - iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, - ) { - actions.onClickWatch.invoke(itemId, !watched) - }, - ) - add( - DialogItem( - text = if (favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (favorite) Color.Red else Color.Unspecified, - ) { - actions.onClickFavorite.invoke(itemId, !favorite) - }, - ) - seriesId?.let { - add( - DialogItem( - resources.getString(R.string.go_to_series), - Icons.AutoMirrored.Filled.ArrowForward, - ) { - actions.navigateTo( - Destination.MediaItem( - it, - BaseItemKind.SERIES, - null, - ), - ) - }, - ) - } - add( - DialogItem( - text = R.string.send_media_info_log_to_server, - iconStringRes = R.string.fa_file_video, - ) { - actions.onSendMediaInfo.invoke(itemId) - }, - ) - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt index f94f7381..df4408fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind 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.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -113,7 +112,6 @@ fun PlaylistList( leadingContent = { Icon( imageVector = Icons.Default.Add, - tint = Color.Green.copy(.7f), contentDescription = "Add", ) }, 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 a493cab3..5b87b3cd 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 @@ -24,7 +24,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -518,7 +517,6 @@ fun buildDialogForSeason( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index 53dabb47..04daa260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -159,7 +159,6 @@ fun ProgramDialog( Icon( imageVector = Icons.Default.PlayArrow, contentDescription = stringResource(R.string.delete), - tint = Color.Green.copy(alpha = .75f), ) Text( text = stringResource(R.string.watch_live), 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 55a8c515..e4cc19ec 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 @@ -259,7 +259,6 @@ fun TvGuideGrid( DialogItem( watchLiveStr, Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { viewModel.navigationManager.navigateTo( 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 7c437f21..69639590 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 @@ -742,7 +742,6 @@ fun buildDialogForSeason( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, 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 696e2eb9..f197998f 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 @@ -315,16 +315,24 @@ class PlaybackViewModel val playlistItem = if (queriedItem.type.playable) { PlaylistItem.Media(BaseItem(queriedItem, false)) - } else if (destination is Destination.PlaybackList) { + } else { val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) + if (destination is Destination.PlaybackList) { + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + } else { + // Try to create a playlist + playlistCreator.createFrom( + item = queriedItem, + recursive = true, + ) + } when (val r = playlistResult) { is PlaylistCreationResult.Error -> { _state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) } @@ -343,8 +351,6 @@ class PlaybackViewModel r.playlist.items.first() } } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } 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 0c577571..531a4f9a 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 @@ -53,4 +53,16 @@ val supportedPlayableTypes = BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, + ) + +val supportedShufflableTypes = + setOf( + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, ) From fa5cc99c3b39075182eeba63c0564aef996b575a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 12 Jun 2026 01:24:24 -0400 Subject: [PATCH 031/475] De-duplicate string resource keys (#1560) ## Description Ensures that all string & plural resource keys are unique. This should help make sure all of the strings are shown in weblate's translation. ### Related issues Related to #1551 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/ExtrasItem.kt | 22 +++++------ .../wholphin/services/HomeSettingsService.kt | 4 +- .../wholphin/services/TrailerService.kt | 2 +- .../damontecres/wholphin/ui/Formatting.kt | 6 +-- .../wholphin/ui/cards/ExtrasRow.kt | 2 +- .../wholphin/ui/cards/PersonRow.kt | 4 +- .../wholphin/ui/detail/FavoritesPage.kt | 6 +-- .../wholphin/ui/detail/PersonPage.kt | 4 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 4 +- .../wholphin/ui/main/SearchPage.kt | 4 +- .../main/settings/HomeSettingsFavoriteList.kt | 6 +-- .../ui/main/settings/HomeSettingsViewModel.kt | 2 +- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 5 +-- app/src/main/res/values-da/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 11 +++--- app/src/main/res/values-el/strings.xml | 8 ++-- app/src/main/res/values-es/strings.xml | 11 +++--- app/src/main/res/values-et/strings.xml | 11 +++--- app/src/main/res/values-fr/strings.xml | 11 +++--- app/src/main/res/values-hu/strings.xml | 9 ++--- app/src/main/res/values-in/strings.xml | 11 +++--- app/src/main/res/values-it/strings.xml | 11 +++--- app/src/main/res/values-iw/strings.xml | 5 +-- app/src/main/res/values-lv/strings.xml | 5 +-- app/src/main/res/values-nb-rNO/strings.xml | 9 ++--- app/src/main/res/values-nl/strings.xml | 11 +++--- app/src/main/res/values-nn/strings.xml | 11 +++--- app/src/main/res/values-pl/strings.xml | 11 +++--- app/src/main/res/values-pt-rBR/strings.xml | 5 +-- app/src/main/res/values-pt/strings.xml | 11 +++--- app/src/main/res/values-ro/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 9 ++--- app/src/main/res/values-sk/strings.xml | 9 ++--- app/src/main/res/values-sl/strings.xml | 5 +-- app/src/main/res/values-sv/strings.xml | 11 +++--- app/src/main/res/values-th/strings.xml | 5 +-- app/src/main/res/values-tr/strings.xml | 5 +-- app/src/main/res/values-uk/strings.xml | 5 +-- app/src/main/res/values-zh-rCN/strings.xml | 11 +++--- app/src/main/res/values-zh-rTW/strings.xml | 11 +++--- app/src/main/res/values/strings.xml | 37 +++++++++---------- 42 files changed, 156 insertions(+), 183 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index ff568a37..30af65cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -92,18 +92,18 @@ sealed interface ExtrasItem { val ExtraType.stringRes: Int get() = when (this) { - ExtraType.UNKNOWN -> R.string.other_extras - ExtraType.CLIP -> R.string.clips + ExtraType.UNKNOWN -> R.string.other_extras_title + ExtraType.CLIP -> R.string.clips_title ExtraType.TRAILER -> R.string.trailers - ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes - ExtraType.DELETED_SCENE -> R.string.deleted_scenes - ExtraType.INTERVIEW -> R.string.interviews - ExtraType.SCENE -> R.string.scenes - ExtraType.SAMPLE -> R.string.samples - ExtraType.THEME_SONG -> R.string.theme_songs - ExtraType.THEME_VIDEO -> R.string.theme_videos - ExtraType.FEATURETTE -> R.string.featurettes - ExtraType.SHORT -> R.string.shorts + ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title + ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title + ExtraType.INTERVIEW -> R.string.interviews_title + ExtraType.SCENE -> R.string.scenes_title + ExtraType.SAMPLE -> R.string.samples_title + ExtraType.THEME_SONG -> R.string.theme_songs_title + ExtraType.THEME_VIDEO -> R.string.theme_videos_title + ExtraType.FEATURETTE -> R.string.featurettes_title + ExtraType.SHORT -> R.string.shorts_title } /** 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 4b03c676..0ca1e89e 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 @@ -506,7 +506,7 @@ class HomeSettingsService is HomeRowConfig.Favorite -> { val name = ResProviderStringProvider( - R.string.favorite_items, + R.string.favorite_items_title, ResStringProvider(favoriteOptions[config.kind]!!), ) HomeRowConfigDisplay(id, name, config) @@ -952,7 +952,7 @@ class HomeSettingsService is HomeRowConfig.Favorite -> { val title = ResProviderStringProvider( - R.string.favorite_items, + R.string.favorite_items_title, ResStringProvider(favoriteOptions[row.kind]!!), ) if (row.kind == BaseItemKind.PERSON) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 6be37807..22df106b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -34,7 +34,7 @@ class TrailerService // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.getString(R.string.trailer) + ?: context.resources.getQuantityString(R.plurals.trailers, 1) val subtitle = when (url.toUri().host) { "youtube.com", "www.youtube.com" -> "YouTube" 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 7b64b1a4..aa00f814 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 @@ -203,12 +203,12 @@ fun listToDotString( @StringRes fun formatTypeName(type: BaseItemKind): Int = when (type) { - BaseItemKind.MOVIE -> R.string.movies - BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.MOVIE -> R.string.movies_title + BaseItemKind.SERIES -> R.string.tv_shows_title BaseItemKind.EPISODE -> R.string.episodes BaseItemKind.VIDEO -> R.string.videos BaseItemKind.PLAYLIST -> R.string.playlists - BaseItemKind.PERSON -> R.string.people + BaseItemKind.PERSON -> R.string.people_title BaseItemKind.BOX_SET -> R.string.collections BaseItemKind.AUDIO -> TODO() BaseItemKind.CHANNEL -> R.string.channels diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 1d9927fe..2bf2bac6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -17,7 +17,7 @@ fun ExtrasRow( modifier: Modifier = Modifier, ) { ItemRow( - title = stringResource(R.string.extras), + title = stringResource(R.string.extras_title), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 140cc2fd..637aa5cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -33,7 +33,7 @@ fun PersonRow( people: List, onClick: (Person) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } @@ -84,7 +84,7 @@ fun DiscoverPersonRow( people: List, onClick: (DiscoverItem) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, DiscoverItem) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } 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 f9aedff3..b8e40895 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 @@ -56,12 +56,12 @@ fun FavoritesPage( val tabs = remember { listOf( - TabDetails(R.string.movies), - TabDetails(R.string.tv_shows), + TabDetails(R.string.movies_title), + TabDetails(R.string.tv_shows_title), TabDetails(R.string.episodes), TabDetails(R.string.videos), TabDetails(R.string.playlists), - TabDetails(R.string.people), + TabDetails(R.string.people_title), ) } val actions = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 0efb9ae8..218627ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -363,7 +363,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.movies), movies), + title = rowTitle(stringResource(R.string.movies_title), movies), state = movies, rowIndex = MOVIE_ROW, position = position, @@ -376,7 +376,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.tv_shows), series), + title = rowTitle(stringResource(R.string.tv_shows_title), series), state = series, rowIndex = SERIES_ROW, position = position, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 5825cc13..7e98b5c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -80,7 +80,7 @@ class SeerrDiscoverViewModel this.copy( movies = DiscoverRowData( - ResStringProvider(R.string.movies), + ResStringProvider(R.string.movies_title), it, DiscoverRequestType.DISCOVER_MOVIES, ), @@ -90,7 +90,7 @@ class SeerrDiscoverViewModel this.copy( tv = DiscoverRowData( - ResStringProvider(R.string.tv_shows), + ResStringProvider(R.string.tv_shows_title), it, DiscoverRequestType.DISCOVER_TV, ), 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 aec58ce1..ecb0e74d 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 @@ -682,7 +682,7 @@ fun SearchPage( modifier = Modifier.focusGroup(), ) { searchResultRow( - title = R.string.movies, + title = R.string.movies_title, result = state.movies, rowIndex = MOVIE_ROW, position = position, @@ -692,7 +692,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = R.string.tv_shows, + title = R.string.tv_shows_title, result = state.series, rowIndex = SERIES_ROW, position = position, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 5bf3056d..80b2f6f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -53,12 +53,12 @@ fun HomeSettingsFavoriteList( val favoriteOptions by lazy { mapOf( - BaseItemKind.MOVIE to R.string.movies, - BaseItemKind.SERIES to R.string.tv_shows, + BaseItemKind.MOVIE to R.string.movies_title, + BaseItemKind.SERIES to R.string.tv_shows_title, BaseItemKind.EPISODE to R.string.episodes, BaseItemKind.VIDEO to R.string.videos, BaseItemKind.PLAYLIST to R.string.playlists, - BaseItemKind.PERSON to R.string.people, + BaseItemKind.PERSON to R.string.people_title, BaseItemKind.MUSIC_ARTIST to R.string.artists, BaseItemKind.MUSIC_ALBUM to R.string.albums, ) 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 ee8fa341..22a55f12 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 @@ -413,7 +413,7 @@ class HomeSettingsViewModel id = id, title = ResProviderStringProvider( - R.string.favorite_items, + R.string.favorite_items_title, ResStringProvider(favoriteOptions[type]!!), ), config = HomeRowConfig.Favorite(type), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0bfa6343..4cbadd03 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -164,7 +164,7 @@ التصنيف العمري مدة العرض - إضافات + إضافات أخرى diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e41bd2b5..0f63eb14 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -149,7 +149,6 @@ Přepnout servery Přepnout Nejlépe hodnocené Nezhlédnuté - Upoutávka Upoutávky @@ -188,7 +187,7 @@ Pozadí Úspěch Rodičovské hodnocení - Doplňky + Doplňky Jiný @@ -512,7 +511,7 @@ 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 + Oblíbené %s Přizpůsobit Oříznout Vyplnit diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7fdb5d82..7803eb49 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -148,7 +148,6 @@ Skift servere Skift Bedst bedømte usete - Trailer Trailere @@ -184,7 +183,7 @@ Succes Aldersgrænse Spilletid - Ekstra + Ekstra Andet diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9c784fdb..77c93b6a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -25,14 +25,14 @@ Gestorben Regisseur Mehr - Filme + Filme Name Keine Daten Keine Ergebnisse Keine geplanten Aufnahmen Kein Update verfügbar Pfad - Darsteller + Darsteller Zuletzt hinzugefügt in %1$s Zuletzt hinzugefügt Zuletzt aufgenommen @@ -46,21 +46,20 @@ Benutzer auswählen Untertitel Server wechseln - Trailer Trailer Staffel Staffeln - Serien + Serien Oberfläche Unbekannt Aktualisierungen Version Live ansehen %1$d Jahre alt - Extras + Extras Szenen @@ -531,7 +530,7 @@ Dirigent Liedtext-Schreiber Künstler - Favorisierte %s + Favorisierte %s Startseite - Reihen Vom Server Benutzerprofil laden Im Server Benutzerprofil speichern diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6159ff33..daef9929 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,6 +1,6 @@ - Ταινίες + Ταινίες Προφίλ Αρχική Γλώσσα @@ -32,7 +32,7 @@ Χρώμα Κειμένου Φωτεινότητα κειμένου Βιβλιοθήκης - Σειρές + Σειρές Είδη Συλλογή Συλλογές @@ -108,7 +108,7 @@ Χωρίς διαθέσιμη ενημέρωση Έναρξη από εδώ Έναρξη - Άτομα + Άτομα Ταχύτητα αναπαραγωγής Αναπαραγωγή Λίστα αναπαραγωγής @@ -168,7 +168,7 @@ Επιτυχής Γονεική αξιολόγηση Χρόνος εκτέλεσης - Επιπρόσθετα + Επιπρόσθετα Άλλο diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4e6ba9e6..21a8da59 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -36,7 +36,7 @@ Sin resultados Ninguno Créditos finales - Personas + Personas Reproducir Reproducción Lista de reproducción @@ -63,13 +63,12 @@ Sugerencias Cambiar servidor Cambiar - Tráiler Tráiler Tráilers - Series + Series Interfaz Versión Vídeo @@ -83,7 +82,7 @@ Pulsa el botón central de la cruceta para reproducir/pausar Éxito Duración - Extras + Extras Otro Otros @@ -134,7 +133,7 @@ Marcar como no visto Marcar como visto Más como esto - Películas + Películas Siguiente Sin datos Sin grabaciones programadas @@ -480,7 +479,7 @@ 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 + Favoritos%s Ajustar Recortar Rellenar diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 339ec1c6..35fc6d46 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -62,7 +62,7 @@ Suurim bitikiirus Veel sarnaseid Veel - Filmid + Filmid Nimi Järgmisena esitamisel Andmed puuduvad @@ -72,7 +72,7 @@ Määramata Väljajuhatus Rada - Inimesed + Inimesed Esitusi Esita siit kohast Esita @@ -123,7 +123,7 @@ Valmis Vanemlik hinnang Kestus - Lisad + Lisad Muu Muud @@ -220,14 +220,13 @@ Salvesta programm Salvesta telesari Parima hinnanguga vaatamata sisu - Treiler Treilerid DVR-i kava Telekava - Telesaated + Telesaated Video skaleerimine Video Videod @@ -463,7 +462,7 @@ Kuvamise eelseadistused Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks Vali kodulehe/avalehe read ja pildid - Märgi %s lemmikuks + Märgi %s lemmikuks %s minut %s minutit diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b0c7dcd7..19b40df2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -48,7 +48,7 @@ Marquer comme non vu Marquer comme vu Débit maximal - Films + Films Nom À suivre Aucune donnée @@ -58,7 +58,7 @@ Aucun Générique de fin Chemin - Personnes + Personnes Lire à partir d’ici Lire Vitesse de lecture @@ -98,7 +98,6 @@ Suggestions Changer de serveur Changer - Bande-annonce Bandes-annonces @@ -106,7 +105,7 @@ Saison Saisons - Séries TV + Séries TV Inconnu Mises à jour Version @@ -182,7 +181,7 @@ %1$d ans Mettre la police en italique Classification parentale - Bonus + Bonus Dans les coulisses @@ -479,7 +478,7 @@ 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 + Favori %s %s minute %s minutes diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f292b891..44730910 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -62,7 +62,7 @@ Maximális bitráta Hasonlóak Továbbiak - Filmek + Filmek Név Következő Nincs adat @@ -72,7 +72,7 @@ Nincs Stáblista Elérési út - Stábtagok + Stábtagok Lejátszások száma Lejátszás innen Lejátszás @@ -120,7 +120,6 @@ Váltás másik szerverre Váltás Legjobbra értékelt, nem megnézett - Előzetes Előzetesek @@ -129,7 +128,7 @@ Műsorújság Évad Évadok - Sorozatok + Sorozatok Kezelőfelület Ismeretlen Frissítések @@ -152,7 +151,7 @@ Sikeres Korhatár-besorolás Hossz - Extrák + Extrák Egyéb Egyebek diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c5199c5b..ae0e29de 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -62,7 +62,7 @@ Bitrate maks Tampilkan yang serupa Lainnya - Film + Film Nama Selanjutnya Tidak ada data @@ -72,7 +72,7 @@ Tidak ada Outro Berkas - Orang + Orang Total Pemutaran Putar dari sini Putar @@ -121,7 +121,6 @@ Ganti Server Ganti Terbaik Belum Ditonton - Trailer Trailer @@ -129,7 +128,7 @@ Panduan Season Season - Acara TV + Acara TV Antarmuka Tidak Diketahui Pembaruan @@ -152,7 +151,7 @@ Berhasil Rating Orang Tua Durasi - Ekstra + Ekstra Lainnya @@ -446,7 +445,7 @@ Preset tampilan Preset bawaan untuk kustomisasi cepat semua baris Pilih baris dan gambar di halaman beranda - Favoritkan %s + Favoritkan %s Artis %s menit diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4c017645..5790b179 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -34,7 +34,7 @@ Caricamento… Libreria Diretta TV - Film + Film Nessun risultato Bitrate massimo Segna come non visto @@ -69,7 +69,7 @@ Casuale Stagione Stagioni - Serie TV + Serie TV Interfaccia Sconosciuto Aggiornamenti @@ -122,7 +122,7 @@ Nascondi informazioni debug Colore testo Opacità testo - Extra + Extra Altro Altri @@ -164,7 +164,7 @@ Nome Prossimo Episodio Nessuna registrazione programmata - Persone + Persone Mostra informazioni debug Data di Aggiunta Data di riproduzione @@ -174,7 +174,6 @@ Suggerimenti Cambia server Cambia - Trailer I trailer @@ -480,7 +479,7 @@ Carica dal client web Usa immagine delle serie Scegli righe e immagini nella home page - Preferito %s + Preferito %s Adatta Ritaglia Riempi diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index f2efcdb9..e6027e7a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -51,7 +51,6 @@ עונות עונה - טריילר טריילר טריילרים @@ -231,7 +230,7 @@ הצלחה דירוג הורים אורך - תכנים נוספים + תכנים נוספים אחר @@ -265,7 +264,7 @@ קטעים קצרים - מועדפים: %s + מועדפים: %s המכשיר תומך ב־AC3/Dolby Digital הגדרות מתקדמות ממשק מתקדם diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index adb54e50..299e3501 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -150,7 +150,6 @@ Mainīt serverus Pārslēgt Augstāk novērtētie un neredzētie - Treileris Treileri @@ -188,7 +187,7 @@ Veiksmīgi Vecuma ierobežojums Ilgums - Ekstras + Ekstras Cits @@ -244,7 +243,7 @@ - Izlase %s + Izlase %s %s lejupielāžu %s lejupielāde diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d892ebf3..6b21a3ea 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -59,7 +59,7 @@ Maks bitrate Mer som dette Mer - Filmer + Filmer Navn Neste Ingen data @@ -70,7 +70,7 @@ Ingen Outro Filbane - Personer + Personer Antall avspillinger Spill av herfra Spill av @@ -119,7 +119,6 @@ Bytt server Bytt bruker Best vurderte usette - Trailer Trailere @@ -128,7 +127,7 @@ Programoversikt Sesong Sesonger - TV-serier + TV-serier Grensesnitt Ukjent Oppdateringer @@ -152,7 +151,7 @@ Fullført Aldersgrense Spilletid - Ekstramateriale + Ekstramateriale Annet diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1c0fdb70..ee1c3734 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -62,7 +62,7 @@ Max. bitsnelheid Soortgelijk Meer - Films + Films Naam Volgende Geen informatie @@ -72,7 +72,7 @@ Geen Aftiteling Locatie - Personen + Personen Aantal keer bekeken Vanaf hier afspelen Afspelen @@ -121,7 +121,6 @@ Andere server kiezen Kiezen Best beoordeeld niet-bekeken - Trailer Trailer Trailers @@ -130,7 +129,7 @@ Gids Seizoen Seizoenen - Series + Series Vormgeving Onbekend Updates @@ -153,7 +152,7 @@ Voltooid Kijkwijzer Duur - Extra\'s + Extra\'s Overig @@ -362,7 +361,7 @@ Zoom uit Negeren Vraag om overslaan - Favoriete %s + Favoriete %s Duur Rij toevoegen Alle leeftijden diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index db921e51..995e26f5 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -64,7 +64,7 @@ Maks bitrate Meir som dette Meir - Filmar + Filmar Namn Neste Ingen data @@ -74,7 +74,7 @@ Ingen oppdatering tilgjengeleg Outro Filveg - Folk + Folk Antall avspelingar Spel av herfrå Spel av @@ -122,7 +122,6 @@ Byt servar Byt brukar Best vurderte usette - Trailer Trailerar @@ -131,7 +130,7 @@ Programoversikt Sesong Sesongar - TV-seriar + TV-seriar Grensesnitt Ukjend Oppdateringar @@ -155,7 +154,7 @@ Fullført Aldersgrense Speltid - Ekstra + Ekstra Anna @@ -378,7 +377,7 @@ Klarte ikkje starta talegjenkjenning Tidsavbrot for talegjenkjenning Prøv på nytt - Favoritt %s + Favoritt %s Oppløysingsbyte Lokal Spel trailer diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5eebccae..c83a91bd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,7 @@ Maksymalny bitrate Podobne Więcej - Filmy + Filmy Następnie Brak danych Brak wyników @@ -33,7 +33,7 @@ Pomiń Data dodania Data dodania odcinka - Seriale + Seriale Sezon Sezony Napisy @@ -42,7 +42,6 @@ Zmień serwer Zmień Najlepiej oceniane nieobejrzane - Zwiastun Zwiastun Zwiastuny @@ -261,13 +260,13 @@ Skalowanie obrazu Przezroczystość czcionki Adres URL do sprawdzenia aktualizacji - Obsada + Obsada Brak Nieznany %1$d lat temu Powodzenie Ocena rodzicielska - Dodatki + Dodatki Inne @@ -433,7 +432,7 @@ Zaloguj się do Seerr Użytkownik Jellyfin Użytkownik lokalny - Ulubione %s + Ulubione %s %s dzień %s dni diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bf4a7c94..83649b09 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -149,7 +149,6 @@ Trocar de servidores Trocar Não assistidos com melhor avaliação - Trailer Trailer Trailers @@ -187,7 +186,7 @@ Sucesso Classificação Parental Duração - Extras + Extras Outro Outros @@ -238,7 +237,7 @@ Curtas Curtas - Favoritar %s + Favoritar %s %s download %s downloads diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fd038556..0c6ac0d7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -59,7 +59,7 @@ Bitrate Máximo Mais como isto Mais - Filmes + Filmes Nome A Seguir Sem dados @@ -68,7 +68,7 @@ Nenhuma atualização disponível Nenhum Créditos finais - Pessoas + Pessoas Reproduções Reproduzir a partir daqui Reproduzir @@ -119,7 +119,6 @@ Mudar Servidores Mudar Mais Votados Não Assistidos - Trailer Trailer Trailers @@ -129,7 +128,7 @@ Guia Temporada Temporadas - Séries TV + Séries TV Interface Desconhecido Atualizações @@ -152,7 +151,7 @@ Êxito Classificação Parental Duração - Extras + Extras Outro Outros @@ -480,7 +479,7 @@ Predefinições embarcadas para definir todas as linhas rapidamente Escolha as linhas e imagens na página inicial Diminuir o tamanho de todos os cartões - Favoritos %s + Favoritos %s %s minuto %s minutos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 46a38847..06643481 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -358,13 +358,13 @@ Evaluare parentală Timp de execuție - Extra + Extra Clipuri - %s favorit + %s favorit Temă aplicație Verifică automat dacă există actualizări Dispozitivul acceptă formatele AC3/Dolby Digital diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f94fc36b..254ea466 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -52,14 +52,14 @@ Отметить как просмотренное Максимальный битрейт Похожее - Фильмы + Фильмы Имя Далее Нет информации Нет результатов Нет доступных обновлений Путь - Люди + Люди Показать отладочную инфомацию воспроизведения Скорость воспроизведения Воспроизведение @@ -95,7 +95,6 @@ Сменить сервер Сменить Лучшее непросмотренное - Трейлер Трейлер Трейлера @@ -105,7 +104,7 @@ Телегид Сезон Сезоны - Телешоу + Телешоу Неизвестно Обновления Версия @@ -124,7 +123,7 @@ Успех Возрастной рейтинг Продолжительность - Дополнительно + Дополнительно Другое Других diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ede412b5..8b8ba7a2 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -61,7 +61,7 @@ Maximálny bitrate Navrhované Viac - Filmy + Filmy Meno Nasleduje Žiadne dáta @@ -71,7 +71,7 @@ Žiadne Outro Cesta - Ľudia + Ľudia Počet prehrávaní Prehrať odtiaľto Prehrať @@ -143,7 +143,6 @@ Zmeniť servery Zmeniť Najlepšie hodnotené Nepozreté - Trailer Trailery @@ -183,7 +182,7 @@ Úspech Rodičovské hodnotenie Dĺžka - Bonusový materiál + Bonusový materiál Ostatné @@ -250,7 +249,7 @@ - Obľúbené %s + Obľúbené %s %s stiahnutý %s stiahnuté diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 71e1bc71..99719417 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -146,7 +146,6 @@ Zamenjaj strežnik Preklopi Najbolje ocenjeno, neogledano - Napovednik Napovedniki @@ -180,7 +179,7 @@ Uspeh Starševska ocena Trajanje izvajanja - Dodatki + Dodatki Ostalo @@ -214,7 +213,7 @@ Kratki filmi - Dodaj %s med priljubljene + Dodaj %s med priljubljene %s prenos %s prenosa diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 1e44bdf8..6bc35660 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -31,7 +31,7 @@ Inga resultat Namn Laddar… - Filmer + Filmer Spela Spellista Spellistor @@ -73,7 +73,6 @@ Byt server Byt Topprankade osedda - Förhandsvisning Förhandsvisningar @@ -82,7 +81,7 @@ TV Guide Säsong Säsonger - TV-serier + TV-serier Gränssnitt Okänd Uppdateringar @@ -105,7 +104,7 @@ Lyckades Åldersgräns Speltid - Extramaterial + Extramaterial Annat @@ -205,7 +204,7 @@ Ingen Slutsekvens Sökväg - Medverkande + Medverkande Spelningar Spela härifrån Visa felsökningsinfo för uppspelning @@ -541,7 +540,7 @@ Spela som? Visa favoritkanaler först Sortera kanaler efter senaste visade - Favoritmarkera %s + Favoritmarkera %s Spela bildspel Sånger Föredra att visa logotyper för titlar diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 51760b13..22bc4601 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -147,7 +147,6 @@ สลับเซิร์ฟเวอร์ สวิตช์ รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู - ตัวอย่าง ตัวอย่าง @@ -181,7 +180,7 @@ สำเร็จ การให้คะแนนโดยผู้ปกครอง รันไทม์ - เพิ่มเติม + เพิ่มเติม อื่นๆ @@ -215,7 +214,7 @@ หนังสั้น - ชื่นชอบ %s + ชื่นชอบ %s อุปกรณ์รองรับระบบเสียง AC3/Dolby Digital การตั้งค่าขั้นสูง UI ขั้นสูง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7fba3690..aee10d57 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -149,7 +149,6 @@ Sunucu değiştir Değiştir İzlenmemiş En İyiler - Fragman Fragman Fragmanlar @@ -185,7 +184,7 @@ Başarılı Yaş Derecelendirmesi Süre - Ekstralar + Ekstralar Diğer Ekstra Diğer Ekstralar @@ -471,7 +470,7 @@ Ayarlar kaydedildi Görüntü ön ayarları Tüm satırları hızlıca stilize etmek için hazır ayarlar - Favori %s + Favori %s Ana sayfadaki satırları ve görselleri seçin Sığdır Kırp diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f7835121..a4bf494f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -153,7 +153,6 @@ Перемикнути сервер Перемкнути Топ непереглянутих - Трейлер Трейлери @@ -193,7 +192,7 @@ Успіх Вікова оцінка Час виконання - Додатково + Додатково Інше @@ -475,7 +474,7 @@ Зменшити Тривалість слайд-шоу Відтворення відео під час слайд-шоу - Улюблені %s + Улюблені %s день дні diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 84d69985..8bfd9a08 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -62,7 +62,7 @@ 最大比特率 类似推荐 更多 - 电影 + 电影 名称 即将播放 无数据 @@ -72,7 +72,7 @@ 无 片尾 路径 - 演职人员 + 演职人员 播放次数 从此处播放 播放 @@ -121,7 +121,6 @@ 切换服务器 切换 高分未看 - 预告片 预告片 @@ -129,7 +128,7 @@ 指南 季 季 - 电视节目 + 电视节目 界面 未知 更新 @@ -152,7 +151,7 @@ 成功 内容分级 播放时长 - 附加内容 + 附加内容 其他 @@ -446,7 +445,7 @@ 显示预设 可快速设置所有行样式的内置预设 选择首页上的行和图片 - 收藏的 %s + 收藏的 %s 适应 裁剪 填充 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4523a787..88da1023 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -9,7 +9,7 @@ 載入中… 類似作品 更多 - 電影 + 電影 名稱 接下來播放 無資料 @@ -52,7 +52,6 @@ 推薦 切換伺服器 切換 - 預告片 預告片 @@ -189,7 +188,7 @@ 標記為已觀看 最大位元率 片尾 - 演職人員 + 演職人員 從此處播放 顯示播放偵錯資訊 播放 @@ -202,7 +201,7 @@ 製作公司 高評分未觀看 錄影排程 - 電視劇 + 電視劇 介面 %1$d 歲 轉碼播放 @@ -243,7 +242,7 @@ 精選片段 - 特別收錄 + 特別收錄 試看片段 @@ -480,7 +479,7 @@ 長方底框 優先使用 MPV 僅 HDR 使用 ExoPlayer 播放 - 我的最愛的 %s + 我的最愛的 %s Wholphin 預設樣式 Wholphin 緊湊樣式 劇集縮圖樣式 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd511f29..b060bf82 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,7 +66,7 @@ Max bitrate More like this More - Movies + Movies Name Next Up No data @@ -78,7 +78,7 @@ Only Forced Subtitles Outro Path - People + People Play Count Play from here Play @@ -145,13 +145,12 @@ Switch servers Switch Top Rated Unwatched - Trailer Trailers DVR Schedule Guide Season Seasons - TV Shows + TV Shows Interface Unknown Updates @@ -176,19 +175,19 @@ Parental Rating Runtime - Extras - Other - Behind the Scenes - Theme Songs - Theme Videos - Clips - Deleted Scenes - Interviews - Scenes - Samples - Featurettes - Shorts - Favorite %s + Extras + Other + Behind the Scenes + Theme Songs + Theme Videos + Clips + Deleted Scenes + Interviews + Scenes + Samples + Featurettes + Shorts + Favorite %s Trailers @@ -732,8 +731,8 @@ - @string/movies - @string/tv_shows + @string/movies_title + @string/tv_shows_title @string/photos From 8534c58426c5c836a0ab0844c8f4be65454d5d80 Mon Sep 17 00:00:00 2001 From: jankg Date: Mon, 8 Jun 2026 20:20:36 +0000 Subject: [PATCH 032/475] Translated using Weblate (German) Currently translated at 100.0% (575 of 575 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 236 ++++++++++++------------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 77c93b6a..594d584e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -10,7 +10,7 @@ Bitrate Geboren Aufnahme abbrechen - Serien Aufnahme abbrechen + Serienaufnahme abbrechen Abbrechen Kapitel Sammlung @@ -48,7 +48,7 @@ Server wechseln Trailer - + Trailer Staffel Staffeln @@ -61,8 +61,8 @@ %1$d Jahre alt Extras - Szenen - + Szene + Szenen %s Download @@ -80,16 +80,16 @@ Automatisch auf Aktualisierungen prüfen Auf Aktualisierungen prüfen Einstellungen - %1$s Wählen - Bilder Cache leeren - Community Bewertung + %1$s wählen + Bilder-Cache leeren + Community-Bewertung Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden. Regie von %1$s Deaktiviert Gefundene Server Herunterladen… Aktiviert - Serveradresse oder URL inklusive Port eingeben + Serveradresse oder URL eingeben Episoden Fehler beim Laden der Sammlung %1$s Extern @@ -100,13 +100,13 @@ Gehe zur Serie Gehe zu Wiedergabesteuerung ausblenden - Debug Infos ausblenden + Debug-Infos ausblenden Ausblenden Home Sofort Intro Bibliothek - Lizenz Informationen + Lizenzinformationen Lädt… Gesamte Serie als gesehen markieren? Gesamte Serie als ungesehen markieren? @@ -121,7 +121,7 @@ Wiedergabegeschwindigkeit Wiedergabe Vorschau - Benutzerprofil Einstellungen + Benutzerprofil-Einstellungen Warteschlange Rückblick Zuletzt veröffentlicht @@ -129,7 +129,7 @@ Serie aufnehmen Nicht favorisieren - Debug Infos anzeigen + Debug-Infos anzeigen Zufällig Überspringen @@ -156,15 +156,15 @@ Altersfreigabe Sonstiges - + Sonstiges Hinter den Kulissen - Gelöschte Szenen - + Gelöschte Szene + Gelöschte Szenen %d Eintrag @@ -174,15 +174,15 @@ Erweiterte Oberfläche Design Verzögerung vor dem nächsten Titel - Automatisch als nächstes wiedergeben + Automatisch als Nächstes wiedergeben Aktualisierung installieren Installierte Version - Max. Anzahl Objekte auf der Startseite + Max. Anzahl Objekte je Startseiten-Reihe #ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ Live TV - Outro - Wiedergabe Anzahl - Wiedergabe Debug Infos anzeigen + Abspann + Wiedergabeanzahl + Wiedergabe Debug-Infos anzeigen Wiedergabeliste Wiedergabelisten Zeige als nächstes @@ -190,11 +190,11 @@ Name Studios Aufnahmeplan - Video Skalierung + Videoskalierung Video Videos Nach Sendedatum sortiert - Drücke D-Pad Mitte zum Pausieren/Fortsetzen + Drücke Steuerkreuz Mitte zum Pausieren/Fortsetzen Kursivschrift Laufzeit @@ -214,12 +214,12 @@ Interviews - Beispiele - + Beispiel + Beispiele - Begleitfilme - + Begleitfilm + Begleitfilme Short @@ -227,23 +227,23 @@ Trifft nur auf Serien zu Libass für ASS Untertitel verwenden - Direktes abspielen von PGS Untertitel + Direktes Abspielen von PGS-Untertiteln Immer zu Stereo heruntermischen - FFmpeg decoder Modul verwenden - Standard Skalierung + FFmpeg-Dekoder-Modul verwenden + Standard-Skalierung Klicken um die Seite zu wechseln Einschlafschutz - Themenmusik wiedergeben + Titelmusik wiedergeben Ausgewählte Tabs merken Nützlich zum Debuggen Sende Absturzmeldung - Zurückspringen wenn wiedergabe fortgesetzt wird + Zurückspringen, wenn Wiedergabe fortgesetzt wird Werbung überspringen Vorspringen - Überspringe Intro verhalten - Überspringe Outro verhalten - Überspringe Vorschau verhalten - Überspringe Rückblick verhalten + Überspringe Intro + Überspringe Abspann + Überspringe Vorschau + Überspringe Rückblick Aktualisierung verfügbar Verwendete URL um auf Aktualisierungen zu prüfen Aktualisierungs-URL @@ -253,32 +253,32 @@ Stil der Umrandung Farbe der Umrandung Deckkraft des Hintergrundes - Hintergrund Stil - Untertitel Stil + Hintergrund-Stil + Untertitelstil Hintergrundfarbe Zurücksetzen Fettschrift - MPV: Hardware dekodierung verwenden - Deaktivieren, wenn abstürze auftreten + MPV: Hardware-Dekodierung verwenden + Deaktivieren, wenn Abstürze auftreten MPV Optionen ExoPlayer Optionen - + Wähle die Standardobjekte zur Anzeige aus, andere werden ausgeblendet - Navigationsmenü Objekte anpassen - Navigationsmenüseiten beim Fokus wechseln + Navigationsmenü anpassen + Navigationsmenü-Seiten beim Fokus wechseln Wiedergabe überschreiben - Zeitleiste Sprünge + Zeitleistensprünge Sende Logs zum aktuellen Server Versucht zum letzten verbundenen Server zu senden Zurückspringen - Wiedergabe backend - Aktiviere erneut wiedergeben in als nächstes - Segment Überspringen - Werbung Überspringen - Vorschau Überspringen - Rückblick Überspringen - Outro Überspringen - Intro Überspringen + Wiedergabe-Backend + Aktiviere erneut wiedergeben in als Nächstes + Segment überspringen + Werbung überspringen + Vorschau überspringen + Rückblick überspringen + Abspann überspringen + Intro überspringen Filter Jahr Jahrzehnt @@ -286,7 +286,7 @@ Dolby Vision Dolby Atmos Endet um %1$s - Server Adresse eingeben + Serveradresse eingeben Keine Server gefunden Medieninformation Abgespielt @@ -306,7 +306,7 @@ Abstände Seitenverhältnis Details anzeigen - Bild Typ + Bildertyp Automatisch Verwende Benutzername/Passwort Anmelden @@ -324,7 +324,7 @@ Keine Trailer Trailer abspielen Lokal - detaillierte Protokollierung + Detailliertes Protokoll Login via Server Drücke die mittlere Taste zur Bestätigung Größe des Festplatten-Caches für Bilder (MB) @@ -343,21 +343,21 @@ Hz Nur erzwungene Untertitel Beginne - verarbeite + Verarbeite Mikrofonzugriff erforderlich Netzwerkfehler - Netzwerk Zeitüberschreitung + Netzwerk-Zeitüberschreitung Serverfehler - unbekannter Fehler - wiederholen - 🇩🇪 Layout + Unbekannter Fehler + Wiederholen + Layout Abtastrate DTS Entdecke Anfrage Ausstehend - Seerr Integration - Seerr server hinzugefügt + Seerr-Integration + Seerr-Server hinzugefügt Passwort Benutzername URL @@ -371,31 +371,31 @@ Im Trend Starten der Spracherkennung fehlgeschlagen Zurück wählen um abzubrechen - MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst das in den Einstellungen ändern. + MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst dies in den Einstellungen ändern. Sprachsuche Sprechen, um zu suchen - Ignoriert geräte kompatibilität - kommende Filme - kommende Serien - Direktes abspielen von Dolby Vision Profil 7 + Ignoriert Gerätekompatibilitätsprüfungen + Kommende Filme + Kommende Serien + Direktes Abspielen von Dolby Vision Profil 7 Untertitelverzögerung Sortiere Kanäle nach zuletzt geschaut - Favoriten als erstes anzeigen + Favoriten als Erstes anzeigen Hintergrundstil Bildwiederholraten-Anpassung Keine Spracheingabe erkannt Spracherkennungsfehler - Aufnahme-Fehler - Keine Spracherkennung + Audio-Aufnahme-Fehler + Keine Spracheingabe erkannt Spracherkennung beschäftigt Spracherkennung ist abgelaufen Videobereich - Video-Bereichstyp + Videobereich-Typ Farbkorrektur-Software Spur-Auswahl löschen Seerr-Server entfernen AV1 Software-Dekodierung - anamorph + Anamorph Farbraum Farbübertragung Primärfarben @@ -403,11 +403,11 @@ Referenzbilder Randgröße Auflösungswechsel - Gaststars + Gastdarsteller Ab Anfang - Keine Fotos - Links Rotieren - Rechts Rotieren + Keine weiteren Fotos + Links rotieren + Rechts rotieren Rot Grün Blau @@ -430,7 +430,7 @@ HDR-Untertitelstil Dauer der Diashow Videos während der Diashow abspielen - Maximale Tage in \"Als nächstes\" + Max. Tage in \"Als Nächstes\" Quick Connect %s Minute @@ -443,7 +443,7 @@ Keine Begrenzung Reihe hinzufügen Genres in %1$s - kürzlich veröffentlicht in %1$s + Kürzlich veröffentlicht in %1$s Höhe Auf alle Reihen anwenden Füge Reihe für %1$s hinzu @@ -455,25 +455,25 @@ Für Folgen Vorlagen um schnell alle Reihen zu gestalten Wähle Reihen und Bilder auf der Homepage - Starte anschließend - Serien Vorschaubilder - Episoden Vorschaubilder - Einloggen zum Seerr Server - Jellyfin User - Zeige Media Management Optionen + Starte nach + Serien-Vorschaubilder + Episoden-Vorschaubilder + Am Seerr-Server einloggen + Jellyfin-Nutzer + Medienmanagement-Optionen anzeigen Größe für alle Karten reduzieren Einstellungen gespeichert Maximale Altersfreigabe Keine Begrenzung Ohne Altersbeschränkung - Ab %s Jahre + Bis %s Jahre Bildschirmschoner Bildschirmschoner-Einstellungen Bildschirmschoner starten - Länge + Dauer Fotos - Arten einschließen - Für welche Arten von Einträgen sollen Bilder angezeigt werden + Medientypen einschließen + Für welche Medientypen sollen Bilder angezeigt werden Angepasst Zugeschnitten Ausgefüllt @@ -484,7 +484,7 @@ FFmpeg statt der vorhandenen Decoder verwenden FFmpeg Decoder nie verwenden Am Ende einer Playlist - Während der Credits/Outro + Während des Abspanns Weiß Hellgrau Dunkelgrau @@ -494,14 +494,14 @@ Umrandung Schatten MPV bevorzugen - ExoPlayer für die HDR Wiedergabe verwenden + ExoPlayer für HDR-Wiedergabe verwenden Poster (2:3) 16:9 4:3 Quadrat (1:1) Primär Bild mit dynamischen Farben - API Schlüssel + API-Schlüssel Lokaler Benutzer Suche %s Design-Vorlagen @@ -512,7 +512,7 @@ Wholphin Kompakt Niedrigste Niedrig - mittel + Mittel Hoch Voll Lila @@ -520,20 +520,20 @@ Schwarz Nur Bild Aktuell - Eingebauten Bildschirmschoner nutzen + Integrierten Bildschirmschoner nutzen Bist du sicher, dass du diesen Eintrag löschen möchtest? - Schauspieler + Darsteller Komponist Autor - Nebenrolle + Gastdarsteller Produzent Dirigent - Liedtext-Schreiber + Liedtexter Künstler - Favorisierte %s - Startseite - Reihen - Vom Server Benutzerprofil laden - Im Server Benutzerprofil speichern + Favorisierte %s + Startseiten-Reihen + Vom Server-Benutzerprofil laden + Im Server-Benutzerprofil speichern Vom Webclient laden Größe für alle Karten erhöhen Vorschaubilder benutzen @@ -541,10 +541,10 @@ Arrangeur Ingenieur Dunkelblau - Transparenz des Bilduntertitels + Deckkraft des Bilduntertitels Vorschaubild - Serien Vorschaubilder verwenden - + Serien-Vorschaubilder verwenden + Ersteller Textmarker @@ -559,43 +559,43 @@ Sofortmix Zu Album wechseln Zur Warteschlange hinzufügen - Album Interpret + Albumkünstler Album Beliebte Lieder - Als nächstes abspielen + Als Nächstes abspielen Musikvideos Liedtexte Liedtexte ausblenden Liedtexte einblenden Lied hat Liedtext Aus Warteschlange entfernen - Album Cover anzeigen + Albumcover anzeigen Visualizer anzeigen Hintergrundbild anzeigen Abspielen als? Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung. Fortfahren Verschiedene Arten - Logos anstatt Titelnamen anzeigen + Bevorzugt Logo statt Titel anzeigen Wholphin aktualisiert auf %s Von \"Weiterschauen\" entfernen - Serie aus \"Als nächstes Anschauen\" entfernen + Serie aus \"Als Nächstes anschauen\" entfernen Mehr anzeigen - TV Serien entdecken + TV-Serien entdecken Filme entdecken Studios in %1$s - Serien anzeigen, die von \"Als nächstes Anschauen\" entfernt wurden + Serien anzeigen, die von \"Als Nächstes anschauen\" entfernt wurden Nie Zuletzt wiedergegeben - Direkte Wiedergabe mit Libass - Direkte Wiedergabe mit ExoPlayer (eingebaut) - Einbrennen/Auf Server transkodieren - SSA/ASS Untertitel Wiedergabe + Direkte Wiedergabe mit libass + Direkte Wiedergabe mit ExoPlayer (integriert) + Einbrennen/auf Server transkodieren + SSA/ASS Untertitel-Wiedergabe Externer Player Systemstandard Zeige %1$s mehr \"Überspringen\" Verhalten - Für Intros, Credits, etc + Für Intros, Credits, etc. Kinomodus Abspielen mit Transkodieren @@ -604,7 +604,7 @@ Alle Ergebnisse in einer gemeinsamen Liste anzeigen Ergebnisse nach Kategorie anzeigen Ergebnisse - Verlange Anmeldung für Profil + Profil benötigt Anmeldung Sichtbar Versteckt Raster @@ -618,8 +618,8 @@ Mit Klick Details zeigen Sterne Tomaten - Zeige Bewertungen - Anpassen was angezeigt wird + Bewertungen anzeigen + Anpassen, was angezeigt wird Sortieren - In unterschiedliche Zeilen aufteilen + In unterschiedliche Reihen aufteilen From ddbec0da734dc3a0b7fd5d30867670b1d52e7518 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 14 Jun 2026 19:55:01 -0400 Subject: [PATCH 033/475] Fix issues from #1530 --- .../java/com/github/damontecres/wholphin/data/ExtrasItem.kt | 2 +- .../main/java/com/github/damontecres/wholphin/ui/Formatting.kt | 2 +- .../github/damontecres/wholphin/ui/components/PlayButtons.kt | 2 +- .../wholphin/ui/detail/discover/DiscoverMovieDetails.kt | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index 30af65cf..8e17ec0d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -94,7 +94,7 @@ val ExtraType.stringRes: Int when (this) { ExtraType.UNKNOWN -> R.string.other_extras_title ExtraType.CLIP -> R.string.clips_title - ExtraType.TRAILER -> R.string.trailers + ExtraType.TRAILER -> R.string.trailers_title ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title ExtraType.INTERVIEW -> R.string.interviews_title 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 aa00f814..9ae6cd50 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 @@ -224,7 +224,7 @@ fun formatTypeName(type: BaseItemKind): Int = BaseItemKind.RECORDING -> TODO() BaseItemKind.SEASON -> R.string.tv_seasons BaseItemKind.STUDIO -> R.string.studios - BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TRAILER -> R.string.trailers_title BaseItemKind.TV_CHANNEL -> R.string.channels BaseItemKind.TV_PROGRAM -> TODO() BaseItemKind.USER_ROOT_FOLDER -> TODO() 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 eea3c9f9..4137759d 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 @@ -399,7 +399,7 @@ fun TrailerButton( } else if (trailers.size == 1) { R.string.play_trailer } else { - R.string.trailers + R.string.trailers_title }, iconStringRes = R.string.fa_film, enabled = trailers.isNotEmpty(), 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 d6bcd152..bbc85042 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 @@ -390,7 +390,7 @@ fun TrailerRow( modifier = modifier, ) { Text( - text = stringResource(R.string.trailers), + text = stringResource(R.string.trailers_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 594d584e..06663981 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -530,7 +530,7 @@ Dirigent Liedtexter Künstler - Favorisierte %s + Favorisierte %s Startseiten-Reihen Vom Server-Benutzerprofil laden Im Server-Benutzerprofil speichern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b060bf82..5240223b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,7 +145,7 @@ Switch servers Switch Top Rated Unwatched - Trailers + Trailers DVR Schedule Guide Season From e17ae3eb8a835f7386aa97603b88c1627cb035ec Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 18 Jun 2026 11:17:19 -0400 Subject: [PATCH 034/475] Use Wholphin-specific dispatchers (#1583) ## Description Switches all usages of `kotlinx.coroutines.Dispatchers` to `WholphinDispatchers`. `WholphinDispatchers` provides an easier and more reliable mechanism to use a test dispatcher in unit tests. For regular app usage, `WholphinDispatchers` just delegates to `Dispatchers`. There are no user facing changes ### Related issues None ### Testing Basic emulator testing & unit tests ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 8 +++--- .../wholphin/data/ItemPlaybackRepository.kt | 4 +-- .../wholphin/data/ServerRepository.kt | 8 +++--- .../wholphin/services/BackdropService.kt | 8 +++--- .../wholphin/services/DatePlayedService.kt | 6 ++--- .../wholphin/services/DeviceProfileService.kt | 4 +-- .../wholphin/services/LatestNextUpService.kt | 6 ++--- .../wholphin/services/LatestNextUpWorker.kt | 4 +-- .../wholphin/services/MusicService.kt | 20 +++++++------- .../wholphin/services/NavDrawerService.kt | 4 +-- .../wholphin/services/PlayerFactory.kt | 6 ++--- .../wholphin/services/RefreshRateService.kt | 4 +-- .../wholphin/services/RememberedTabService.kt | 6 ++--- .../wholphin/services/ScreensaverService.kt | 4 +-- .../wholphin/services/SuggestionsCache.kt | 4 +-- .../wholphin/services/SuggestionsWorker.kt | 8 +++--- .../wholphin/services/ThemeSongPlayer.kt | 6 ++--- .../wholphin/services/UpdateChecker.kt | 12 ++++----- .../wholphin/services/UserSwitchListener.kt | 4 +-- .../wholphin/services/hilt/AppModule.kt | 14 +++++----- .../wholphin/services/hilt/DatabaseModule.kt | 2 +- .../damontecres/wholphin/ui/Extensions.kt | 16 ++++++------ .../wholphin/ui/cards/GenreCard.kt | 1 - .../wholphin/ui/cards/StudioCard.kt | 2 -- .../ui/components/CollectionFolderView.kt | 12 ++++----- .../wholphin/ui/components/GenreCardGrid.kt | 6 ++--- .../wholphin/ui/components/ItemGrid.kt | 6 ++--- .../ui/components/RecommendedContent.kt | 4 +-- .../wholphin/ui/components/StudioCardGrid.kt | 4 +-- .../ui/components/VoiceInputManager.kt | 4 +-- .../wholphin/ui/detail/CardGrid.kt | 4 +-- .../wholphin/ui/detail/HomeRowGrid.kt | 6 ++--- .../wholphin/ui/detail/PlaylistDetails.kt | 6 ++--- .../detail/collection/CollectionViewModel.kt | 10 +++---- .../detail/discover/DiscoverMovieViewModel.kt | 4 +-- .../discover/DiscoverSeriesViewModel.kt | 4 +-- .../ui/detail/episode/EpisodeViewModel.kt | 6 ++--- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 4 +-- .../ui/detail/music/AlbumDetailsPage.kt | 4 +-- .../ui/detail/music/ArtistDetailsPage.kt | 4 +-- .../ui/detail/music/NowPlayingButtons.kt | 1 - .../ui/detail/music/NowPlayingViewModel.kt | 4 +-- .../ui/detail/music/SongListDisplay.kt | 1 - .../ui/detail/series/SeriesViewModel.kt | 22 ++++++++-------- .../wholphin/ui/discover/SeerrRequestsPage.kt | 4 +-- .../wholphin/ui/main/HomeViewModel.kt | 12 ++++----- .../wholphin/ui/main/SearchPage.kt | 6 ++--- .../ui/main/settings/HomeSettingsViewModel.kt | 4 +-- .../wholphin/ui/playback/PlaybackViewModel.kt | 26 +++++++++---------- .../ui/playback/SubtitleSearchUtils.kt | 4 +-- .../playback/overlay/PlaybackDebugOverlay.kt | 4 +-- .../ui/preferences/LocaleChoiceDialog.kt | 1 - .../ui/preferences/NavDrawerPreference.kt | 4 +-- .../damontecres/wholphin/ui/setup/PinEntry.kt | 1 - .../wholphin/ui/setup/SwitchUserViewModel.kt | 6 ++--- .../ui/slideshow/SlideshowViewModel.kt | 6 ++--- .../wholphin/ui/util/LocalClock.kt | 4 +-- .../util/CoroutineContextApiClient.kt | 5 ++-- .../damontecres/wholphin/util/RequestPager.kt | 3 +-- .../util/TrackActivityPlaybackListener.kt | 9 +++---- .../wholphin/util/WholphinDispatchers.kt | 20 ++++++++++++++ .../LatestNextUpSchedulerServiceTest.kt | 12 ++++----- .../services/LatestNextUpServiceTests.kt | 10 +++---- .../services/SuggestionServiceTest.kt | 10 +++---- .../SuggestionsSchedulerServiceTest.kt | 10 +++---- .../damontecres/wholphin/ui/BasicUiTests.kt | 15 ++++------- .../damontecres/wholphin/ui/TestModule.kt | 4 +-- .../wholphin/util/WholphinDispatchersTest.kt | 21 +++++++++++++++ 68 files changed, 252 insertions(+), 226 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.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 b5bc119f..6aa749f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -60,10 +60,10 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first @@ -170,7 +170,7 @@ class MainActivity : AppCompatActivity() { viewModel.serverRepository.currentUserFlow .onEach { user -> - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (user?.hasPin == true) { window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) } else { @@ -184,7 +184,7 @@ class MainActivity : AppCompatActivity() { screensaverService.keepScreenOn .onEach { keepScreenOn -> Timber.v("keepScreenOn: %s", keepScreenOn) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -383,7 +383,7 @@ class MainActivity : AppCompatActivity() { } fun changeDisplayMode(modeId: Int) { - lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { + lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes if (attrs.preferredDisplayModeId != modeId) { Timber.d("Switch preferredDisplayModeId to %s", modeId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index b2dedf26..c3afbc33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -7,7 +7,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.StreamChoiceService -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -108,7 +108,7 @@ class ItemPlaybackRepository itemId: UUID, sourceId: UUID, ): ItemPlayback? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverRepository.currentUser?.let { user -> val itemPlayback = ItemPlayback( 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 05cc9bd3..4f3ccbd1 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,9 +9,9 @@ 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.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -109,7 +109,7 @@ class ServerRepository }.build() } val currentUser = CurrentUser(updatedServer, updatedUser) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = currentUser _currentUserDto.value = userDto } @@ -222,7 +222,7 @@ class ServerRepository suspend fun removeUser(user: JellyfinUser) { if (current.value?.user?.id == user.id) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = null } userPreferencesDataStore.updateData { @@ -241,7 +241,7 @@ class ServerRepository suspend fun removeServer(server: JellyfinServer) { if (current.value?.server?.id == server.id) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = null } userPreferencesDataStore.updateData { 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 1bf34929..8025a6f6 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 @@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +55,7 @@ class BackdropService * Update the backdrop to use the specified item */ suspend fun submit(item: BaseItem) = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val imageUrl = if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride @@ -76,7 +76,7 @@ class BackdropService suspend fun submit( itemId: String, imageUrl: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { _backdropFlow.update { it.copy( @@ -132,7 +132,7 @@ class BackdropService } suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { if (imageUrl.isNullOrBlank()) { return@withContext ExtractedColors.DEFAULT } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index 382ac22e..ceb84695 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -56,7 +56,7 @@ class DatePlayedService adjacentTo = item.id, limit = 1, ) - return scope.async(Dispatchers.IO) { + return scope.async(WholphinDispatchers.IO) { val premiereDate = item.data.premiereDate try { val result = @@ -92,7 +92,7 @@ class DatePlayedService * the previous episode's last played timestamp, and the episode's premiere date */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val seriesId = item.data.seriesId return@withContext if (seriesId != null) { datePlayedCache diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 4405092e..ce4ea7dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -3,10 +3,10 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -37,7 +37,7 @@ class DeviceProfileService prefs: PlaybackPreferences, serverVersion: ServerVersion?, ): DeviceProfile = - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { mutex.withLock { val newConfig = DeviceProfileConfiguration( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 2bc9943f..f67dd100 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -8,8 +8,8 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.util.LocalDateTimeSerializer +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.supportItemKinds -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore @@ -143,14 +143,14 @@ class LatestNextUpService resume: List, nextUp: List, ): List = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val start = System.currentTimeMillis() val semaphore = Semaphore(3) val deferred = nextUp .filter { it.data.seriesId != null } .map { item -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { try { semaphore.withPermit { datePlayedService.getLastPlayed(item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 04706b94..01ed9dd2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -16,12 +16,12 @@ import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -92,7 +92,7 @@ class LatestNextUpSchedulerService ) // Exposed for testing - internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO init { serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index c339a8dd..de99d579 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -24,10 +24,10 @@ import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -177,9 +177,9 @@ class MusicService items: BlockingList, startIndex: Int, shuffled: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.setMediaItems(emptyList()) player.shuffleModeEnabled = shuffled } @@ -199,7 +199,7 @@ class MusicService items .filter { it.type == BaseItemKind.AUDIO } .map(::convert) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled updateQueueSize() @@ -216,7 +216,7 @@ class MusicService ) { if (item.type == BaseItemKind.AUDIO) { val mediaItem = convert(item) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (index != null) { player.addMediaItem(index, mediaItem) } else { @@ -293,7 +293,7 @@ class MusicService */ private suspend fun updateQueueSize() { // val ids = -// withContext(Dispatchers.Default) { +// withContext(WholphinDispatchers.Default) { // (0..() .onEach { message -> message.data?.let { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { when (it.command) { PlaystateCommand.STOP -> { stop() 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 21253e1e..b8322efe 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 @@ -13,11 +13,11 @@ 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.WholphinDispatchers import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -209,7 +209,7 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } } 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 7f4886b4..6cdeeb34 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 @@ -29,6 +29,7 @@ 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.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler @@ -36,7 +37,6 @@ 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.withContext import okhttp3.OkHttpClient import timber.log.Timber @@ -62,7 +62,7 @@ class PlayerFactory backend: PlayerBackend, prefs: PlaybackPreferences, ): PlayerCreation { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() @@ -138,7 +138,7 @@ class PlayerFactory .build() .apply { assHandler?.init(this) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { setAudioAttributes( AudioAttributes .Builder() 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 91df9518..174c103b 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 @@ -8,9 +8,9 @@ import android.os.Looper import android.view.Display import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -38,7 +38,7 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") return@withContext diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt index 515caad4..a3b5b795 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt @@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.RememberedTab -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -16,7 +16,7 @@ class RememberedTabService private val rememberedTabDao: RememberedTabDao, ) { suspend fun getRememberedTab(itemId: String): Int? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverRepository.currentUser?.rowId?.let { userId -> rememberedTabDao.getRememberedTab(userId, itemId)?.index } @@ -26,7 +26,7 @@ class RememberedTabService itemId: String, tabIndex: Int, ): Unit = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverRepository.currentUser?.rowId?.let { userId -> rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex)) } 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 6069c00a..871a20e2 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 @@ -10,10 +10,10 @@ 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 +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -224,7 +224,7 @@ class ScreensaverService if (index > pager.lastIndex) index = 0 } } - }.flowOn(Dispatchers.Default).cancellable() + }.flowOn(WholphinDispatchers.Default).cancellable() private suspend fun createPager(): ApiRequestPager { val prefs = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index 0921a1be..eedbffc5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import com.mayakapps.kache.ObjectKache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.ExperimentalSerializationApi @@ -43,7 +43,7 @@ class SuggestionsCache private val memoryCache = InMemoryKache(maxSize = 8) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } private fun cacheKey( 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 800d31fd..aa64058d 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 @@ -9,9 +9,9 @@ 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.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -81,7 +81,7 @@ class SuggestionsWorker .takeIf { it > 0 } ?: AppPreference.HomePageItems.defaultValue.toInt() - val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") + val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions") if (requestedParentId != null && requestedItemKind != null) { Timber.d( "Fetching on-demand suggestions for parent=%s kind=%s", @@ -128,7 +128,7 @@ class SuggestionsWorker supervisorScope { supportedViews .map { view -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { runCatching { Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) fetchAndCacheSuggestions( @@ -293,7 +293,7 @@ class SuggestionsWorker limit = freshLimit, ) } - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { val contextual = contextualDeferred.await() val random = randomDeferred.await() val fresh = freshDeferred.await() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index e1b6cf1b..036ca505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow @@ -102,7 +102,7 @@ class ThemeSongPlayer Codec.Audio.FLAC, ), ) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.apply { stop() volume = volumeLevel @@ -127,7 +127,7 @@ class ThemeSongPlayer scope.launch { mutex.withLock { state.value.job?.cancelAndJoin() - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { Timber.v("Stopping theme song") player.stop() } 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 b56d8a7d..7fbcbe53 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 @@ -21,8 +21,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.Version +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -131,7 +131,7 @@ class UpdateChecker suspend fun getRelease(version: Version): Release? { val url = "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" - return withContext(Dispatchers.IO) { + return withContext(WholphinDispatchers.IO) { val request = Request .Builder() @@ -146,7 +146,7 @@ class UpdateChecker * Get the latest released version */ suspend fun getLatestRelease(updateUrl: String): Release? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val request = Request .Builder() @@ -199,7 +199,7 @@ class UpdateChecker release: Release, callback: DownloadCallback, ) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { cleanup() val request = Request @@ -210,7 +210,7 @@ class UpdateChecker okHttpClient.newCall(request).execute().use { if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { callback.contentLength(it.body.contentLength()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -396,7 +396,7 @@ suspend fun copyTo( while (bytes >= 0) { out.write(buffer, 0, bytes) bytesCopied += bytes - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { callback.bytesDownloaded(bytesCopied) } bytes = input.read(buffer) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 2565f077..536dfb40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -12,9 +12,9 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext @@ -56,7 +56,7 @@ class UserSwitchListener user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList() Timber.i("Switching locale to %s", localeList) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { AppCompatDelegate.setApplicationLocales(localeList) } 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 25dd1be3..06607cf9 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 @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,7 +15,6 @@ 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 import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin @@ -42,21 +42,21 @@ annotation class AuthOkHttpClient annotation class StandardOkHttpClient /** - * A [CoroutineScope] with [Dispatchers.IO] + * A [CoroutineScope] with [WholphinDispatchers.IO] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope /** - * A [CoroutineScope] with [Dispatchers.Default] + * A [CoroutineScope] with [WholphinDispatchers.Default] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope /** - * [Dispatchers.IO] + * [WholphinDispatchers.IO] * * @see IoCoroutineScope */ @@ -65,7 +65,7 @@ annotation class DefaultCoroutineScope annotation class IoDispatcher /** - * [Dispatchers.Default] + * [WholphinDispatchers.Default] * * @see DefaultCoroutineScope */ @@ -156,7 +156,7 @@ object AppModule { @Provides @Singleton @IoDispatcher - fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO @Provides @Singleton @@ -168,7 +168,7 @@ object AppModule { @Provides @Singleton @DefaultDispatcher - fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default @Provides @Singleton diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 97718089..29688664 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -44,7 +44,7 @@ object DatabaseModule { ).addMigrations(Migrations.Migrate2to3) // .setQueryCallback({ sqlQuery, args -> // Timber.v("sqlQuery=$sqlQuery, args=$args") -// }, Dispatchers.IO.asExecutor()) +// }, WholphinDispatchers.IO.asExecutor()) .build() @Provides 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 2c633c35..31ad597a 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 @@ -33,9 +33,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest @@ -359,34 +359,34 @@ suspend fun showToast( context: Context, text: CharSequence, duration: Int, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, duration).show() } suspend fun showToast( context: Context, text: CharSequence, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, Toast.LENGTH_LONG).show() } /** - * Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] */ fun CoroutineScope.launchIO( context: CoroutineContext = ExceptionHandler(), start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -): Job = launch(context = Dispatchers.IO + context, start = start, block = block) +): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block) /** - * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] */ fun CoroutineScope.launchDefault( context: CoroutineContext = ExceptionHandler(), start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -): Job = launch(context = Dispatchers.Default + context, start = start, block = block) +): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block) /** * Converts a UUID to the format used server-side (ie without hyphens). @@ -410,7 +410,7 @@ fun logTab( ACRA.errorReporter.putCustomData("tabInfo", info) } -suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) +suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block) fun Modifier.dimAndBlur(enabled: Boolean) = this.ifElse( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index a32fece9..654999f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -30,7 +30,6 @@ import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Genre -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt index fb89a58b..c128285d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -32,9 +32,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.Studio -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index eb769626..dd6ed7ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -70,13 +70,13 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -251,7 +251,7 @@ class CollectionFolderViewModel fun saveViewOptions(viewOptions: ViewOptions) { _state.update { it.copy(viewOptions = viewOptions) } - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) if (!viewOptions.showBackdrop) { backdropService.clearBackdrop() @@ -295,7 +295,7 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) = viewModelScope.launch(Dispatchers.IO) { + ) = viewModelScope.launch(WholphinDispatchers.IO) { _state.update { it.copy( items = DataLoadingState.Loading, @@ -437,7 +437,7 @@ class CollectionFolderViewModel ) suspend fun positionOfLetter(letter: Char): Int? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val sort = state.value.sortAndDirection val filter = state.value.filter val request = @@ -461,7 +461,7 @@ class CollectionFolderViewModel itemId: UUID, played: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) @@ -474,7 +474,7 @@ class CollectionFolderViewModel itemId: UUID, favorite: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) 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 18e335f1..00dfbb56 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 @@ -33,13 +33,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -142,7 +142,7 @@ class GenreViewModel } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val request = GetGenresRequest( parentId = itemId, @@ -197,7 +197,7 @@ suspend fun getGenreImageMap( val semaphore = Semaphore(4) genres .map { genreId -> - scope.async(Dispatchers.IO) { + scope.async(WholphinDispatchers.IO) { semaphore.withPermit { val item = GetItemsRequestHandler diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index d4609d79..9a188a6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -38,13 +38,13 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -150,7 +150,7 @@ class ItemGridViewModel itemId: UUID, played: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) @@ -163,7 +163,7 @@ class ItemGridViewModel itemId: UUID, favorite: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) 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 dd264d0f..7dc5d11a 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 @@ -48,13 +48,13 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -173,7 +173,7 @@ class RecommendedViewModel } private fun fetchSuggestions() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(WholphinDispatchers.IO) { val limit = userPreferencesService .getCurrent() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index cf3e7ed9..3f424bad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -32,11 +32,11 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -113,7 +113,7 @@ class StudioViewModel } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val request = GetStudiosRequest( userId = serverRepository.currentUser?.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt index d2d6fa32..57874b0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt @@ -17,10 +17,10 @@ import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import androidx.core.content.ContextCompat import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher @@ -175,7 +175,7 @@ class VoiceInputManager private fun provideMainDispatcher(): CoroutineDispatcher = try { - Dispatchers.Main.immediate + WholphinDispatchers.Main.immediate } catch (_: IllegalStateException) { // Fallback for unit tests where Main dispatcher is not installed handler.asCoroutineDispatcher() 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 1d42848b..b399766c 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 @@ -73,7 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -217,7 +217,7 @@ fun CardGrid( { letter: Char -> scope.launch(ExceptionHandler()) { val jumpPosition = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { letterPosition.invoke(letter) } Timber.d("Alphabet jump to $jumpPosition") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 51c496dc..7948662c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -53,12 +53,12 @@ import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -166,7 +166,7 @@ class HomeRowGridViewModel itemId: UUID, played: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) (state.value.loading as? HomeRowLoadingState.Success)?.let { (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) @@ -179,7 +179,7 @@ class HomeRowGridViewModel itemId: UUID, favorite: Boolean, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) (state.value.loading as? HomeRowLoadingState.Success)?.let { (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 0557edbc..070268cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -107,12 +107,12 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -316,14 +316,14 @@ class PlaylistViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 3b778ce6..1576b745 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -40,12 +40,12 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -210,7 +210,7 @@ class CollectionViewModel supervisorScope { val jobs = typesInCollection.map { type -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { val title = ResStringProvider(formatTypeName(type)) val result = try { @@ -359,7 +359,7 @@ class CollectionViewModel ) suspend fun letterPosition(letter: Char): Int = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val sort = state.value.sortAndDirection val filter = state.value.itemFilter val request = @@ -387,7 +387,7 @@ class CollectionViewModel itemId: UUID, played: Boolean, position: RowColumn?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) if (itemId == state.value.collection?.id) { refreshCollection() @@ -400,7 +400,7 @@ class CollectionViewModel itemId: UUID, favorite: Boolean, position: RowColumn?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (itemId == state.value.collection?.id) { refreshCollection() 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 60e6a816..14440539 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 @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -31,7 +32,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -73,7 +73,7 @@ class DiscoverMovieViewModel } private fun fetchAndSetItem(): Deferred = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { try { val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) _state.update { it.copy(movie = DataLoadingState.Success(movie)) } 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 4a13c65f..96ba1efa 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 @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -30,7 +31,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -71,7 +71,7 @@ class DiscoverSeriesViewModel } private fun fetchAndSetItem(): Deferred = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { try { val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } 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 1c6249da..ef94620a 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 @@ -23,13 +23,13 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -126,7 +126,7 @@ class EpisodeViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) fetchAndSetItem() } @@ -134,7 +134,7 @@ class EpisodeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) fetchAndSetItem() } 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 e4cc19ec..c9b72031 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 @@ -59,11 +59,11 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions import eu.wewox.programguide.ProgramGuideItem import eu.wewox.programguide.rememberSaveableProgramGuideState -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -319,7 +319,7 @@ fun TvGuideGridContent( var focusedProgramIndex by rememberInt(0) LaunchedEffect(onFocus, focusedProgramIndex) { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { val program = programs.programs.getOrNull(focusedProgramIndex) if (program != null) { val channelIndex = channels.indexOfFirst { it.id == program.channelId } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 293807fb..55dc6b4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -86,12 +86,12 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -242,7 +242,7 @@ class AlbumViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) val album = api.userLibraryApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index e87419aa..d8806b59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -81,12 +81,12 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -254,7 +254,7 @@ class ArtistViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) val artist = api.userLibraryApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt index db5bbf4c..f8dc0ccd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons -import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackFaButton import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index f1cc9225..a076bd29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -27,13 +27,13 @@ import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -95,7 +95,7 @@ class NowPlayingViewModel private val lyricCache = InMemoryKache(20) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } init { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 4e5fa329..011bb8ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.DenseListItem import androidx.tv.material3.Icon -import androidx.tv.material3.ListItem import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Text 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 bdce9a20..499dc44f 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 @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted @@ -50,7 +51,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -137,7 +137,7 @@ class SeriesViewModel val episodeListDeferred = if (seriesPageType == SeriesPageType.OVERVIEW) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { if (seasonEpisodeIds != null) { loadEpisodesInternal( seasonEpisodeIds.seasonId, @@ -292,7 +292,7 @@ class SeriesViewModel series: BaseItem, seasonNum: Int?, ): Deferred> = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { Timber.v("getSeasons for %s", series.id) val request = GetItemsRequest( @@ -405,7 +405,7 @@ class SeriesViewModel itemId: UUID, played: Boolean, listIndex: Int?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) listIndex?.let { refreshEpisode(itemId, listIndex) @@ -440,7 +440,7 @@ class SeriesViewModel itemId: UUID, favorite: Boolean, listIndex: Int?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (listIndex != null) { refreshEpisode(itemId, listIndex) @@ -452,13 +452,13 @@ class SeriesViewModel fun setSeasonWatched( seasonId: UUID, played: Boolean, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) updateSeries() } fun setWatchedSeries(played: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) updateSeries() } @@ -466,7 +466,7 @@ class SeriesViewModel fun refreshEpisode( itemId: UUID, listIndex: Int, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { val eps = state.value.episodes if (eps is EpisodeList.Success) { eps.episodes.refreshItem(listIndex, itemId) @@ -481,7 +481,7 @@ class SeriesViewModel * Play whichever episode is next up for series or else the first episode */ fun playNextUp() { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { val result by api.tvShowsApi.getNextUp(seriesId = seriesId) val nextUp = result.items.firstOrNull() ?: api.tvShowsApi @@ -491,7 +491,7 @@ class SeriesViewModel ).content.items .firstOrNull() if (nextUp != null) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { navigateTo(Destination.Playback(BaseItem(nextUp))) } } else { @@ -582,7 +582,7 @@ class SeriesViewModel val result = peopleInEpisodeCache .get(item.id) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { val list = api.userLibraryApi .getItem(item.id) 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 d83f840b..50f2464c 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 @@ -35,8 +35,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -77,7 +77,7 @@ class SeerrRequestsViewModel val requests = mediaRequests.mapNotNull { request -> if (request.media?.tmdbId != null) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { val type = SeerrItemType.fromString(request.type) when (type) { 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 d6dd37e2..feaf8d04 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 @@ -28,9 +28,9 @@ import com.github.damontecres.wholphin.ui.util.EmptyStringProvider import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -117,7 +117,7 @@ class HomeViewModel val deferred = settings.rows .map { row -> - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { Timber.v("Fetching row: %s", row) try { @@ -220,9 +220,9 @@ class HomeViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } @@ -230,9 +230,9 @@ class HomeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } 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 ecb0e74d..ea978971 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 @@ -103,9 +103,9 @@ import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.SearchRelevance +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -253,7 +253,7 @@ class SearchViewModel } private fun searchCombined(query: String) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { try { val request = GetItemsRequest( @@ -477,7 +477,7 @@ fun SearchPage( ) { if (!searchClicked) return@LaunchedEffect - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { // Want to focus on the first successful row after all of the ones before it are finished searching val results = if (isLibraryTab) { 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 22a55f12..4fb8398e 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 @@ -43,10 +43,10 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -129,7 +129,7 @@ class HomeSettingsViewModel .let { state -> state.rows .map { row -> - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { homeSettingsService.fetchDataForRow( 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 f197998f..fbc694d5 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 @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile @@ -79,7 +80,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -242,7 +242,7 @@ class PlaybackViewModel Timber.d("Selected backend: %s", playerBackend) if (currentPlayer.value?.backend != playerBackend) { Timber.i("Switching player backend to %s", playerBackend) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { disconnectPlayer() } @@ -421,7 +421,7 @@ class PlaybackViewModel positionMs: Long, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val item = when (playlistItem) { is PlaylistItem.Intro -> playlistItem.item @@ -581,7 +581,7 @@ class PlaybackViewModel trickPlayInfo = trickPlayInfo, ) } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { changeStreams( item, itemPlaybackToUse, @@ -612,7 +612,7 @@ class PlaybackViewModel userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { val itemId = item.id val currentPlayback = state.value.currentPlayback @@ -779,7 +779,7 @@ class PlaybackViewModel ) } } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { // TODO, don't need to release & recreate when switching streams this@PlaybackViewModel.activityListener?.let { it.release() @@ -850,7 +850,7 @@ class PlaybackViewModel subtitleIndex: Int?, userInitiated: Boolean, ): Boolean = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") @@ -859,7 +859,7 @@ class PlaybackViewModel if (externalSubtitle == null) { val result = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { TrackSelectionUtils.createTrackSelections( onMain { player.trackSelectionParameters }, onMain { player.currentTracks }, @@ -1124,7 +1124,7 @@ class PlaybackViewModel MediaSegmentType.INTRO -> prefs.skipIntros MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { val newSegment = when (behavior) { SkipSegmentBehavior.AUTO_SKIP -> { @@ -1318,7 +1318,7 @@ class PlaybackViewModel override fun onPlayerError(error: PlaybackException) { Timber.e(error, "Playback error") - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + viewModelScope.launch(WholphinDispatchers.Main + ExceptionHandler()) { state.value.currentPlayback?.let { when (it.playMethod) { PlayMethod.TRANSCODE -> { @@ -1346,7 +1346,7 @@ class PlaybackViewModel enableDirectPlay = false, enableDirectStream = false, ) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.prepare() player.play() } @@ -1367,7 +1367,7 @@ class PlaybackViewModel .subscribe() .onEach { message -> message.data?.let { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { when (it.command) { PlaystateCommand.STOP -> { release() @@ -1422,7 +1422,7 @@ class PlaybackViewModel * Atomically update [currentMediaInfo] */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { _state.update { it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 5892b01a..95d52a1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -183,7 +183,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (wasPlaying) { player.play() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt index 1420ffb6..2aecdc65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt @@ -36,7 +36,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupportReason import com.github.damontecres.wholphin.util.TrackType -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -90,7 +90,7 @@ fun PlaybackDebugOverlay( ) val memoryUsed by produceState("") { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { while (isActive) { val runtime = Runtime.getRuntime() val total = runtime.totalMemory() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt index a3e50a85..b352c550 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.tv.material3.Text import com.github.damontecres.wholphin.R diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index 4f2fb968..e2024aa2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update @@ -317,7 +317,7 @@ class NavDrawerPreferencesViewModel return@launchDefault } val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao .getNavDrawerPinnedItems(user) .associateBy { it.itemId } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt index f9f22591..ef97620c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt @@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.theme.WholphinTheme diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index fce4a01c..8b8c159a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -13,13 +13,13 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -119,7 +119,7 @@ class SwitchUserViewModel } fun trySwitchUser(user: JellyfinUser): Deferred = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) @@ -234,7 +234,7 @@ class SwitchUserViewModel } private suspend fun getUsers(): List = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val api = jellyfin.createApi(server.url) val knownUsers = serverDao 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 9744c9de..8db01751 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 @@ -27,13 +27,13 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -377,7 +377,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for image %s", it.image.id) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -406,7 +406,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", 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 213dc725..8664d604 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 @@ -8,7 +8,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.github.damontecres.wholphin.ui.getTimeFormatter -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -34,7 +34,7 @@ data class Clock( fun ProvideLocalClock(content: @Composable () -> Unit) { val clock = remember { Clock() } LaunchedEffect(Unit) { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { while (isActive) { val now = LocalDateTime.now() val time = getTimeFormatter().format(now) 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 index 3ea6bdf9..412130d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -1,7 +1,6 @@ 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 @@ -21,7 +20,7 @@ import kotlin.coroutines.CoroutineContext */ class CoroutineContextApiClient( private val client: ApiClient, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClient() { override val baseUrl: String? get() = client.baseUrl @@ -65,7 +64,7 @@ class CoroutineContextApiClient( class CoroutineContextApiClientFactory( private val factory: OkHttpFactory, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClientFactory, SocketConnectionFactory { override fun create( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt index b478338b..d8a34de6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import com.google.common.cache.CacheBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -85,7 +84,7 @@ abstract class RequestPager( get() = totalCount private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { + scope.launch(ExceptionHandler() + WholphinDispatchers.IO) { fetchPageBlocking(position, false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 86ba1425..30241845 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback 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.extensions.playStateApi @@ -34,7 +33,7 @@ class TrackActivityPlaybackListener( private val player: Player, private val getState: () -> PlaybackItemState?, ) : Player.Listener { - private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) private val task: TimerTask = object : TimerTask() { override fun run() { @@ -57,7 +56,7 @@ class TrackActivityPlaybackListener( PlaybackStartInfo( canSeek = true, itemId = state.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying }, playMethod = state.playMethod, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, @@ -117,11 +116,11 @@ class TrackActivityPlaybackListener( launch("saveActivity") { getState.invoke()?.let { state -> val calcPosition = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { (if (position >= 0) position else player.currentPosition) } if (calcPosition > 0) { - val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + val isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying } Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt new file mode 100644 index 00000000..2a466742 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt @@ -0,0 +1,20 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainCoroutineDispatcher + +/** + * Analogous to [kotlinx.coroutines.Dispatchers], but easier to swap for testing + */ +object WholphinDispatchers { + @Suppress("ktlint:standard:property-naming") + val Main: MainCoroutineDispatcher get() = kotlinx.coroutines.Dispatchers.Main + + @Suppress("ktlint:standard:property-naming") + var IO: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO + internal set + + @Suppress("ktlint:standard:property-naming") + var Default: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + internal set +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt index d4f4d436..256ee9d8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt @@ -8,17 +8,17 @@ import androidx.lifecycle.LifecycleRegistry import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule @@ -36,14 +36,14 @@ class LatestNextUpSchedulerServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() = WholphinDispatchers.reset() private fun createService() = LatestNextUpSchedulerService( @@ -56,7 +56,7 @@ class LatestNextUpSchedulerServiceTest { @Test fun cancels_latestNextUp_work_when_user_null() = - runTest { + runTest(testDispatcher) { createService() currentUser.value = null diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt index 4c605df4..d4590cf7 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -5,15 +5,15 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import kotlinx.serialization.UseSerializers import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient @@ -56,14 +56,14 @@ class LatestNextUpServiceTests { @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockApi.tvShowsApi } returns mockTvShowsApi } @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index 436fd360..403a2715 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -8,19 +8,19 @@ import androidx.work.WorkManager import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto @@ -48,12 +48,12 @@ class SuggestionServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) } @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() io.mockk.unmockkObject(GetItemsRequestHandler) } 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 0d52db15..f66e7292 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 @@ -11,19 +11,19 @@ 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.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import com.google.common.util.concurrent.ListenableFuture import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -43,14 +43,14 @@ class SuggestionsSchedulerServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() = WholphinDispatchers.reset() private fun createService() = SuggestionsSchedulerService( 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 index 027afb98..2e2c3a89 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -27,18 +27,17 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset 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 @@ -105,11 +104,7 @@ class BasicUiTests { @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 + WholphinDispatchers.configure(TestModule.testDispatcher) hiltRule.inject() screensaverService = mockk(relaxed = true) @@ -130,7 +125,7 @@ class BasicUiTests { @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } /** 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 index e2026a4d..c4a54ec2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -36,6 +36,7 @@ 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.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,7 +47,6 @@ 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 @@ -225,7 +225,7 @@ object TestDatabaseModule { .allowMainThreadQueries() .setQueryCallback({ sqlQuery, args -> Timber.v("sqlQuery=$sqlQuery, args=$args") - }, Dispatchers.IO.asExecutor()) + }, WholphinDispatchers.IO.asExecutor()) .build() @Provides diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt new file mode 100644 index 00000000..daeb934d --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt @@ -0,0 +1,21 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.configure(testDispatcher: TestDispatcher) { + Dispatchers.setMain(testDispatcher) + IO = testDispatcher + Default = testDispatcher +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.reset() { + Dispatchers.resetMain() + IO = Dispatchers.IO + Default = Dispatchers.Default +} From 0a1f2a5fef6614c6e9f8359b2ab03e1cc1deb951 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 18 Jun 2026 12:00:39 -0400 Subject: [PATCH 035/475] Update renovate settings (#1588) Just updating the renovate config to separate out android TV dependencies & use digests for GHA. --- renovate.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index bba12817..5f9021d9 100644 --- a/renovate.json +++ b/renovate.json @@ -11,7 +11,14 @@ }, { "groupName": "Github Actions", - "matchManagers": ["github-actions"] + "matchManagers": ["github-actions"], + "pinDigests": true + }, + { + "groupName": "Android TV Dependencies", + "matchPackageNames": [ + "androidx.tv:**" + ] }, { "groupName": "Androidx Media3", From 3fd2cbbbd6674d3e0e658ad5a2b95bfb9e8c1fa7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:58:34 -0400 Subject: [PATCH 036/475] Pin dependencies (#1589) --- .github/actions/setup/action.yml | 6 +++--- .github/workflows/main.yml | 4 ++-- .github/workflows/pr.yml | 8 ++++---- .github/workflows/release.yml | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d7b2f290..85070a55 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: zulu java-version: '21' @@ -21,7 +21,7 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@v4 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 with: packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6cb20eeb..63648874 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -76,7 +76,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - name: Upload mapping files - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8607abd8..c4ff149c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,22 +16,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: '3.14' - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 build: runs-on: ubuntu-latest needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0520630..5aa37b70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -58,14 +58,14 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 - name: Upload mapping files - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | From 2afac3a1a5c18fbd92df687b3c3f5bcb4067df3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:59:54 -0400 Subject: [PATCH 037/475] Update openapi.generator to v7.23.0 (#1542) --- 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 e9c3cca9..030c9ee7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ paletteKtx = "1.0.0" assMedia = "0.4.0" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.22.0" +openapi-generator = "7.23.0" runner = "1.7.0" wholphin-extensions = "0.1.3" From 8e0798ee16eb3257f1644cc384d5c99e56f44184 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 19 Jun 2026 11:06:35 -0400 Subject: [PATCH 038/475] Unit tests for PlaybackViewModel & several services (#1592) ## Description This PR contains only tests, there are no user facing changes. Adds tests for playback covering: - Playing next, previous, or within the queue - Media segments (intro/credit) - Playing intros before media Adds basic unit tests for track selection. This uses a new "test track" builder which can model various files and build them as represented (differently) by the server, ExoPlayer, and MPV. In future, this will allow for testing of more complex files. Also add unit tests for services covering things like: - Fetching extras - Deleting media - Server/user management & auth ### Related issues Will help with testing/verifying some issues like #1475, #1548 ### Testing Ran the unit tests! ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Playlist.kt | 2 + .../wholphin/services/PlayerFactory.kt | 6 + .../wholphin/ui/playback/PlaybackViewModel.kt | 26 +- .../wholphin/services/ExtrasServiceTest.kt | 187 +++++ .../services/MediaManagementServiceTest.kt | 190 +++++ .../wholphin/services/ServerRepositoryTest.kt | 485 +++++++++++ .../StreamChoiceServiceTest.kt} | 5 +- .../test/TestSelectionTrackExamples.kt | 114 +++ .../wholphin/test/TestSuggestionsLogic.kt | 30 - .../wholphin/test/TestTrackSelection.kt | 36 +- .../damontecres/wholphin/test/TestTracks.kt | 476 +++++++++++ .../damontecres/wholphin/test/TestUtils.kt | 59 ++ .../github/damontecres/wholphin/ui/Utils.kt | 4 + .../ui/playback/PlaybackViewModelTests.kt | 756 ++++++++++++++++++ 14 files changed, 2313 insertions(+), 63 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt rename app/src/test/java/com/github/damontecres/wholphin/{test/TestStreamChoiceService.kt => services/StreamChoiceServiceTest.kt} (99%) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 68771f6a..9f10f58c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -13,6 +13,8 @@ data class Playlist( ) { companion object { const val MAX_SIZE = 100 + + fun fromMedia(list: List) = Playlist(list.map { PlaylistItem.Media(it) }) } } 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 6cdeeb34..0517497c 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 @@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences @@ -211,6 +212,11 @@ class PlayerFactory ), ) } + + fun createMediaSession(player: Player) = + MediaSession + .Builder(context, player) + .build() } val Player.isReleased: Boolean 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 fbc694d5..6a531119 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 @@ -192,14 +192,18 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow + // Exposed for testing + val initJob: Job + init { - viewModelScope.launchIO { - addCloseable { - screensaverService.keepScreenOn(false) - disconnectPlayer() + initJob = + viewModelScope.launchIO { + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } + init() } - init() - } } private fun disconnectPlayer() { @@ -269,10 +273,7 @@ class PlaybackViewModel player, preferences.appPreferences.playbackPreferences, ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() + mediaSession = playerFactory.createMediaSession(sessionPlayer) } /** @@ -1054,7 +1055,8 @@ class PlaybackViewModel } // Variables for tracking segment state - private var segmentJob: Job? = null + // Exposed for testing + internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf() private val outroShownSegments = mutableSetOf() @@ -1269,7 +1271,7 @@ class PlaybackViewModel } } } else { - Timber.w("Attempting to play next, but there are no more items") + Timber.w("Attempting to play previous, but there is none") return@launchDefault } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt new file mode 100644 index 00000000..5988d5d6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt @@ -0,0 +1,187 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.res.Resources +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ImageType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ExtrasServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk() + private val mockApiClient = mockk() + private val mockImageUrlService = mockk() + + private val mockUserLibraryApi = mockk() + private val mockResources = mockk() + + private val extrasService = ExtrasService(mockApiClient, mockContext, mockImageUrlService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApiClient.userLibraryApi } returns mockUserLibraryApi + every { mockContext.resources } returns mockResources + every { mockResources.getQuantityString(R.plurals.interviews, any()) } returns "Interviews" + every { mockResources.getQuantityString(R.plurals.clips, any()) } returns "Clips" + every { mockResources.getQuantityString(R.plurals.interviews, any(), any()) } returns "Interviews" + every { mockResources.getQuantityString(R.plurals.clips, any(), any()) } returns "Clips" + + every { mockResources.getQuantityString(R.plurals.items, any(), any()) } returns "Items" + + every { mockImageUrlService.getItemImageUrl(any(), ImageType.PRIMARY, any(), any(), any()) } returns + "https://localhost/image.jpg" + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + @Test + fun `Test grouping extras`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(1, extras.size) + val extra = extras.first() + Assert.assertTrue(extra is ExtrasItem.Group) + extra as ExtrasItem.Group + val extrasIds = extra.items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extra.type, ExtraType.INTERVIEW) + } + + @Test + fun `Test grouping extras with multiple types`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Group) + } + val extrasIds = extras.flatMap { (it as ExtrasItem.Group).items.map { it.id } }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test extras not grouped`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ), + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Single) + } + val extrasIds = extras.map { (it as ExtrasItem.Single).item.id }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test mixed grouping`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + Assert.assertTrue(extras[0] is ExtrasItem.Single) + Assert.assertTrue(extras[1] is ExtrasItem.Group) + + val extrasIds = listOf((extras[0] as ExtrasItem.Single).item.id) + (extras[1] as ExtrasItem.Group).items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt new file mode 100644 index 00000000..e88c2b81 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.preferences.InterfacePreferences +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.operations.LibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.UserPolicy +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class MediaManagementServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk() + private val mockApiClient = mockk() + private val mockServerRepository = mockk() + private val mockUserPreferencesService = mockk() + + private val mockLibraryApi = mockk() + + private val mediaManagementService = + MediaManagementService(mockContext, mockApiClient, mockServerRepository, mockUserPreferencesService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { mockApiClient.libraryApi } returns mockLibraryApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private val movie = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = true, + ), + ) + private val recording = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.RECORDING, + canDelete = true, + ), + ) + + private val movieCannotDelete = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = false, + ), + ) + + private val userWithLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk { + every { enableLiveTvManagement } returns true + }, + ) + + private val userWithoutLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk { + every { enableLiveTvManagement } returns false + }, + ) + + private val prefsEnabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = true + }.build() + }.build() + + private val prefsDisabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = false + }.build() + }.build() + + @Test + fun `Test canDelete`() { + Assert.assertTrue(mediaManagementService.canDelete(movie, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(movie, prefsDisabled)) + Assert.assertFalse(mediaManagementService.canDelete(movieCannotDelete, prefsEnabled)) + } + + @Test + fun `Test canDelete recording`() { + every { mockServerRepository.currentUserDto } returns userWithLiveTv + Assert.assertTrue(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @Test + fun `Test canDelete recording no permission`() { + every { mockServerRepository.currentUserDto } returns userWithoutLiveTv + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test successful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } returns successResponse(Unit) + + val deletedItems = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Success) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(1, deletedItems.size) + Assert.assertEquals(deletedItems[0].item.id, movie.id) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test unsuccessful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } throws InvalidStatusException(403) + + val deletedItems = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Error) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(0, deletedItems.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt new file mode 100644 index 00000000..06402b88 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -0,0 +1,485 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ServerRepositoryTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + val temporaryFolder: TemporaryFolder = + TemporaryFolder + .builder() + .assureDeletion() + .build() + + private val mockContext = mockk(relaxed = true) + private val mockJellyfin = mockk() + private val mockJellyfinServerDao = mockk() + private val mockApiClient = mockk() + private val dataStore = + DataStoreFactory.create( + serializer = AppPreferencesSerializer(), + produceFile = { temporaryFolder.newFile("test_datastore.pb") }, + scope = CoroutineScope(testDispatcher), + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + + private val mockUserApi = mockk() + private val mockSystemApi = mockk() + private val mockSharedPreferences = mockk(relaxed = true) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences + every { mockApiClient.userApi } returns mockUserApi + every { mockApiClient.systemApi } returns mockSystemApi + coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) + coEvery { mockSystemApi.getPublicSystemInfo() } returns + Response( + PublicSystemInfo( + id = serverId.toServerString(), + serverName = "test server", + version = "10.11.11", + ), + 200, + emptyMap(), + ) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") + every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") + every { mockApiClient.update(any(), any(), any(), any()) } just Runs + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + private fun create() = + ServerRepository( + mockContext, + mockJellyfin, + mockJellyfinServerDao, + mockApiClient, + dataStore, + testDispatcher, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private fun setUpCurrentUser( + serverRepository: ServerRepository, + currentUser: JellyfinUser = user, + ) { + every { mockApiClient.baseUrl } returns server.url + every { mockApiClient.accessToken } returns user.accessToken + Assert.assertNull(serverRepository.currentUser) + (serverRepository.current as MutableStateFlow).update { + CurrentUser(server, currentUser) + } + Assert.assertNotNull(serverRepository.currentUser) + } + + @Test + fun `Test overriding state`() { + val serverRepository = create() + Assert.assertNull(serverRepository.currentServer) + Assert.assertNull(serverRepository.currentUser) + setUpCurrentUser(serverRepository, currentUser = user) + Assert.assertEquals(serverId, serverRepository.currentServer?.id) + Assert.assertEquals(userId, serverRepository.currentUser?.id) + } + + @Test + fun `Test changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.changeUser(server, user) + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + } + + @Test + fun `Test restoreUser via changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test restoreUser via shortcut`() = + runTest { + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + setUpCurrentUser(serverRepository) + + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + + // TODO fix this, need to check userDto too +// Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test remove user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different user than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val otherUser = + JellyfinUser( + rowId = 2, + id = UUID.randomUUID(), + name = "other", + serverId = serverId, + accessToken = "other token", + ) + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = otherUser) + Assert.assertEquals(otherUser, serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 0) { mockApiClient.update(accessToken = null) } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + Assert.assertEquals(otherUser, serverRepository.currentUser) + } + + @Test + fun `Test remove current user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeUser(user) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = nonBlankString(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different server than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + val toRemove = JellyfinServer(UUID.randomUUID(), null, "http://jellyfin.local", null) + serverRepository.removeServer(toRemove) + + Assert.assertEquals(server, serverRepository.currentServer) + verify(exactly = 0) { + mockApiClient.update( + accessToken = any(), + baseUrl = any(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(toRemove.id) } + } + + @Test + fun `Test remove current server`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeServer(server) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = null, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + } + + @Test + fun `Test changeUser via auth`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, null) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Newly created user, so rowId should be 0 + Assert.assertEquals(0, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via auth for existing`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Existing user, so rowId should not change + Assert.assertEquals(user.rowId, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + // PIN should be removed when re-authing existing user + Assert.assertNull(savedUser.captured.pin) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via invalid auth`() { + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = null, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = null, + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = null, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt similarity index 99% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt rename to app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index dbf04447..e16bc4e5 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository @@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceService import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID @@ -22,7 +21,7 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class TestStreamChoiceServiceBasic( +class StreamChoiceServiceTest( val input: TestInput, ) { @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt new file mode 100644 index 00000000..e1bf9fc8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.TrackSelectionParameters +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +class TestSelectionTrackExamples { + private fun runTest( + builder: TestTracks.Builder, + playerBackend: PlayerBackend, + audioIndex: Int, + subtitleIndex: Int, + onResult: (TrackSelectionResult) -> Unit, + ) { + val testTacks = + when (playerBackend) { + PlayerBackend.EXO_PLAYER -> builder.buildForExoPlayer() + PlayerBackend.MPV -> builder.buildForMpv() + else -> throw IllegalArgumentException("Unsupported backend: $playerBackend") + } + + // These checks help check for invalid inputs + val audioTrack = testTacks.tracks.filter { it.index == audioIndex } + Assert.assertEquals(1, audioTrack.size) + Assert.assertEquals(MediaStreamType.AUDIO, audioTrack.first().type) + val subtitleTrack = testTacks.tracks.filter { it.index == subtitleIndex } + Assert.assertEquals(1, subtitleTrack.size) + Assert.assertEquals(MediaStreamType.SUBTITLE, subtitleTrack.first().type) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTacks.toTracks(), + playerBackend = playerBackend, + supportsDirectPlay = true, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = testTacks.toMediaSourceInfo(), + ).also(onResult) + } + + @Test + fun `test VAS Exo`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.EXO_PLAYER, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test VAS MPV`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.MPV, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS Exo`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("0:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS MPV`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt index 5027aee8..90b45182 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.test import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -264,32 +263,3 @@ class TestSuggestionsCombineAndDeduplicate( } } } - -// Helper functions to create test data - -private fun movie( - id: UUID = UUID.randomUUID(), - name: String = "Test Movie", - genres: List? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.MOVIE, - name = name, - seriesId = null, - genreItems = genres, - ) - -private fun episode( - id: UUID = UUID.randomUUID(), - seriesId: UUID, - name: String = "Test Episode", - genres: List? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.EPISODE, - name = name, - seriesId = seriesId, - genreItems = genres, - ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index cad496ca..956ddcd4 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -235,24 +235,6 @@ class TestTrackSelection { return Tracks(groups) } - private fun TrackSelectionParameters.getAudioOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_AUDIO) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) - } - } - return null - } - - private fun TrackSelectionParameters.getSubtitleOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_TEXT) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) - } - } - return null - } - @Test fun `test MPV embedded`() { val resource = javaClass.classLoader?.getResource("embedded_subs.json") @@ -589,3 +571,21 @@ class TestTrackSelection { } } } + +internal fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} + +internal fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt new file mode 100644 index 00000000..ba8d0d53 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -0,0 +1,476 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaProtocol +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaSourceType +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +/** + * Represents a list of video, audio or, subtitle tracks in a file when direct playing + * + * @see Builder + */ +class TestTracks private constructor( + val tracks: List = emptyList(), +) { + /** + * Convert to media3 [Tracks] + */ + fun toTracks(): Tracks { + val groups = + tracks + .map { + val mimeType = + when (it.type) { + MediaStreamType.VIDEO -> "video/sample" + MediaStreamType.AUDIO -> "audio/default" + MediaStreamType.SUBTITLE -> "text/sample" + else -> throw UnsupportedOperationException("${it.type}") + } + Format + .Builder() + .setId(it.id) + .setSampleMimeType(mimeType) + .build() + }.map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + fun toMediaSourceInfo(): MediaSourceInfo { + val streams = + tracks.mapIndexed { index, track -> + MediaStream( + index = index, + type = track.type, + isExternal = track.external, + codec = null, + language = null, + isInterlaced = false, + isDefault = false, + isForced = false, + isHearingImpaired = false, + isTextSubtitleStream = false, + supportsExternalStream = false, + ) + } + val source = + MediaSourceInfo( + mediaStreams = streams, + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = false, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = false, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) + return source + } + + /** + * Builder for adding tracks + */ + class Builder { + private val tracks = mutableListOf() + + fun addVideo(): Builder { + tracks.add(TestTrackBuilder(MediaStreamType.VIDEO)) + return this + } + + fun addAudio(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.AUDIO)) + } + return this + } + + fun addSubtitle(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, false)) + } + return this + } + + fun addExternalSubtitle(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, true)) + } + return this + } + + /** + * Create the [TestTracks] as if being playing by ExoPlayer + */ + fun buildForExoPlayer(): TestTracks { + // Format is: [:][e:], "e:" indicates external subtitle + val hasExternal = tracks.firstOrNull { it.external } != null + val testTracks = + buildList { + var externalSubCount = 0 + val t = + tracks + .mapIndexed { index, track -> + if (track.external) { + externalSubCount++ + TestTrack( + "$externalSubCount:e:$index", + index, + track.type, + track.external, + ) + } else if (hasExternal) { + val idx = index + 1 - externalSubCount + // If there's a sidecar file, the actual file + TestTrack("0:$idx", index, track.type, track.external) + } else { + val idx = index + 1 + TestTrack("$idx", index, track.type, track.external) + } + } + addAll(t) + } + return TestTracks(testTracks) + } + + /** + * Create the [TestTracks] as if being playing by MPV + */ + fun buildForMpv(): TestTracks { + val embeddedCount = tracks.count { !it.external } + val embeddedSubtitleCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } + val externalSubCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } + var videoCount = 0 + var audioCount = 0 + var subtitleCount = 0 + val testTracks = + tracks.mapIndexed { index, track -> + val id = + if (track.external) { + // MPV places external subtitles last + val idx = embeddedCount + index + subtitleCount++ + val count = embeddedSubtitleCount + subtitleCount + "$idx:e:$count" + } else { + val idx = index - externalSubCount + when (track.type) { + MediaStreamType.AUDIO -> { + audioCount++ + "$idx:$audioCount" + } + + MediaStreamType.VIDEO -> { + videoCount++ + "$idx:$videoCount" + } + + MediaStreamType.SUBTITLE -> { + subtitleCount++ + val count = subtitleCount - externalSubCount + "$idx:$count" + } + + else -> { + throw IllegalArgumentException("Unsupported type " + track.type) + } + } + } + TestTrack(id, index, track.type, track.external) + } + return TestTracks(testTracks) + } + } + + companion object { + fun fromMediaSourceInfo(source: MediaSourceInfo): TestTracks.Builder = + TestTracks.Builder().apply { + source.mediaStreams.orEmpty().forEach { + when (it.type) { + MediaStreamType.AUDIO -> addAudio() + MediaStreamType.VIDEO -> addVideo() + MediaStreamType.SUBTITLE -> if (it.isExternal) addExternalSubtitle() else addSubtitle() + else -> throw IllegalArgumentException("Unsupported type ${it.type}") + } + } + } + } +} + +data class TestTrack( + val id: String, + val index: Int, + val type: MediaStreamType, + val external: Boolean, +) + +class TestTrackBuilder( + val type: MediaStreamType, + val external: Boolean = false, +) + +fun assertIdType( + expectedId: String, + expectedType: MediaStreamType, + track: TestTrack, +) { + Assert.assertEquals(expectedId, track.id) + Assert.assertEquals(expectedType, track.type) +} + +/** + * Varies [TestTracks.Builder] examples + * + * They are named by the order of the tracks, V=video, A=audio, S=subtitle, E=external subtitle + */ +object TrackExamples { + val builderVAASSS = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(3) + + val builderAASSSV = + TestTracks + .Builder() + .addAudio(2) + .addSubtitle(3) + .addVideo() + + val builderAAVSSS = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addSubtitle(3) + + val builderVASASS = + TestTracks + .Builder() + .addVideo() + .addAudio(1) + .addSubtitle(1) + .addAudio(1) + .addSubtitle(2) + + val builderEVAASSS = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(2) + .addSubtitle(3) +} + +class TestTracksTests { + @Test + fun testMpv() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForMpv() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("9:e:5", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("1:1", testTracks[2].id) + Assert.assertEquals("2:2", testTracks[3].id) + Assert.assertEquals("3:3", testTracks[4].id) + Assert.assertEquals("4:4", testTracks[5].id) + Assert.assertEquals("5:1", testTracks[6].id) + Assert.assertEquals("6:2", testTracks[7].id) + Assert.assertEquals("7:3", testTracks[8].id) + Assert.assertEquals("8:4", testTracks[9].id) + } + + @Test + fun `test ExoPlayer with external`() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("1:e:0", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("0:2", testTracks[2].id) + Assert.assertEquals("0:3", testTracks[3].id) + Assert.assertEquals("0:4", testTracks[4].id) + Assert.assertEquals("0:5", testTracks[5].id) + Assert.assertEquals("0:6", testTracks[6].id) + Assert.assertEquals("0:7", testTracks[7].id) + Assert.assertEquals("0:8", testTracks[8].id) + Assert.assertEquals("0:9", testTracks[9].id) + } + + @Test + fun `test ExoPlayer without external`() { + val testTracks = + TestTracks + .Builder() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(9, testTracks.size) + Assert.assertEquals("1", testTracks[0].id) + Assert.assertEquals("2", testTracks[1].id) + Assert.assertEquals("3", testTracks[2].id) + Assert.assertEquals("4", testTracks[3].id) + Assert.assertEquals("5", testTracks[4].id) + Assert.assertEquals("6", testTracks[5].id) + Assert.assertEquals("7", testTracks[6].id) + Assert.assertEquals("8", testTracks[7].id) + Assert.assertEquals("9", testTracks[8].id) + } + + @Test + fun `test original-VAS`() { + TrackExamples.builderVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + Assert.assertEquals("1", exo[0].id) + Assert.assertEquals("2", exo[1].id) + Assert.assertEquals("3", exo[2].id) + Assert.assertEquals("4", exo[3].id) + Assert.assertEquals("5", exo[4].id) + Assert.assertEquals("6", exo[5].id) + } + + TrackExamples.builderVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + Assert.assertEquals("0:1", mpv[0].id) + Assert.assertEquals("1:1", mpv[1].id) + Assert.assertEquals("2:2", mpv[2].id) + Assert.assertEquals("3:1", mpv[3].id) + Assert.assertEquals("4:2", mpv[4].id) + Assert.assertEquals("5:3", mpv[5].id) + } + } + + @Test + fun `test VAS with external`() { + TrackExamples.builderEVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(7, exo.size) + Assert.assertTrue(exo[0].external) + assertIdType("1:e:0", MediaStreamType.SUBTITLE, exo[0]) + assertIdType("0:1", MediaStreamType.VIDEO, exo[1]) + assertIdType("0:2", MediaStreamType.AUDIO, exo[2]) + assertIdType("0:3", MediaStreamType.AUDIO, exo[3]) + assertIdType("0:4", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("0:5", MediaStreamType.SUBTITLE, exo[5]) + assertIdType("0:6", MediaStreamType.SUBTITLE, exo[6]) + } + + TrackExamples.builderEVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(7, mpv.size) + Assert.assertTrue(mpv[0].external) + Assert.assertEquals("6:e:4", mpv[0].id) + Assert.assertEquals("0:1", mpv[1].id) + Assert.assertEquals("1:1", mpv[2].id) + Assert.assertEquals("2:2", mpv[3].id) + Assert.assertEquals("3:1", mpv[4].id) + Assert.assertEquals("4:2", mpv[5].id) + Assert.assertEquals("5:3", mpv[6].id) + } + } + + @Test + fun `test ASV`() { + TrackExamples.builderAASSSV.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.VIDEO, exo[5]) + } + + TrackExamples.builderAASSSV.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:3", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:1", MediaStreamType.VIDEO, mpv[5]) + } + } + + @Test + fun `test AVS`() { + TrackExamples.builderAAVSSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderAAVSSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:1", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `test VASASS`() { + TrackExamples.builderVASASS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.VIDEO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderVASASS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.VIDEO, mpv[0]) + assertIdType("1:1", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt new file mode 100644 index 00000000..a1bcb2f1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import io.mockk.MockKMatcherScope +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.NameGuidPair + +fun MockKMatcherScope.nonBlankString() = match { it.isNotNullOrBlank() } + +/** + * Create a simple [BaseItemDto] movie + */ +fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + name = name, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] tv episode + */ +fun episode( + id: UUID = UUID.randomUUID(), + seriesId: UUID, + name: String = "Test Episode", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] playlist + */ +fun playlist( + id: UUID = UUID.randomUUID(), + name: String = "Test Playlist", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.PLAYLIST, + name = name, + seriesId = null, + genreItems = genres, + ) 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 index d0bc8532..2fc00bce 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -6,6 +6,8 @@ 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 +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult @OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.performClickEnter() = @@ -14,3 +16,5 @@ fun SemanticsNodeInteraction.performClickEnter() = } fun successResponse(content: T) = Response(content, 200, emptyMap()) + +fun successQueryResult(items: List) = successResponse(BaseItemDtoQueryResult(items, items.size, 0)) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt new file mode 100644 index 00000000..ad4d7a43 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -0,0 +1,756 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ShowNextUpWhen +import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlayerCreation +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.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.MediaInfoApi +import org.jellyfin.sdk.api.operations.MediaSegmentsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult +import org.jellyfin.sdk.model.api.MediaSegmentType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlaybackInfoResponse +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@RunWith(RobolectricTestRunner::class) +class PlaybackViewModelTests { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk(relaxed = true) + private val mockApi = mockk(relaxed = true) + private val mockNavigationManager = mockk(relaxed = true) + private val mockPlaylistCreator = mockk(relaxed = true) + private val mockItemPlaybackDao = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockItemPlaybackRepository = mockk(relaxed = true) + private val mockPlayerFactory = mockk() + private val mockDatePlayedService = mockk(relaxed = true) + private val mockDeviceInfo = mockk(relaxed = true) + private val mockDeviceProfileService = mockk(relaxed = true) + private val mockRefreshRateService = mockk(relaxed = true) + private val mockStreamChoiceService = mockk(relaxed = true) + private val mockUserPreferencesService = mockk(relaxed = true) + private val mockImageUrlService = mockk(relaxed = true) + private val mockScreensaverService = mockk(relaxed = true) + private val mockMusicService = mockk(relaxed = true) + + private val mockUserLibraryApi = mockk() + private val mockMediaInfoApi = mockk() + private val mockVideosApi = mockk() + private val mockMediaSegmentsApi = mockk() + private val mockPlayer = mockk(relaxed = true) + + fun create(destination: Destination): PlaybackViewModel = + PlaybackViewModel( + context = mockContext, + api = mockApi, + navigationManager = mockNavigationManager, + playlistCreator = mockPlaylistCreator, + itemPlaybackDao = mockItemPlaybackDao, + serverRepository = mockServerRepository, + itemPlaybackRepository = mockItemPlaybackRepository, + playerFactory = mockPlayerFactory, + datePlayedService = mockDatePlayedService, + deviceInfo = mockDeviceInfo, + deviceProfileService = mockDeviceProfileService, + refreshRateService = mockRefreshRateService, + streamChoiceService = mockStreamChoiceService, + userPreferencesService = mockUserPreferencesService, + imageUrlService = mockImageUrlService, + screensaverService = mockScreensaverService, + musicService = mockMusicService, + destination = destination, + ) + + private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { + val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) + val prefs = UserPreferences(appPrefs) + coEvery { mockUserPreferencesService.getCurrent() } returns prefs + } + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = + JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addSubtitle() + .buildForExoPlayer() + .toMediaSourceInfo() + + private val playSessionId = "playsessionid12345" + private val videoStreamUrl = "http://localhost:8096/video/stream" + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + every { mockApi.mediaInfoApi } returns mockMediaInfoApi + every { mockApi.videosApi } returns mockVideosApi + every { mockApi.mediaSegmentsApi } returns mockMediaSegmentsApi + + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(mediaSource), + playSessionId = playSessionId, + ), + ) + + every { + mockVideosApi.getVideoStreamUrl( + itemId = any(), + mediaSourceId = mediaSource.id, + static = true, + tag = mediaSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + coEvery { mockServerRepository.currentUser } returns user + coEvery { mockServerRepository.currentUserDto } returns userDto + + coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null + coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns + PlayerCreation(mockPlayer) + every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) + + coEvery { + mockStreamChoiceService.chooseAudioStream( + source = mediaSource, + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + + coEvery { + mockStreamChoiceService.chooseSubtitleStream( + source = mediaSource, + any(), + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + // Test data + val playlist = playlist() + val movie = movie(name = "Test Movie 1") + val movie2 = movie(name = "Test Movie 2") + val movie3 = movie(name = "Test Movie 3") + val intro = movie(name = "Test Intro") + val playlistItems = + Playlist.fromMedia(listOf(BaseItem(movie), BaseItem(movie2), BaseItem(movie3))) + val introSegmentMovie2 = + MediaSegmentDto( + id = UUID.randomUUID(), + itemId = movie2.id, + type = MediaSegmentType.INTRO, + startTicks = 5.seconds.inWholeTicks, + endTicks = 10.seconds.inWholeTicks, + ) + val segments = + MediaSegmentDtoQueryResult( + items = listOf(introSegmentMovie2), + totalRecordCount = 1, + startIndex = 0, + ) + // END test data + + /** + * Create the [PlaybackViewModel] and wait for its init job to complete + * + * Throws an exception if the job throws one + */ + @OptIn(InternalCoroutinesApi::class) + private suspend fun TestScope.createViewModel(destination: Destination): PlaybackViewModel { + val viewModel = create(destination) + testScheduler.advanceUntilIdle() + viewModel.initJob.join() + viewModel.initJob + .getCancellationException() + .cause + ?.let { throw it } + return viewModel + } + + @Test + fun `Play intro first`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + // Should be playing the intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + + @Test + fun `Play two intros first`() = + runTest(testDispatcher) { + val movie = movie() + val intro = movie() + val intro2 = movie() + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro, intro2)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + // Should be playing the first intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + val playlist = state.playlist.items + Assert.assertEquals(2, playlist.size) + Assert.assertEquals(intro.id, playlist[0].id) + Assert.assertEquals(intro2.id, playlist[1].id) + } + + @Test + fun `Don't play intro`() = + runTest(testDispatcher) { + val movie = movie() + setupPreferences { + cinemaMode = false + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + coVerify(exactly = 0) { mockUserLibraryApi.getIntros(any(), any()) } + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + viewModel.state.value.also { state -> + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + } + + private fun setupForPlaylist() { + coEvery { mockUserLibraryApi.getItem(playlist.id, any()) } returns + successResponse(playlist) + coEvery { + mockPlaylistCreator.createFrom( + item = playlist, + startIndex = any(), + sortAndDirection = any(), + shuffled = any(), + recursive = any(), + filter = any(), + ) + } returns PlaylistCreationResult.Success(playlistItems) + } + + @Test + fun `Play next up`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItem = slot() + val mediaItem2 = slot() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any()) + mockPlayer.setMediaItem(capture(mediaItem2), any()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Play previous`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + } + + viewModel.playPrevious() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItems = mutableListOf() + verify(exactly = 4) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any()) + } + Assert.assertEquals(4, mediaItems.size) + Assert.assertEquals(movie.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[1].mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItems[2].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[3].mediaId) + } + + @OptIn(InternalCoroutinesApi::class) + @Test + fun `Show next up at end of playback`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(movie2, state.nextUp?.data) + } + } + + @Test + fun `Show next up never`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + } + } + + @Test + fun playItemInPlaylist() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.playItemInPlaylist(BaseItem(movie3)) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + val mediaItem = slot() + val mediaItem2 = slot() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any()) + mockPlayer.setMediaItem(capture(mediaItem2), any()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Media segments auto skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1500.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and clicked`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + + viewModel.updateSegment(introSegmentMovie2.id, false) + every { mockPlayer.currentPosition } returns introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1 + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and dismissed`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + + viewModel.updateSegment(introSegmentMovie2.id, true) + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + + // Never skipped + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ignore`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.IGNORE + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment not at current position`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 2.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } +} From ae49e977ced2b26c96b89ea1d1ff5e6ad42c5767 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 20 Jun 2026 20:06:34 -0400 Subject: [PATCH 039/475] Fix crash on certain grid pages when the app is sent to the background (#1605) ## Description Fixes a crash when backgrounding/restoring the app on certain grid pages ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/1598 Replaces #1599 ### Testing Emulator It's easy to reproduce by going to a library's recommended tab, scrolling to any row's view more, then pressing home to background the app ## Screenshots N/A ## AI or LLM usage None, but includes a commit from #1599 which did, see the note there --------- Co-authored-by: Christian Tismer --- .../damontecres/wholphin/MainActivity.kt | 30 +- .../wholphin/util/ApiRequestPager.kt | 438 +++++++++--------- 2 files changed, 251 insertions(+), 217 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 6aa749f1..8d9beccb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -61,6 +61,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.requestSerializersModule import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() { private val json = Json { classDiscriminator = "_type" + serializersModule = requestSerializersModule } @OptIn(ExperimentalTvMaterial3Api::class) @@ -151,10 +153,22 @@ class MainActivity : AppCompatActivity() { lifecycle.addObserver(playbackLifecycleObserver) val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) - if (backStackStr != null) { + val restoredBackStack = + if (backStackStr != null) { + try { + json.decodeFromString>(backStackStr) + } catch (ex: Exception) { + // Best-effort: a previously persisted back stack we can no longer decode + // must not crash startup; fall back to a fresh start destination. + Timber.w(ex, "Could not restore back stack; starting fresh") + null + } + } else { + null + } + if (restoredBackStack != null) { Timber.d("Restoring back stack") - var backStack = json.decodeFromString>(backStackStr) - + var backStack = restoredBackStack if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() if (lastDest.isPlayback) { @@ -320,8 +334,14 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") - val str = json.encodeToString(navigationManager.backStack.toList()) - outState.putString(KEY_BACK_STACK, str) + try { + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) + } catch (ex: Exception) { + // Best-effort: some destinations carry types we can't serialize; skip persisting + // the back stack rather than crashing in onSaveInstanceState. + Timber.w(ex, "Could not persist back stack; skipping") + } val playerBackend = runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) 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 32b66d04..555a5a51 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 @@ -5,6 +5,9 @@ import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.genresApi @@ -107,7 +110,8 @@ class ApiRequestPager( /** * Specifies how a [RequestPager] should prepare and execute API calls */ -interface RequestHandler { +@Serializable +sealed interface RequestHandler { /** * Prepare the given request with the specified parameters (eg which page to fetch) */ @@ -128,245 +132,255 @@ interface RequestHandler { } @Serializable -val GetItemsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetItemsRequestHandler : RequestHandler { + override fun prepare( + request: GetItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetItemsRequest, - ): Response = api.itemsApi.getItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetItemsRequest, + ): Response = api.itemsApi.getItems(request) +} @Serializable -val GetEpisodesRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetEpisodesRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetEpisodesRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetEpisodesRequestHandler : RequestHandler { + override fun prepare( + request: GetEpisodesRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetEpisodesRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetEpisodesRequest, - ): Response = api.tvShowsApi.getEpisodes(request) - } + override suspend fun execute( + api: ApiClient, + request: GetEpisodesRequest, + ): Response = api.tvShowsApi.getEpisodes(request) +} @Serializable -val GetResumeItemsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetResumeItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetResumeItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetResumeItemsRequestHandler : RequestHandler { + override fun prepare( + request: GetResumeItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetResumeItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetResumeItemsRequest, - ): Response = api.itemsApi.getResumeItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetResumeItemsRequest, + ): Response = api.itemsApi.getResumeItems(request) +} @Serializable -val GetNextUpRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetNextUpRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetNextUpRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetNextUpRequestHandler : RequestHandler { + override fun prepare( + request: GetNextUpRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetNextUpRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetNextUpRequest, - ): Response = api.tvShowsApi.getNextUp(request) - } + override suspend fun execute( + api: ApiClient, + request: GetNextUpRequest, + ): Response = api.tvShowsApi.getNextUp(request) +} @Serializable -val GetSuggestionsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetSuggestionsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetSuggestionsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetSuggestionsRequestHandler : RequestHandler { + override fun prepare( + request: GetSuggestionsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetSuggestionsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetSuggestionsRequest, - ): Response = api.suggestionsApi.getSuggestions(request) - } + override suspend fun execute( + api: ApiClient, + request: GetSuggestionsRequest, + ): Response = api.suggestionsApi.getSuggestions(request) +} @Serializable -val GetPlaylistItemsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetPlaylistItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPlaylistItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetPlaylistItemsRequestHandler : RequestHandler { + override fun prepare( + request: GetPlaylistItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPlaylistItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPlaylistItemsRequest, - ): Response = api.playlistsApi.getPlaylistItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetPlaylistItemsRequest, + ): Response = api.playlistsApi.getPlaylistItems(request) +} @Serializable -val GetGenresRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetGenresRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetGenresRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetGenresRequestHandler : RequestHandler { + override fun prepare( + request: GetGenresRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetGenresRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetGenresRequest, - ): Response = api.genresApi.getGenres(request) - } - -val GetProgramsDtoHandler = - object : RequestHandler { - override fun prepare( - request: GetProgramsDto, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetProgramsDto = - request.copy( - startIndex = startIndex, - limit = limit, - ) - - override suspend fun execute( - api: ApiClient, - request: GetProgramsDto, - ): Response = api.liveTvApi.getPrograms(request) - } + override suspend fun execute( + api: ApiClient, + request: GetGenresRequest, + ): Response = api.genresApi.getGenres(request) +} @Serializable -val GetPersonsHandler = - object : RequestHandler { - override fun prepare( - request: GetPersonsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPersonsRequest = - request.copy( +object GetProgramsDtoHandler : RequestHandler { + override fun prepare( + request: GetProgramsDto, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetProgramsDto = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response = api.liveTvApi.getPrograms(request) +} + +@Serializable +object GetPersonsHandler : RequestHandler { + override fun prepare( + request: GetPersonsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPersonsRequest = + request.copy( // startIndex = startIndex, - limit = limit, - ) + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPersonsRequest, - ): Response = api.personsApi.getPersons((request)) - } + override suspend fun execute( + api: ApiClient, + request: GetPersonsRequest, + ): Response = api.personsApi.getPersons((request)) +} -val GetStudiosRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetStudiosRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetStudiosRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +@Serializable +object GetStudiosRequestHandler : RequestHandler { + override fun prepare( + request: GetStudiosRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetStudiosRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetStudiosRequest, - ): Response = api.studiosApi.getStudios(request) - } + override suspend fun execute( + api: ApiClient, + request: GetStudiosRequest, + ): Response = api.studiosApi.getStudios(request) +} -val GetRecordingsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetRecordingsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetRecordingsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +@Serializable +object GetRecordingsRequestHandler : RequestHandler { + override fun prepare( + request: GetRecordingsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetRecordingsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetRecordingsRequest, - ): Response = api.liveTvApi.getRecordings(request) - } + override suspend fun execute( + api: ApiClient, + request: GetRecordingsRequest, + ): Response = api.liveTvApi.getRecordings(request) +} -val GetLiveTvChannelsRequestHandler = - object : RequestHandler { - override fun prepare( - request: GetLiveTvChannelsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetLiveTvChannelsRequest = - request.copy( - startIndex = startIndex, - limit = limit, +@Serializable +object GetLiveTvChannelsRequestHandler : RequestHandler { + override fun prepare( + request: GetLiveTvChannelsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetLiveTvChannelsRequest = + request.copy( + startIndex = startIndex, + limit = limit, // enableTotalRecordCount = enableTotalRecordCount, - ) + ) - override suspend fun execute( - api: ApiClient, - request: GetLiveTvChannelsRequest, - ): Response = api.liveTvApi.getLiveTvChannels(request) + override suspend fun execute( + api: ApiClient, + request: GetLiveTvChannelsRequest, + ): Response = api.liveTvApi.getLiveTvChannels(request) +} + +val requestSerializersModule = + SerializersModule { + polymorphic(Any::class) { + subclass(GetItemsRequest::class) + subclass(GetEpisodesRequest::class) + subclass(GetResumeItemsRequest::class) + subclass(GetNextUpRequest::class) + subclass(GetSuggestionsRequest::class) + subclass(GetPlaylistItemsRequest::class) + subclass(GetGenresRequest::class) + subclass(GetProgramsDto::class) + subclass(GetPersonsRequest::class) + subclass(GetStudiosRequest::class) + subclass(GetRecordingsRequest::class) + subclass(GetLiveTvChannelsRequest::class) + } } From 6a18e303146bd9a2a25251fb8683c11532440592 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:06:18 -0400 Subject: [PATCH 040/475] Update Github Actions to v7 (#1585) --- .github/workflows/main.yml | 2 +- .github/workflows/pr.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63648874..7a63489e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c4ff149c..7ebdcb17 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup Python @@ -31,7 +31,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5aa37b70..b5a0d66f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup From 37c02e24917f41312f9c854bfb5504afea030006 Mon Sep 17 00:00:00 2001 From: texxlan Date: Sun, 14 Jun 2026 21:39:15 +0000 Subject: [PATCH 041/475] Translated using Weblate (German) Currently translated at 95.5% (562 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 06663981..faf753d0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -160,7 +160,6 @@ Hinter den Kulissen - Gelöschte Szene From 379da6444288f83f2bf2fa6abd83094a06355289 Mon Sep 17 00:00:00 2001 From: damontecres Date: Sun, 14 Jun 2026 21:39:20 +0000 Subject: [PATCH 042/475] Translated using Weblate (Spanish) Currently translated at 100.0% (588 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 21a8da59..738b0f18 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -86,7 +86,6 @@ Otro Otros - Configuración avanzada Interfaz avanzada @@ -164,13 +163,10 @@ Cursiva Detrás de cámaras - - Canción principal Canciones principales - Vídeos temáticos @@ -185,22 +181,18 @@ Escena eliminada Escenas eliminadas - Entrevista Entrevistas - Escena Escenas - Muestra Muestras - Mini-documentales @@ -209,8 +201,6 @@ CortoCortos - - %s descarga From 21619c5e1070335323577ba325f1b8cdf13d58e9 Mon Sep 17 00:00:00 2001 From: rcv11x Date: Sun, 14 Jun 2026 21:39:20 +0000 Subject: [PATCH 043/475] Translated using Weblate (Spanish) Currently translated at 100.0% (588 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 738b0f18..5bb63af1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -170,8 +170,6 @@ Vídeos temáticos - - Clip @@ -196,8 +194,6 @@ Mini-documentales - - CortoCortos From 891cbc8ae343305ab740b04bbd13b97eebba71d9 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Sun, 14 Jun 2026 23:06:48 +0000 Subject: [PATCH 044/475] Translated using Weblate (Spanish) Currently translated at 100.0% (588 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 52 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5bb63af1..24414e3b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -86,6 +86,7 @@ Otro Otros + Otros Configuración avanzada Interfaz avanzada @@ -162,14 +163,19 @@ Orden de emisión Cursiva - Detrás de cámaras + Detrás de las cámaras + Detrás de las cámaras + Detrás de las cámaras - Canción principal - Canciones principales + Tema musical + Temas musicales + Temas musicales - Vídeos temáticos + Vídeo temático + Vídeos temáticos + Vídeos temáticos Clip @@ -179,24 +185,32 @@ Escena eliminada Escenas eliminadas + Escenas eliminadas Entrevista Entrevistas + Entrevistas Escena Escenas + Escenas Muestra Muestras + Muestras - Mini-documentales + Reportaje + Reportajes + Reportajes - CortoCortos + Corto + Cortos + Cortos %s descarga @@ -626,4 +640,30 @@ Lista compacta Ordenar Separar en filas independientes + Otro + Detrás de las cámaras + Temas musicales + Vídeos temáticos + Clips + Escenas eliminadas + Entrevistas + Escenas + Muestras + Reportajes + Cortos + + Película + Películas + Películas + + + Serie + Series + Series + + + Persona + Personas + Personas + From 06c99b2aa1b0b2c43b4fadc002b7c72074980add Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Sun, 14 Jun 2026 21:40:28 +0000 Subject: [PATCH 045/475] Translated using Weblate (Swedish) Currently translated at 82.6% (486 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6bc35660..fb3db1a3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -107,47 +107,36 @@ Extramaterial Annat - Bakom kulisserna - Temalåtar - Temavideor - Klipp - Borttagna scener - Intervjuer - Scener - Exempel - Specialinslag - Kortfilmer - %s nedladdning From bedbde3ebb8b78d9fec2283140f459f866ca9fe1 Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sun, 14 Jun 2026 21:40:14 +0000 Subject: [PATCH 046/475] Translated using Weblate (Slovak) Currently translated at 86.2% (507 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 36 -------------------------- 1 file changed, 36 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 8b8ba7a2..0402a4c7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -155,9 +155,6 @@ Série Seriály - - - Rozhranie Neznámy @@ -185,69 +182,36 @@ 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 From 61d07a9d294d94b15014467a70f6459907b0440c Mon Sep 17 00:00:00 2001 From: oyvhov Date: Sun, 14 Jun 2026 21:39:53 +0000 Subject: [PATCH 047/475] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 52.3% (308 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nb_NO/ --- app/src/main/res/values-nb-rNO/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 6b21a3ea..50a7556d 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -154,47 +154,36 @@ Ekstramateriale Annet - Bak kulissene - Temasanger - Temavideoer - Klipp - Slettede scener - Intervjuer - Scener - Smakebiter - Bakomfilmer - Kortfilmer - %s nedlasting From 27ebf9d621870dd64924191b00fb31aded5c7ae4 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sun, 14 Jun 2026 21:39:32 +0000 Subject: [PATCH 048/475] Translated using Weblate (French) Currently translated at 95.7% (563 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 19b40df2..c1b69f22 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -126,33 +126,21 @@ Durée Autres - - Extraits - - Scènes supprimées - - Interviews - - Scènes - - Échantillons - - Appareil compatible AC3/Dolby Digital Paramètres avancés @@ -184,8 +172,6 @@ Bonus Dans les coulisses - - Délai avant de lire \"À suivre\" Lecture automatique de \"À suivre\" @@ -248,8 +234,6 @@ Les mieux notés non vus Courts-métrages - - %d heure @@ -280,13 +264,9 @@ Comportement de saut du récapitulatif Chanson à thème - - Vidéos à thème - - From d5d0f994744ecd47d33295a8531667363f832517 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 14 Jun 2026 21:39:39 +0000 Subject: [PATCH 049/475] Translated using Weblate (Italian) Currently translated at 95.7% (563 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5790b179..22515cc3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -92,13 +92,9 @@ Interviste - - Scene - - Guarda diretta Guida TV @@ -213,18 +209,12 @@ Salta Outro Temi Video - - Scene Eliminate - - Video brevi - - Impostazioni Avanzate Cerca aggiornamenti @@ -254,13 +244,9 @@ Ricorda schede selezionate Campioni - - Featurettes - - Converti sempre in stereo @@ -270,8 +256,6 @@ Durata Clip - - %s scaricamento From e177f362127033ce73f66096a8e8b4a35bf1e390 Mon Sep 17 00:00:00 2001 From: Vistaus Date: Sun, 14 Jun 2026 21:39:55 +0000 Subject: [PATCH 050/475] Translated using Weblate (Dutch) Currently translated at 82.3% (484 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ee1c3734..86fb18e9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -155,47 +155,36 @@ Extra\'s Overig - Achter de schermen - Titelmuziek - Titelvideo\'s - Clips - Verwijderde scènes - Interviews - Scènes - Voorvertoningen - Featurettes - Shorts - %s download From 26b64bd636623427ea508219a4af831a917ca07a Mon Sep 17 00:00:00 2001 From: Tellos Date: Sun, 14 Jun 2026 21:40:12 +0000 Subject: [PATCH 051/475] Translated using Weblate (Russian) Currently translated at 55.2% (325 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 254ea466..847d1514 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -132,9 +132,6 @@ За кадром - - - Клип @@ -298,15 +295,9 @@ Отрывки - - - Дополнительные материалы - - - Выбор плеера Толщина обводки @@ -315,15 +306,9 @@ Форсированные Саундтреки - - - Видеоролики - - - Настроить элементы боковой панели Выберите элементы для показа на панели, остальные элементы будут скрыты @@ -338,9 +323,6 @@ Остановка воспроизведения при бездействии Короткометражки - - - Конец в %1$s Введите адрес сервера From 8ba6b1c61363be7be5ace4e7d5743a16c0d83a17 Mon Sep 17 00:00:00 2001 From: Pawsi Date: Sun, 14 Jun 2026 21:40:01 +0000 Subject: [PATCH 052/475] Translated using Weblate (Polish) Currently translated at 95.7% (563 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c83a91bd..0309c1a1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -121,15 +121,9 @@ Wciśnij środek by pauzować/wznowić Wywiady - - - Sceny - - - Czołówka @@ -269,9 +263,6 @@ Dodatki Inne - - - Usunięta scena From 4a29672569aedc4c9a374d374c55535a4fbdddca Mon Sep 17 00:00:00 2001 From: Chosnuk Date: Sun, 14 Jun 2026 21:40:01 +0000 Subject: [PATCH 053/475] Translated using Weblate (Polish) Currently translated at 95.7% (563 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0309c1a1..bbd31f79 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -581,27 +581,15 @@ Czas trwania Klipy - - - Próbki - - - Filmy krótkometrażowe - - - Krótkie filmy - - - Proporcje ekranu Aktorzy gościnni From 376ae48aebe0ae4aa5a7541f331747b91b90ba5a Mon Sep 17 00:00:00 2001 From: adambibor Date: Sun, 14 Jun 2026 21:39:34 +0000 Subject: [PATCH 054/475] Translated using Weblate (Hungarian) Currently translated at 53.7% (316 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 44730910..86c2b158 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -158,7 +158,6 @@ Színfalak mögött - Főcímdal From 5c5c5cd2026aea839f2d2d3b099692b2a388a176 Mon Sep 17 00:00:00 2001 From: efko Date: Sun, 14 Jun 2026 21:39:17 +0000 Subject: [PATCH 055/475] Translated using Weblate (Greek) Currently translated at 29.0% (171 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index daef9929..44ef1e4f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -171,35 +171,27 @@ Επιπρόσθετα Άλλο - Παρασκήνια - Αποσπάσματα - Διαγραμμένες σκηνές - Συνεντεύξεις - Σκηνές - Δείγματα - Χαρακτηριστικά - Αυτόματος έλεγχος για ενημερώσεις Καθυστέρηση πριν την έναρξη του επόμενου From 7f436424671171d27c2979507a2992320d57f21c Mon Sep 17 00:00:00 2001 From: Rand0mB0y Date: Sun, 14 Jun 2026 21:39:17 +0000 Subject: [PATCH 056/475] Translated using Weblate (Greek) Currently translated at 29.0% (171 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 44ef1e4f..6ecbebec 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -19,11 +19,9 @@ Παρασκήνιο Θεματικά τραγούδια - Θεματικά βίντεο - Επόμενο Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\' From 1a90b480ebb29bb76b90cf909e856067068f79e5 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Sun, 14 Jun 2026 21:39:58 +0000 Subject: [PATCH 057/475] Translated using Weblate (Norwegian Nynorsk) Currently translated at 54.9% (323 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 995e26f5..1ff22c8f 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -157,47 +157,36 @@ Ekstra Anna - Bak kulissane - Temasongar - Temavideoar - Klipp - Sletta sener - Intervju - Scener - Smakebitar - Bakomfilmar - Kortfilmar - Avanserte innstillingar Avansert grensesnitt From 1ce07862afcdcccb7547fa7a5479ef18e4d2b9d4 Mon Sep 17 00:00:00 2001 From: veisen Date: Sun, 14 Jun 2026 21:39:09 +0000 Subject: [PATCH 058/475] Translated using Weblate (Czech) Currently translated at 77.7% (457 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 42 -------------------------- 1 file changed, 42 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 0f63eb14..26ce4b02 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -63,9 +63,6 @@ Více Filmy - - - Jméno Další v pořadí @@ -80,9 +77,6 @@ Cesta Lidé - - - Počet přehrání Přehrát odtud @@ -161,9 +155,6 @@ Sezóny Seriály - - - Prostředí Neznámý @@ -190,57 +181,30 @@ Doplňky Jiný - - - V zákulisí - - - Znělky - - - Tématická videa - - - Klipy - - - Vymazané scény - - - Rozhovory - - - Scény - - - Krátkometrážní filmy - - - %d hodina @@ -428,15 +392,9 @@ Trvání Vzorky - - - Krátká videa - - - %s Stažen From 8168a07dc6ba855c0c507f8d6c9f3e980360752f Mon Sep 17 00:00:00 2001 From: Sathen Date: Sun, 14 Jun 2026 21:40:35 +0000 Subject: [PATCH 059/475] Translated using Weblate (Ukrainian) Currently translated at 68.5% (403 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 42 -------------------------- 1 file changed, 42 deletions(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a4bf494f..0a449c9f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -66,9 +66,6 @@ Більше Фільми - - - Ім\'я Наступний @@ -83,9 +80,6 @@ Шлях Люди - - - Кількість відтворень Відтворити звідси @@ -165,9 +159,6 @@ Сезони Серіали - - - Інтерфейс Невідомо @@ -195,69 +186,36 @@ Додатково Інше - - - Позакадром - - - Тематичні пісні - - - Тематичні відео - - - Кліпи - - - Видалені сцени - - - Інтерв\'ю - - - Сцени - - - Зразки - - - Короткометражні фільми - - - Шорти - - - %s завантаження From e80d6896033745ed96d4669fc35158119e8af5ec Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Sun, 14 Jun 2026 21:40:04 +0000 Subject: [PATCH 060/475] Translated using Weblate (Portuguese (Brazil)) Currently translated at 91.3% (537 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 83649b09..5cde8a4f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -566,8 +566,6 @@ Mixagem Bastidores - - %s minuto From 66459bb2fc7280e6938f32ad1791aec985ff98a6 Mon Sep 17 00:00:00 2001 From: alexkahler Date: Sun, 14 Jun 2026 21:39:13 +0000 Subject: [PATCH 061/475] Translated using Weblate (Danish) Currently translated at 66.6% (392 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7803eb49..006adb67 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -65,7 +65,6 @@ Mere Film - Navn Næste @@ -80,7 +79,6 @@ Filsti Personer - Antal afspilninger Afspil herfra @@ -158,7 +156,6 @@ Sæsoner TV-serier - Grænseflade Ukendt @@ -186,47 +183,36 @@ Ekstra Andet - Bag kulisserne - Temasange - Temavideoer - Klip - Slettede scener - Interview - Scener - Prøver - Specialindslag - Kortfilm - %s download From 222c6c492484618533159b16c064e4a6bea91e77 Mon Sep 17 00:00:00 2001 From: EmadAljohani Date: Sun, 14 Jun 2026 21:39:03 +0000 Subject: [PATCH 062/475] Translated using Weblate (Arabic) Currently translated at 25.8% (152 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ar/ --- app/src/main/res/values-ar/strings.xml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4cbadd03..fa8f0243 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -66,11 +66,6 @@ المزيد الأفلام - - - - - الاسم التالي @@ -85,11 +80,6 @@ المسار الأشخاص - - - - - عدد مرات التشغيل تشغيل من هنا From afe26f3db414fdfbcb1c0cc15b016e6eeb0c568b Mon Sep 17 00:00:00 2001 From: gatisr Date: Sun, 14 Jun 2026 21:39:51 +0000 Subject: [PATCH 063/475] Translated using Weblate (Latvian) Currently translated at 38.9% (229 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/lv/ --- app/src/main/res/values-lv/strings.xml | 28 -------------------------- 1 file changed, 28 deletions(-) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 299e3501..7a066578 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -65,8 +65,6 @@ Vairāk Filmas - - Nosaukums Rindā @@ -81,8 +79,6 @@ Ceļš Cilvēki - - Skatījumu skaits Atskaņot no šejienes @@ -161,8 +157,6 @@ Sezonas TV Šovi - - Saskarne Nezināms @@ -190,58 +184,36 @@ Ekstras Cits - - Aizkadri - - Tituldziesmas - - Titulvideo - - Klipi - - Izgrieztās ainas - - Intervijas - - Ainas - - Paraugi - - Īssižeti - - Īsie rullīši - - Izlase %s From 060641ece3005c537a6fab4148b61092189ac2b6 Mon Sep 17 00:00:00 2001 From: Pwnisher Date: Sun, 14 Jun 2026 21:40:09 +0000 Subject: [PATCH 064/475] Translated using Weblate (Romanian) Currently translated at 56.1% (330 of 588 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ro/ --- app/src/main/res/values-ro/strings.xml | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 06643481..bb443a53 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -42,8 +42,6 @@ Nu este disponibilă nicio actualizare Filme - - Librărie Mergi la @@ -55,8 +53,6 @@ Marchează ca vizionat Persoane - - Număr de redări Mai mult @@ -215,8 +211,6 @@ Sezoane Seriale TV - - Interfață Necunoscut @@ -228,8 +222,6 @@ Pauză cu un click Interviuri - - Fundal @@ -329,8 +321,8 @@ Cele mai bine cotate, nevizionate Trailere - - + + Redă cu transcodare Informații media @@ -338,31 +330,21 @@ Adaugă în lista de redare Altele - - În spatele scenelor - - Melodii tematice - - Scene șterse - - Evaluare parentală Timp de execuție Extra Clipuri - - %s favorit Temă aplicație From cd263fadd353e3697201a62034f5d0a72fcfb0f5 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Mon, 15 Jun 2026 04:26:12 +0000 Subject: [PATCH 065/475] Translated using Weblate (German) Currently translated at 100.0% (589 of 589 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, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index faf753d0..caf63eb7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -156,7 +156,7 @@ Altersfreigabe Sonstiges - Sonstiges + Sonstige Hinter den Kulissen @@ -621,4 +621,16 @@ Anpassen, was angezeigt wird Sortieren In unterschiedliche Reihen aufteilen + Trailer + Andere + Behind the Scenes + Titelsongs + Titelvideos + Ausschnitte + Gelöschte Szenen + Interviews + Szenen + Probe + Featurettes + Shorts From cb779c82ff58d7ebba29a899d3b7c1dfa2778de8 Mon Sep 17 00:00:00 2001 From: nofkclue Date: Mon, 15 Jun 2026 08:39:38 +0000 Subject: [PATCH 066/475] Translated using Weblate (German) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index caf63eb7..772a2084 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -160,10 +160,11 @@ Hinter den Kulissen + Hinter den Kulissen - Gelöschte Szene - Gelöschte Szenen + Nicht verwendete Szene + Nicht verwendete Szenen %d Eintrag @@ -213,8 +214,8 @@ Interviews - Beispiel - Beispiele + Ausschnitt + Ausschnitte Begleitfilm @@ -612,7 +613,7 @@ Zeige Sprachsuche-Button Sprache Benutzeroberfläche Profilschutz - Weiterschauen/Als nächstes Verhalten + Klickverhalten bei Weiterschauen/Als nächstes Mit Klick abspielen Mit Klick Details zeigen Sterne @@ -633,4 +634,16 @@ Probe Featurettes Shorts + + Film + Filme + + + Serie + Serien + + + Darsteller + Darsteller + From b522320e97a70187df714151eab485749672ab64 Mon Sep 17 00:00:00 2001 From: damontecres Date: Sun, 14 Jun 2026 23:55:54 +0000 Subject: [PATCH 067/475] Translated using Weblate (Spanish) Currently translated at 99.6% (587 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 24414e3b..7193dc4c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -66,7 +66,6 @@ Tráiler Tráilers - Series Interfaz From f88f78116bb9103c3b12ce4be19ec0b1677423d1 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 15 Jun 2026 11:00:15 +0000 Subject: [PATCH 068/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 67 ++++++++++++++++++-------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0c6ac0d7..1ada5a3d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -163,49 +163,49 @@ Nos Bastidores - Músicas temáticas - Músicas temáticas - Músicas temáticas + Tema de abertura + Temas de abertura + Temas de abertura - Vídeos Temáticos - Vídeos Temáticos - Vídeos Temáticos + Genérico + Genéricos + Genéricos - Clipes + Clipe Clipes Clipes - Cenas Excluídas - Cenas Excluídas - Cenas Excluídas + Cena cortada + Cenas cortadas + Cenas cortadas - Entrevistas + Entrevista Entrevistas Entrevistas - Cenas + Cena Cenas Cenas - Amostras + Amostra Amostras Amostras - Curta-metragens - Curta-metragens - Curta-metragens + Video promocional + Videos promocionais + Videos promocionais - Curtos - Curtos - Curtos + Curta + Curtas + Curtas %s transferido @@ -252,7 +252,7 @@ Clique para mudar de página Alternar páginas da barra de navegação ao focar Protecção Contra Inatividade - Reproduzir música temática + Reproduzir tema de abertura Substituições de reprodução Lembrar separadores selecionados Activar a revisão em \'A seguir\' @@ -640,4 +640,31 @@ Tomates Ordenar Dividir em linhas separadas + Trailers + Outro + Nos bastidores + Clipes + Cenas cortadas + Entrevistas + Cenas + Amostras + Curtas + Genéricos + Temas de abertura + + Filme + Filmes + Filmes + + + Série de TV + Séries de TV + Séries de TV + + + Pessoa + Pessoas + Pessoas + + Videos promocionais From 115799c6c64b8feaf38cf42190ae73e49a38c2f6 Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Sun, 14 Jun 2026 23:56:13 +0000 Subject: [PATCH 069/475] Translated using Weblate (Swedish) Currently translated at 82.3% (485 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index fb3db1a3..faaa40fd 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -75,7 +75,6 @@ Topprankade osedda Förhandsvisningar - DVR-schema TV Guide From 127f3c74c27be71f68371535381e1f79e55d762d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 14 Jun 2026 23:55:54 +0000 Subject: [PATCH 070/475] Translated using Weblate (Estonian) Currently translated at 95.4% (562 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 35fc6d46..15d8799c 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -222,7 +222,6 @@ Parima hinnanguga vaatamata sisu Treilerid - DVR-i kava Telekava From 818e14c53ac23b6b54b3e94cfaac7dc2cdfcea07 Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sun, 14 Jun 2026 23:56:10 +0000 Subject: [PATCH 071/475] Translated using Weblate (Slovak) Currently translated at 85.9% (506 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 0402a4c7..d615e77a 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -145,9 +145,6 @@ Najlepšie hodnotené Nepozreté Trailery - - - DVR rozvrh Sprievodca From 8adfa3f312c4d13b06778feb93f3823f8df272a2 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Sun, 14 Jun 2026 23:56:03 +0000 Subject: [PATCH 072/475] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 52.1% (307 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nb_NO/ --- app/src/main/res/values-nb-rNO/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 50a7556d..48c5b438 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -121,7 +121,6 @@ Best vurderte usette Trailere - DVR-plan Programoversikt From d06ae90f1d58df169ac5361222f93cbb28c55b72 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sun, 14 Jun 2026 23:55:56 +0000 Subject: [PATCH 073/475] Translated using Weblate (French) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c1b69f22..11841157 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -100,8 +100,6 @@ Changer Bandes-annonces - - Saison Saisons From ef1a8184daf4940b8b0b92249f5c62fc155b0060 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 15 Jun 2026 19:48:03 +0000 Subject: [PATCH 074/475] Translated using Weblate (French) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 62 +++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 11841157..a97d9d32 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -99,7 +99,9 @@ Changer de serveur Changer - Bandes-annonces + Bandes-annonce + Bandes-annonces + Bandes-annonces Saison Saisons @@ -123,22 +125,34 @@ Succès Durée - Autres + Autre + Autres + Autres - Extraits + Extrait + Extraits + Extraits Scènes supprimées + Scène supprimé + Scènes supprimées Interviews + Interview + Interviews - Scènes + Scène + Scènes + Scènes - Échantillons + Échantillon + Échantillons + Échantillons Appareil compatible AC3/Dolby Digital Paramètres avancés @@ -170,6 +184,8 @@ Bonus Dans les coulisses + Dans les coulisses + Dans les coulisses Délai avant de lire \"À suivre\" Lecture automatique de \"À suivre\" @@ -231,7 +247,9 @@ Le téléchargement prend du temps, vous devrez peut-être redémarrer la lecture Les mieux notés non vus - Courts-métrages + Court-métrages + Courts-métrages + Courts-métrages %d heure @@ -262,9 +280,13 @@ Comportement de saut du récapitulatif Chanson à thème + Chansons à thème + Chanson à thème Vidéos à thème + Vidéos à thème + Vidéos à thème @@ -275,6 +297,7 @@ Bonus Bonus + Bonus Dolby Vision Dolby Atmos @@ -617,4 +640,31 @@ Tomates Trier Diviser en lignes distinctes + Bandes-annonces + Autre + Coulisses + Musiques de générique + Vidéos thématiques + Clips + Scènes coupées + Interviews + Scènes + Extraits + Featurettes + Courts-métrages + + Film + Films + Film + + + Série télévisée + Séries télévisées + Série télévisée + + + Acteur + Acteurs + Acteur + From 2cdf5a1738571bd8ebb4a8df442c9d8657a16c91 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 14 Jun 2026 23:55:59 +0000 Subject: [PATCH 075/475] Translated using Weblate (Italian) Currently translated at 95.4% (562 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 22515cc3..76a6d012 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -172,8 +172,6 @@ Cambia I trailer - - Programma DVR Scala Video From bcc84a99a444308bb100a44e9e46a2fc952b3445 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 15 Jun 2026 06:12:34 +0000 Subject: [PATCH 076/475] Translated using Weblate (Polish) Currently translated at 97.9% (577 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bbd31f79..c5bd37dc 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -637,4 +637,34 @@ Siatka Sortuj Podziel na osobne rzędy + Zwiastuny + Inne + Za kulisami + Piosenki tematyczne + Filmy tematyczne + Klipy + Usunięte sceny + Wywiady + Sceny + Próbki + Materiały dodatkowe + Szorty + + Film + Filmy + Filmów + Filmów + + + Osoba + Osoby + Osób + Osób + + + Serial + Seriale + Serialów + Serialów + From eac606f1ddf52939ea0b4e5e406ee4a9495f7f68 Mon Sep 17 00:00:00 2001 From: adambibor Date: Sun, 14 Jun 2026 23:55:57 +0000 Subject: [PATCH 077/475] Translated using Weblate (Hungarian) Currently translated at 53.4% (315 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 86c2b158..356175c2 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -122,7 +122,6 @@ Legjobbra értékelt, nem megnézett Előzetesek - DVR Időzítés Műsorújság From cb2724e8819ad482f3443f84bb78519d8e623be4 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 15 Jun 2026 04:13:31 +0000 Subject: [PATCH 078/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8bfd9a08..a774ec7a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -604,4 +604,25 @@ 用户界面语言 排序 拆分为单独的行 + 预告片 + 其他 + 幕后花絮 + 主题曲 + 主题视频 + 片段 + 删减片段 + 访谈 + 场景 + 样片 + 特辑 + 短片 + + 电影 + + + 电视节目 + + + 演职人员 + From 77f944dd992e0fecd2cfc48a787231dab8c8e548 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Sun, 14 Jun 2026 23:56:05 +0000 Subject: [PATCH 079/475] Translated using Weblate (Norwegian Nynorsk) Currently translated at 54.6% (322 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 1ff22c8f..7f6658f4 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -124,7 +124,6 @@ Best vurderte usette Trailerar - DVR-plan Programoversikt From 78195740a3c55d4600c9f31e243ae16d97ae31dd Mon Sep 17 00:00:00 2001 From: veisen Date: Sun, 14 Jun 2026 23:55:50 +0000 Subject: [PATCH 080/475] Translated using Weblate (Czech) Currently translated at 77.4% (456 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 26ce4b02..a683ad9b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -145,9 +145,6 @@ Nejlépe hodnocené Nezhlédnuté Upoutávky - - - Harmonogram nahrávání Televizní program From 8245b49cbc11829c589365d48e7b0d0b74dfb7a6 Mon Sep 17 00:00:00 2001 From: Sathen Date: Sun, 14 Jun 2026 23:56:16 +0000 Subject: [PATCH 081/475] Translated using Weblate (Ukrainian) Currently translated at 68.2% (402 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0a449c9f..6b2e2332 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -149,9 +149,6 @@ Топ непереглянутих Трейлери - - - Розклад DVR Посібник From 5568ded06796ec1db40456998c0b54506db0a85f Mon Sep 17 00:00:00 2001 From: idezentas Date: Mon, 15 Jun 2026 11:01:54 +0000 Subject: [PATCH 082/475] Translated using Weblate (Turkish) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index aee10d57..f6e8c935 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -63,7 +63,7 @@ İzlendi olarak işaretle Maksimum bit hızı Benzerleri - Diğer + Daha Fazla Film Filmler @@ -222,8 +222,8 @@ Örnekler - Tanıtım - Tanıtımlar + Özel Seçenek + Özel Seçenekler Kısa Video @@ -631,4 +631,19 @@ Sıkışık Liste Sırala Ayrı sıralara ayır + Filmler + Fragmanlar + Diziler + Diğer + Kamera Arkası + Tema Müzikleri + Tema Videoları + Klipler + Silinmiş Sahneler + Röportajlar + Sahneler + Örnekler + Özel Seçenekler + Kısa Videolar + Kişiler From dc5792e5ce82ec72c8af00a985c78afeaa05b267 Mon Sep 17 00:00:00 2001 From: alexkahler Date: Sun, 14 Jun 2026 23:55:51 +0000 Subject: [PATCH 083/475] Translated using Weblate (Danish) Currently translated at 66.3% (391 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 006adb67..6f6cbd17 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -148,7 +148,6 @@ Bedst bedømte usete Trailere - DVR-plan TV-guide From 52f11cc405bc35517480a3644d65ce2ec2fe9abd Mon Sep 17 00:00:00 2001 From: gatisr Date: Sun, 14 Jun 2026 23:56:02 +0000 Subject: [PATCH 084/475] Translated using Weblate (Latvian) Currently translated at 38.7% (228 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/lv/ --- app/src/main/res/values-lv/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 7a066578..e9cc76c9 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -148,8 +148,6 @@ Augstāk novērtētie un neredzētie Treileri - - Ierakstu plāns Programma From 200b2cd06c7105f312c65f6a90304a421f0d272a Mon Sep 17 00:00:00 2001 From: Pwnisher Date: Sun, 14 Jun 2026 23:56:09 +0000 Subject: [PATCH 085/475] Translated using Weblate (Romanian) Currently translated at 55.8% (329 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ro/ --- app/src/main/res/values-ro/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index bb443a53..64017e76 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -321,8 +321,6 @@ Cele mai bine cotate, nevizionate Trailere - - Redă cu transcodare Informații media From 0cb0d899a12ebb3a078e44d9ddb80ee4724fd93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 16 Jun 2026 20:50:01 +0000 Subject: [PATCH 086/475] Translated using Weblate (Estonian) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 31 +++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 15d8799c..b7ecec40 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -157,8 +157,8 @@ Stseenid - Näide - Näited + Näidislõik + Näidislõigud Keskmise pikkusega film @@ -221,7 +221,8 @@ Salvesta telesari Parima hinnanguga vaatamata sisu - Treilerid + Treiler + Treilerid DVR-i kava Telekava @@ -621,4 +622,28 @@ Kompaktne loend Järjesta Eralda ridadena + Treilerid + Muud + Kaadri tagaKaadri taga + Tunnuslood + Tunnusvideod + Klipid + Väljajäetud lõigud + Intervjuud + Stseenid + Näidislõigud + Keskmise pikkusega filmid + Lühifilmid + + Film + Filmid + + + Telesaade + Telesaated + + + Isik + Inimesed + From c2f2f13146fc8d123280606fe2f4d62163b1428f Mon Sep 17 00:00:00 2001 From: SimonHung Date: Wed, 17 Jun 2026 16:31:56 +0000 Subject: [PATCH 087/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 88da1023..93b1b0b7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -450,10 +450,10 @@ 拉伸填滿 填滿寬度 填滿高度 - 最低音量 - 低音量 + 最小音量 + 小音量 中等音量 - 高音量 + 大音量 最大音量 紫色 橘色 @@ -604,4 +604,25 @@ 介面語言 排序 分開為不同列 + 預告片 + 其他 + 幕後花絮 + 主題曲 + 主題影片 + 片段 + 刪剪片段 + 訪談 + 精選片段 + 試看片段 + 幕後特輯 + 短片 + + 電影 + + + 電視劇 + + + 演職人員 + From db401962d0bde94fe80d1fa7166bf920cd0e111c Mon Sep 17 00:00:00 2001 From: wizart Date: Wed, 17 Jun 2026 19:01:28 +0000 Subject: [PATCH 088/475] Translated using Weblate (Ukrainian) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 273 ++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 26 deletions(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6b2e2332..b3d7a7a0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -8,7 +8,7 @@ Аудіо Місце народження Бітрейт - Місце народженя + Дата народження Скасувати запис Скасувати запис серіалу Скасувати @@ -23,7 +23,7 @@ Підтвердити Продовжити перегляд Оцінка критиків - %.1f секунд + %.2f секунд За замовчуванням Видалити Помер @@ -31,7 +31,7 @@ Режисер Вимкнутий Виявлені сервери - + Завантаження… Увімкнено Закінчується о %1$s @@ -42,7 +42,7 @@ Зовнішній Улюблені Розмір - Forced + Примусові Жанри Перейти до серії Перейти до @@ -65,12 +65,15 @@ Більше подібного Більше - Фільми + Фільм + Фільми + Фільмів + Фільмів Ім\'я Наступний Дані відсутні - Відсутні результати + Результати відсутні Сервери не знайдені Заплановані записи відсутні Немає доступних оновлень @@ -79,7 +82,10 @@ Завершення Шлях - Люди + Людина + Людей + Людей + Людей Кількість відтворень Відтворити звідси @@ -104,7 +110,7 @@ Перезавантаження Відновити Зберегти - + Пошук Шукаю… Голосовий пошук @@ -148,14 +154,20 @@ Перемкнути Топ непереглянутих - Трейлери + Трейлер + Трейлери + Трейлерів + Трейлерів Розклад DVR Посібник Сезон Сезони - Серіали + ТВ-шоу + ТВ-шоу + ТВ-шоу + ТВ-шоу Інтерфейс Невідомо @@ -183,36 +195,69 @@ Додатково Інше + Іншого + Іншого + Іншого Позакадром + Позакадром + Позакадром + Позакадром - Тематичні пісні + Музична тема + Музичні теми + Музичних тем + Музичних тем - Тематичні відео + Тематичне відео + Тематичні відео + Тематичних відео + Тематичних відео - Кліпи + Кліп + Кліпи + Кліпів + Кліпів - Видалені сцени + Видалена сцена + Видалені сцени + Видалених сцен + Видалених сцен Інтерв\'ю + Інтерв\'ю + Інтерв\'ю + Інтерв\'ю - Сцени + Сцена + Сцени + Сцен + Сцен - Зразки + Зразок + Зразка + Зразків + Зразків - Короткометражні фільми + Короткометражний фільм + Короткометражні фільми + Короткометражних фільмім + Короткометражних фільмім - Шорти + Коротке відео + Короткі відео + Коротких відео + Коротких відео %s завантаження @@ -247,7 +292,7 @@ Автоматичне відтворення наступного Перевірити оноалення Застосовується тільки до серіалів - Пряме відтворення субтитрів ASS + Використовувати libass для субтитрів ASS Пряме відтворення субтитрів PGS Завжди змішувати в стерео Використовувати модуль декодера FFmpeg @@ -292,7 +337,7 @@ Колір фону Скинути Жирний шрифт - Playback Backend + Компонент відтворення MPV: Використовувати апаратне декодування Вимкніть, якщо ви стикаєтеся з проблемами Варіанти MPV @@ -330,9 +375,9 @@ Відступ(Інтервал) Співвідношення сторін Показати деталі - + Тип зображення - + Запрошені зірки Розмір контуру Перемикання частоти оновлення @@ -431,10 +476,10 @@ Відтворення відео під час слайд-шоу Улюблені %s - день - дні - днів - днів + %s день + %s дні + %s днів + %s днів Швидке підключення Дозвольте іншому пристрою увійти у ваш обліковий запис @@ -467,4 +512,180 @@ Налаштування дисплея Вбудовані налаштування для швидкого оформлення всіх рядків Обери рядки і зображення для головної сторінки + Люди + Трейлери + Телешоу + Інше + За лаштунками + Фільми + Музичні теми + Тематичні відео + Кліпи + Видалені сцени + Інтерв\'ю + Сцени + Зразки + Короткометражні фільми + Короткі відео + + %s хвилина + %s хвилини + %s хвилин + %s хвилин + + Запитувати пароль для профілю + [0-9] + Спільні результати пошуку + Показувати всі результати одним списком + Показувати результати за категоріями + Показувати кнопку голосового пошуку + Видимий + Прихований + Результати + Альбоми + Виконавці + Пісні + До виконавця + Зараз грає + Швидкий мікс + До альбому + Додати до черги + Почати після + Анімація + Обмеження за віком + Без обмежень + Для будь-якого віку + До %s років + Заставка + Налаштування заставки + Запустити заставку + Тривалість + Фото + Включити типи + Для яких типів елементів показувати зображення + Вписати + Обрізати + Заповнити + Заповнити по ширині + Заповнити по висоті + Wholphin за замовчуванням + Мініатюри серіалів + Мініатюри серій + Найнижча + Низька + Середня + Висока + Максимальна + Пурпуровий + Помаранчевий + Яскраво-синій + Чорний + Ігнорувати + Пропускати автоматично + Запитувати про пропуск + Використовувати FFmpeg, лише якщо немає вбудованого декодера + Надавати перевагу FFmpeg перед вбудованими декодерами + Ніколи не використовувати декодери FFmpeg + Після завершення відтворення + Під час фінальних титрів + Ніколи + Білий + Світло-сірий + Темно-сірий + Жовтий + Блакитний + Маджента + Контур + Тінь + Обтікання + У рамці + Надавати перевагу MPV + Зовнішній плеєр + Використовувати ExoPlayer для відтворення HDR + Постер (2:3) + 16:9 + 4:3 + Квадрат (1:1) + Основне + Мініатюра + Сітка + Список + Компактний список + Зображення з динамічним кольором + Лише зображення + + API-ключ + Увійти до сервера Seerr + Користувач Jellyfin + Локальний користувач + + Віддалених субтитрів не знайдено + Дотепер + Використовувати вбудовану заставку + Ви впевнені, що хочете видалити цей елемент? + Показувати параметри керування медіа + Шукати %s + Актор + Композитор + Сценарист + Запрошена зірка + Продюсер + Диригент + Автор тексту + Аранжувальник + Звукорежисер + Звукорежисер зведення + Автор + Виконавець + Виконавець альбому + Альбом + Популярні пісні + Відтворити наступним + Музичні відео + Текст пісн + Приховати текст пісні + Показати текст пісні + Пісня має текст + Видалити з черги + Показувати обкладинку альбому + Показувати візуалізатор + Показувати фон + Відтворити як? + Для візуалізації аудіо, що зараз відтворюється, потрібен дозвіл на запис звуку. + Продовжити + Вибрати все + Розділяти типи + Надавати перевагу логотипам для назв + Wholphin оновлено до %s + Видалити з «Продовжити перегляд» + Видалити серіал з «Далі» + Переглянути серіали, видалені з «Далі» + Переглянути більше + Знайти ТВ-шоу + Знайти фільми + Студії у %1$s + Останні відтворені + Системне за замовчуванням + Пряме відтворення через libass + Пряме відтворення через вбудований ExoPlayer + Вшивати/транскодувати на сервері + Відтворення субтитрів SSA/ASS + Режим кінотеатру + Показати ще %1$s + Поведінка пропуску + Для інтро, титрів тощо + Відтворити за допомогою + Транскодування + Мова інтерфейсу + Відображення рейтингів + Налаштуйте, що відображати + Захист профілю + Дія при натисканні в «Продовжити перегляд» + Відтворювати при натисканні + Показувати деталі при натисканні + Зірки + Томати + Сортування + Розділити на окремі рядки + Wholphin компактний From 501ecf89b01acf8394b2eb6ef9c2f8a77fad241c Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Thu, 18 Jun 2026 17:34:42 +0000 Subject: [PATCH 089/475] Translated using Weblate (Spanish) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7193dc4c..b2a170e8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -65,7 +65,8 @@ Cambiar Tráiler - Tráilers + Tráileres + Tráileres Series Interfaz @@ -665,4 +666,5 @@ Personas Personas + Tráileres From ac18bdf285bd2fcbd37208d8a9e7ef64c344c031 Mon Sep 17 00:00:00 2001 From: tal_sarid Date: Sat, 20 Jun 2026 14:40:51 +0000 Subject: [PATCH 090/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 75 +++++++++++++++++++------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index e6027e7a..1b4462d0 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -11,7 +11,7 @@ בטל הקלטה בטל הקלטת סדרה בטל - פרקי משנה + סצינות נקה זיכרון מטמון אוסף אוספים @@ -52,9 +52,9 @@ עונות עונה - טריילר - טריילרים - טריילרים + קדימון + קדימונים + קדימונים כתובית כתוביות @@ -104,8 +104,8 @@ נגן מהירות ניגון ניגון - פלייליסט - פלייליסטים + רשימת־השמעה + רשימות־השמעה הורדה %s %s הורדות @@ -220,8 +220,8 @@ פרטי המדיה הצג שעון סדר שידור הפרקים - צור פלייליסט חדש - הוסף לפלייליסט + צור רשימת־השמעה חדשה + הוסף לרשימת־השמעה השהה בלחיצה אחת לחץ על מרכז כפתור הניווט להשהייה/ניגון גופן נטוי @@ -233,36 +233,58 @@ תכנים נוספים אחר + אחרים + אחרים מאחורי הקלעים + מאחורי הקלעים + מאחורי הקלעים - שירי נושא + שיר־נושא + שירי־נושא + שירי־נושא - סרטוני נושא + סרטון־נושא + סרטוני־נושא + סרטוני־נושא - קליפים + קליפ + קליפים + קליפים - סצינות שנמחקו + סצינה גנוזה + סצינות גנוזות + סצינות גנוזות - ראיונות + ראיון + ראיונות + ראיונות - סצינות + סצינה + סצינות + סצינות - דגימות + דגימה + דגימות + דגימות - סרטונים מיוחדים + קטע־בונוס + קטעי־בונוס + קטעי־בונוס - קטעים קצרים + קטע קצר + קטעים קצרים + קטעים קצרים מועדפים: %s המכשיר תומך ב־AC3/Dolby Digital @@ -397,8 +419,8 @@ סגנון תמונת־רקע החלפת רזולוציית־תצוגה מקומי - נגן טריילר - אין טריילרים + נגן קדימון + אין קדימונים ניקוי בחירת רצועות ניגון ישיר של Dolby Vision Profile 7 התעלם מבדיקות תאימות של המכשיר @@ -630,4 +652,19 @@ עגבניות מיון פצל לשורות נפרדות + סרטים + אנשים + קדימונים + סדרות + אחר + מאחורי הקלעים + שירי־נושא + סרטוני־נושא + קליפים + סצינות גנוזות + ראיונות + סצינות + דגימות + קטעי בונוס + קטעים קצרים From d85f1882368ff6f76236a3d297f818bfc621cb74 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 09:44:19 -0400 Subject: [PATCH 091/475] Show runtime on different versions dialog (#1607) ## Description Small PR to add the runtime for different versions of media to the choose version dialog. This can be helpful to further distinguish versions such as theatrical vs extended editions. ### Related issues Suggested https://github.com/damontecres/Wholphin/pull/1522#issuecomment-4766811832 ### Testing Emulator ## Screenshots image ## AI or LLM usage None --- .../damontecres/wholphin/ui/components/Dialogs.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 2224a010..75c7fbf7 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 @@ -70,6 +70,7 @@ 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.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay @@ -77,6 +78,7 @@ import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID @@ -640,6 +642,16 @@ fun chooseVersionParams( } Text(text) }, + trailingContent = { + val runtime = + remember { + source.runTimeTicks + ?.ticks + ?.roundMinutes + .toString() + } + Text(runtime) + }, onClick = { onClick.invoke(index) }, ) }, From 443175770584819982c757128c4b0bdce1bfad62 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 10:36:27 -0400 Subject: [PATCH 092/475] Release v1.0.2 From 77d7571276efcddb170881c3eef0171e2a880e43 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 18:37:16 -0400 Subject: [PATCH 093/475] Choose Seerr quality profile & root folder (#1604) ## Description Adds option to select the quality profile and/or root folder when requesting from Seerr. The user needs "Advanced Requests" permission to use this feature. If not, the options won't show. ### Related issues Closes #863 Related to #702 ### Testing Emulator w/ different user permissions and different request configurations ## Screenshots request_movie Large ## AI or LLM usage None --- .../wholphin/services/SeerrService.kt | 128 ++++++ .../detail/discover/DiscoverMovieDetails.kt | 61 +-- .../detail/discover/DiscoverMovieViewModel.kt | 52 ++- .../detail/discover/DiscoverSeriesDetails.kt | 17 +- .../discover/DiscoverSeriesViewModel.kt | 63 ++- .../ui/detail/discover/RequestMovie.kt | 397 ++++++++++++++++++ .../ui/detail/discover/RequestSeasons.kt | 214 ++++++++-- .../ui/detail/discover/SeerrRequestData.kt | 40 ++ app/src/main/res/values/strings.xml | 3 + app/src/main/seerr/seerr-api.yml | 62 ++- 10 files changed, 899 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt 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 d9a340c9..9643da39 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 @@ -6,13 +6,21 @@ 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.RootFolder import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile 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.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder +import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.toLocalDate import kotlinx.coroutines.flow.first @@ -191,6 +199,126 @@ class SeerrService return "${base}${prefix}$path" } + suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData { + var result = SeerrRequestData() + val current = seerrServerRepository.current.firstOrNull() + if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) { + if (type == SeerrItemType.MOVIE) { + val radarrs = api.serviceApi.serviceRadarrGet() + val radarr = + radarrs.firstOrNull { it.isDefault && !it.is4k } + ?: radarrs.firstOrNull { !it.is4k } + val radarr4k = + radarrs.firstOrNull { it.isDefault && it.is4k } + ?: radarrs.firstOrNull { it.is4k } + + if (radarr != null && radarr.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = radarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (radarr4k != null && radarr4k.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = radarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } + } else { + val sonarrs = api.serviceApi.serviceSonarrGet() + val sonarr = + sonarrs.firstOrNull { it.isDefault && !it.is4k } + ?: sonarrs.firstOrNull { !it.is4k } + val sonarr4k = + sonarrs.firstOrNull { it.isDefault && it.is4k } + ?: sonarrs.firstOrNull { it.is4k } + + if (sonarr != null && sonarr.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = sonarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (sonarr4k != null && sonarr4k.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = sonarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } + } + } + return result + } + + private fun setProfilesAndFolder( + profiles: List?, + rootFolders: List?, + activeProfileId: Int?, + activeDirectory: String?, + setter: (List, List) -> Unit, + ) { + val profiles = + profiles.orEmpty().mapNotNull { + if (it.id != null && it.name != null) { + SeerrProfile(it.id, it.name, activeProfileId == it.id) + } else { + null + } + } + val rootFolders = + rootFolders.orEmpty().mapNotNull { + if (it.id != null && it.path != null) { + val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: "" + SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path) + } else { + null + } + } + setter.invoke(profiles, rootFolders) + } + suspend fun createDiscoverItem(item: Any): DiscoverItem = when (item) { is MovieResult -> createDiscoverItem(item) 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 bbc85042..4dea6d41 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 @@ -53,9 +53,6 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog @@ -90,7 +87,7 @@ fun DiscoverMovieDetails( val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf(null) } - var moreDialog by remember { mutableStateOf(null) } + var showRequestDialog by remember { mutableStateOf(false) } val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) @@ -119,28 +116,8 @@ fun DiscoverMovieDetails( similar = state.similar, recommended = state.recommended, requestOnClick = { - movie.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } - } + viewModel.requestOnClick() + showRequestDialog = true }, cancelOnClick = { movie.id?.let { viewModel.cancelRequest(it) } @@ -170,14 +147,7 @@ fun DiscoverMovieDetails( ) } }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = listOf(), - ) - }, + moreOnClick = {}, onLongClickPerson = { index, person -> }, onLongClickSimilar = { index, similar -> }, @@ -186,6 +156,19 @@ fun DiscoverMovieDetails( }, modifier = modifier, ) + if (showRequestDialog) { + RequestMovieDialog( + loading = state.profileLoading, + data = state.requestData, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = { + viewModel.request(it) + showRequestDialog = false + }, + onDismissRequest = { showRequestDialog = false }, + ) + } } } overviewDialog?.let { info -> @@ -195,16 +178,6 @@ fun DiscoverMovieDetails( onDismissRequest = { overviewDialog = null }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } } private const val HEADER_ROW = 0 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 14440539..9afde46e 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 @@ -11,6 +11,7 @@ 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.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.hasPermission @@ -24,6 +25,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -164,23 +166,33 @@ class DiscoverMovieViewModel navigationManager.navigateTo(destination) } - fun request( - id: Int, - is4k: Boolean, - ) { + fun request(request: MovieRequest) { viewModelScope.launchIO { + if (request.movieId == null) { + Timber.w("Null movie ID") + return@launchIO + } try { seerrService.api.requestApi.requestPost( RequestPostRequest( - is4k = is4k, - mediaId = id, + is4k = request.is4k, + mediaId = request.movieId, mediaType = RequestPostRequest.MediaType.MOVIE, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), ), ) - } catch (ex: kotlinx.coroutines.CancellationException) { + } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { - Timber.e(ex, "Error requesting %s", id) + Timber.e(ex, "Error requesting %s", request.movieId) showToast(context, "An error occurred") } fetchAndSetItem().await() @@ -205,6 +217,28 @@ class DiscoverMovieViewModel } } } + + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.MOVIE) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } } fun canUserCancelRequest( @@ -226,4 +260,6 @@ data class DiscoverMovieState( val similar: List = emptyList(), val recommended: List = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) 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 5b87b3cd..be9acc64 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 @@ -92,9 +92,6 @@ fun DiscoverSeriesDetails( var moreDialog by remember { mutableStateOf(null) } var showRequestSeasonDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - when (val st = state.tvSeries) { is DataLoadingState.Error -> { ErrorMessage(st, modifier) @@ -150,9 +147,8 @@ fun DiscoverSeriesDetails( }, trailers = state.trailers, requestOnClick = { - item.id?.let { id -> - showRequestSeasonDialog = true - } + viewModel.requestOnClick() + showRequestSeasonDialog = true }, cancelOnClick = { item.id?.let { viewModel.cancelRequest(it) } @@ -192,15 +188,16 @@ fun DiscoverSeriesDetails( } if (showRequestSeasonDialog) { RequestSeasonsDialog( + id = state.tvSeries.successValue?.id ?: -1, title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, request4kEnabled = request4kEnabled, - onSubmit = { seasons, is4k -> - state.tvSeries.successValue - ?.id - ?.let { viewModel.request(it, seasons, is4k) } + onSubmit = { showRequestSeasonDialog = false + viewModel.request(it) }, + loading = state.profileLoading, + data = state.requestData, onDismissRequest = { showRequestSeasonDialog = false }, ) } 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 96ba1efa..4a9eb3f2 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 @@ -12,6 +12,7 @@ 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.SeerrItemType import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -22,6 +23,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -198,11 +200,29 @@ class DiscoverSeriesViewModel _state.update { it.copy(canCancelRequest = canCancel) } } - fun request( - id: Int, - seasons: Set, - is4k: Boolean, - ) { + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.TV) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } + + fun request(request: TvRequest) { viewModelScope.launchIO { state.value.tvSeries.successValue?.let { tv -> val currentRequest = @@ -217,26 +237,43 @@ class DiscoverSeriesViewModel requestId = currentRequest.id.toString(), requestRequestIdPutRequest = RequestRequestIdPutRequest( - is4k = is4k, + is4k = request.is4k, mediaType = RequestRequestIdPutRequest.MediaType.TV, - seasons = seasons.toList(), + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, ), ) } else { - Timber.v("New request for %s seasons", seasons.size) + Timber.v("New request for %s seasons", request.seasons.size) seerrService.api.requestApi.requestPost( RequestPostRequest( - is4k = is4k, - mediaId = id, + is4k = request.is4k, + mediaId = request.tvId, mediaType = RequestPostRequest.MediaType.TV, - seasons = seasons.toList(), + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), ), ) } } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { - Timber.e(ex, "Error requesting %s", id) + Timber.e(ex, "Error requesting %s", request.tvId) showToast(context, "An error occurred") } @@ -279,4 +316,6 @@ data class DiscoverSeriesState( val similar: List = emptyList(), val recommended: List = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt new file mode 100644 index 00000000..7a009f09 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -0,0 +1,397 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DialogPopupContent +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun RequestMovieDialog( + loading: LoadingState, + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestMovie( + loading = loading, + data = data, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun RequestMovie( + loading: LoadingState, + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && + data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() + ) { + if (request4kEnabled) { + val items = + remember { + listOf( + DialogItem( + text = requestStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + }, + ), + DialogItem( + text = request4kStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, true, null, null)) + }, + ), + ) + } + DialogPopupContent( + title = movie.title + " (${movie.releaseDate ?: ""})", + dialogItems = items, + waiting = false, + onDismissRequest = {}, + dismissOnClick = false, + modifier = modifier.focusRequester(focusRequester), + ) + } else { + LaunchedEffect(Unit) { + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + } + } + } else { + RequestMovieWithOptions( + data = data, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = modifier.focusRequester(focusRequester), + ) + } + } + } +} + +@Composable +private fun RequestMovieWithOptions( + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + var is4k by remember { mutableStateOf(request4kEnabled) } + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = movie.title + " (${movie.releaseDate ?: ""})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier, + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + if (request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + Button( + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, is4k, profile?.id, folder?.path)) + }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } + } + } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + } + } +} + +@Composable +fun ChooseProfile( + selectedProfile: SeerrProfile, + profiles: List, + onClickProfile: (SeerrProfile) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val qualityProfileStr = stringResource(R.string.quality_profile) + var showProfileDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(qualityProfileStr) + }, + supportingContent = { + val text = + remember(selectedProfile) { + if (selectedProfile.default) "${selectedProfile.name} ($defaultStr)" else selectedProfile.name + } + Text(text) + }, + onClick = { showProfileDialog = true }, + modifier = modifier, + ) + if (showProfileDialog) { + val params = + remember { + val items = + profiles.map { + DialogItem( + headlineContent = { + Text(it.name) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + leadingContent = { + SelectedLeadingContent(it == selectedProfile) + }, + onClick = { onClickProfile.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = qualityProfileStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showProfileDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@Composable +fun ChooseFolder( + selectedFolder: SeerrRootFolder, + folders: List, + onClickFolder: (SeerrRootFolder) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val rootFolderStr = stringResource(R.string.root_folder) + var showFolderDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(rootFolderStr) + }, + supportingContent = { + val text = + remember(selectedFolder) { + if (selectedFolder.default) "${selectedFolder.path} ($defaultStr)" else selectedFolder.path + } + Text(text) + }, + trailingContent = { + Text(selectedFolder.freeSpace) + }, + onClick = { showFolderDialog = true }, + modifier = modifier, + ) + if (showFolderDialog) { + val params = + remember { + val items = + folders.map { + DialogItem( + headlineContent = { + Text(it.path) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + trailingContent = { + Text(it.freeSpace) + }, + leadingContent = { + SelectedLeadingContent(it == selectedFolder) + }, + onClick = { onClickFolder.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = rootFolderStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showFolderDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@PreviewTvSpec +@Composable +fun MovieRequestPreview() { + WholphinTheme { + RequestMovie( + loading = LoadingState.Success, + data = + SeerrRequestData( + profiles = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + profiles4k = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + ), + request4kEnabled = false, + movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), + onSubmit = {}, + modifier = + Modifier + .width(320.dp) + .padding(16.dp), + ) + } +} 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 index 4a56cb88..04ce859b 100644 --- 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 @@ -12,7 +12,9 @@ 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.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf @@ -20,9 +22,10 @@ import androidx.compose.runtime.remember 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.focusRequester 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 @@ -40,7 +43,11 @@ 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.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState data class RequestSeason( val season: Season, @@ -49,14 +56,16 @@ data class RequestSeason( @Composable fun RequestSeasons( + id: Int, title: String, seasons: List, - onSubmit: (Set, Boolean) -> Unit, + data: SeerrRequestData, request4kEnabled: Boolean, - modifier: Modifier, + onSubmit: (TvRequest) -> Unit, + modifier: Modifier = Modifier, ) { val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } - val selected = + val selectedSeasons = remember { mutableStateSetOf( *seasons @@ -69,39 +78,74 @@ fun RequestSeasons( }.toTypedArray(), ) } - var is4k by remember { mutableStateOf(false) } + var is4k by remember { mutableStateOf(request4kEnabled) } + + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + + fun submit() { + onSubmit.invoke( + TvRequest( + data = data, + tvId = id, + seasons = selectedSeasons.toList(), + is4k = is4k, + profileId = profile?.id, + folder = folder?.path, + ), + ) + } + Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Text( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier, ) - LazyColumn { + LazyColumn( + modifier = Modifier, + ) { item { - val isSelected = selected.containsAll(allSeasonNumbers) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { - ClickSwitch( - label = stringResource(R.string.select_all), - checked = isSelected, - onClick = { - if (isSelected) { - selected.removeAll(allSeasonNumbers) - } else { - selected.addAll(allSeasonNumbers) - } - }, - ) + if (request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -109,39 +153,80 @@ fun RequestSeasons( } } } - if (request4kEnabled) { - item { - ClickSwitch( - label = stringResource(R.string.request_4k), - checked = is4k, - onClick = { is4k = !is4k }, - ) + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } } } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + item { + HorizontalDivider() + } + item { + Text( + text = stringResource(R.string.tv_seasons), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val isSelected = selectedSeasons.containsAll(allSeasonNumbers) + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selectedSeasons.removeAll(allSeasonNumbers) + } else { + selectedSeasons.addAll(allSeasonNumbers) + } + }, + ) + } itemsIndexed(seasons) { index, season -> val seasonNumber = season.season.seasonNumber - val isSelected = seasonNumber in selected + val isSelected = seasonNumber in selectedSeasons SeasonListItem( season = season, selected = isSelected, onClick = { if (isSelected) { - selected.remove(seasonNumber) + selectedSeasons.remove(seasonNumber) } else { - seasonNumber?.let { selected.add(it) } + seasonNumber?.let { selectedSeasons.add(it) } } }, modifier = Modifier, ) } - if (seasons.size > 7) { + if (seasons.size > 3) { item { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -237,7 +322,7 @@ private fun ClickSurface( } @Composable -private fun ClickSwitch( +fun ClickSwitch( label: String, checked: Boolean, onClick: () -> Unit, @@ -267,22 +352,46 @@ private fun ClickSwitch( @Composable fun RequestSeasonsDialog( + id: Int, title: String, + loading: LoadingState, + data: SeerrRequestData, seasons: List, request4kEnabled: Boolean, - onSubmit: (Set, Boolean) -> Unit, + onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, ) { BasicDialog( onDismissRequest = onDismissRequest, ) { - RequestSeasons( - title = title, - seasons = seasons, - request4kEnabled = request4kEnabled, - onSubmit = onSubmit, - modifier = Modifier.padding(16.dp), - ) + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, Modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(Modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + RequestSeasons( + id = id, + title = title, + data = data, + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = + Modifier + .padding(16.dp) + .focusRequester(focusRequester), + ) + } + } } } @@ -313,10 +422,23 @@ fun RequestSeasonsPreview() { WholphinTheme { RequestSeasons( + id = 1, title = "Series title", seasons = seasons, + data = + SeerrRequestData( + profiles4k = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/tv", "400GB", true), + ), + ), request4kEnabled = true, - onSubmit = { _, _ -> }, + onSubmit = { }, modifier = Modifier.width(400.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt new file mode 100644 index 00000000..355049dc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt @@ -0,0 +1,40 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +data class SeerrRequestData( + val serverId: Int = -1, + val server4kId: Int = -1, + val profiles: List = emptyList(), + val rootFolders: List = emptyList(), + val profiles4k: List = emptyList(), + val rootFolders4k: List = emptyList(), +) + +data class SeerrProfile( + val id: Int, + val name: String, + val default: Boolean, +) + +data class SeerrRootFolder( + val id: Int, + val path: String, + val freeSpace: String, + val default: Boolean, +) + +data class MovieRequest( + val data: SeerrRequestData, + val movieId: Int?, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) + +data class TvRequest( + val data: SeerrRequestData, + val tvId: Int, + val seasons: List, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5240223b..facdec73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,4 +821,7 @@ Tomatoes Sort Split into separate rows + Quality profile + Default + Root folder diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 4c21b699..254bdb09 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -603,15 +603,14 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - minimumAvailability + # - minimumAvailability - isDefault SonarrSettings: type: object @@ -682,15 +681,15 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - apiKey + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - enableSeasonFolders + # - enableSeasonFolders - isDefault ServarrTag: type: object @@ -1396,6 +1395,17 @@ components: name: type: string example: 720p/1080p + RootFolder: + type: object + properties: + id: + type: integer + example: 1 + freeSpace: + type: long + path: + type: string + example: /movies PageInfo: type: object properties: @@ -6154,9 +6164,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6166,6 +6176,10 @@ paths: userid: type: integer nullable: true + tags: + type: array + items: + type: integer required: - mediaType - mediaId @@ -6261,9 +6275,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -7061,7 +7075,13 @@ paths: server: $ref: '#/components/schemas/RadarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr: get: summary: Get non-sensitive Sonarr server list @@ -7101,7 +7121,13 @@ paths: server: $ref: '#/components/schemas/SonarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr/lookup/{tmdbId}: get: summary: Get series from Sonarr From c945e1a4729b0df7207b767dee00de6289431561 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 18:38:19 -0400 Subject: [PATCH 094/475] Several small fixes (#1612) ## Description - Fixes showing "null" in the subtitle for some tv episodes (usually daily shows like Jeopardy) - Partially fix holding back to return to the previous grid position (still has focus issues) - Show/hide tabs on the discover page similar to other pages in the app - Prevent a warning server-side if a negative playback timestamp is sent ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 19 ++++++++++--------- .../damontecres/wholphin/ui/Extensions.kt | 2 ++ .../wholphin/ui/detail/CardGrid.kt | 9 +++++---- .../wholphin/ui/discover/DiscoverPage.kt | 3 +++ .../ui/discover/DiscoverSearchPage.kt | 6 +++++- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 7 ++++++- .../wholphin/ui/discover/SeerrRequestsPage.kt | 5 ++++- .../wholphin/ui/playback/PlayExternalPage.kt | 5 ++++- .../util/TrackActivityPlaybackListener.kt | 6 +++++- 9 files changed, 44 insertions(+), 18 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 4b3c248f..5ca32695 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 @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.getDateFormatter +import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -57,14 +58,14 @@ data class BaseItem( val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name - val subtitle - get() = - when (type) { - BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name - BaseItemKind.SERIES -> data.seriesProductionYears - BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ") - else -> data.productionYear?.toString() - } + val subtitle: String? by lazy { + when (type) { + BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") + BaseItemKind.SERIES -> data.seriesProductionYears + BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") + else -> data.productionYear?.toString() + } + } val subtitleLong: String? by lazy { if (type == BaseItemKind.EPISODE) { @@ -72,7 +73,7 @@ data class BaseItem( add(data.seasonEpisodePadded) add(data.name) add(data.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") + }.joinNotBlank(" - ") } else { data.productionYear?.toString() } 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 31ad597a..37353a8f 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 @@ -74,6 +74,8 @@ inline fun List.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { return if (index >= 0) index else null } +fun Iterable.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) } + /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ 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 b399766c..6587c626 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 @@ -141,7 +141,7 @@ fun CardGrid( { index: Int -> if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") if (index != currentFocusedIndex) { - previouslyFocusedIndex = focusedIndex + previouslyFocusedIndex = currentFocusedIndex } focusedIndex = index } @@ -257,10 +257,11 @@ fun CardGrid( val newPosition = previouslyFocusedIndex if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") if (newPosition > 0) { - focusOn(newPosition) scope.launch(ExceptionHandler()) { - gridState.scrollToItem(newPosition, -columns) - firstFocus.tryRequestFocus() + pager.getOrNull(newPosition) + gridState.scrollToItem(newPosition) + focusOn(newPosition) + alphabetFocus = true } } return@onKeyEvent true 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 9751a6d0..3ef3424a 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 @@ -42,6 +42,7 @@ fun DiscoverPage( 0 -> { SeerrDiscoverPage( preferences = preferences, + positionCallback = { showHeader = it.row < 1 }, modifier = Modifier .fillMaxSize() @@ -53,6 +54,7 @@ fun DiscoverPage( 1 -> { SeerrRequestsPage( focusRequesterOnEmpty = tabDetails.tabFocusRequester, + positionCallback = { columns, index -> showHeader = index < columns }, modifier = Modifier .fillMaxSize() @@ -64,6 +66,7 @@ fun DiscoverPage( 2 -> { DiscoverSearchPage( preferences = preferences, + positionCallback = { columns, index -> showHeader = index < columns }, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index f0664d49..fe4a198a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -102,6 +102,7 @@ class DiscoverSearchViewModel @Composable fun DiscoverSearchPage( preferences: UserPreferences, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, viewModel: DiscoverSearchViewModel = hiltViewModel(), ) { @@ -241,7 +242,10 @@ fun DiscoverSearchPage( onPlayItem = { _, _ -> }, onClickPosition = { position = it.column }, onClickDiscover = onClickDiscover, - positionCallback = { columns, index -> position = index }, + positionCallback = { columns, index -> + position = index + positionCallback.invoke(columns, index) + }, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 7e98b5c8..ccdda6d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -243,6 +243,7 @@ data class DiscoverState( @Composable fun SeerrDiscoverPage( preferences: UserPreferences, + positionCallback: (RowColumn) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { @@ -335,12 +336,16 @@ fun SeerrDiscoverPage( viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + onCardFocus = { index -> + position = RowColumn(rowIndex, index) + positionCallback.invoke(position) + }, focusRequester = focusRequesters[rowIndex], enableViewMore = row.type != DiscoverRequestType.UNKNOWN, onClickViewMore = { (row.items as? DataLoadingState.Success>)?.data?.size?.let { position = RowColumn(rowIndex, it) + positionCallback.invoke(position) } viewModel.navigationManager.navigateTo( Destination.DiscoverMoreResult(row.type), 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 50f2464c..52815683 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 @@ -148,10 +148,12 @@ data class RequestGridItem( @Composable fun SeerrRequestsPage( focusRequesterOnEmpty: FocusRequester?, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrRequestsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + val columns = 6 when (val state = state.requests) { is DataLoadingState.Error -> { @@ -213,7 +215,8 @@ fun SeerrRequestsPage( modifier = mod, ) }, - columns = 6, + columns = columns, + positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index a9e4b61d..871264c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -338,12 +338,15 @@ class PlayExternalViewModel } } Timber.v("Result position: %s", position?.milliseconds) + if (position != null && position < 0L) { + Timber.w("Unknown stop position for external playback") + } if (position != null || result.data?.action != null) { api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, mediaSourceId = mediaSourceId, - positionTicks = position?.milliseconds?.inWholeTicks, + positionTicks = position?.milliseconds?.inWholeTicks?.takeIf { it >= 0 }, failed = false, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 30241845..6e11013b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -21,6 +21,7 @@ import timber.log.Timber import java.util.Timer import java.util.TimerTask import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -84,10 +85,13 @@ class TrackActivityPlaybackListener( launch("reportPlaybackStopped") { getState.invoke()?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") + if (position < Duration.ZERO) { + Timber.w("Negative position when reporting playback stopped: %s", position) + } api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = state.itemId, - positionTicks = position.inWholeTicks, + positionTicks = position.inWholeTicks.takeIf { it >= 0 }, failed = false, playSessionId = state.playSessionId, liveStreamId = state.liveStreamId, From f21b29fe1b9c44d8c6da316fe5847aefb2598efe Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 19:35:44 -0400 Subject: [PATCH 095/475] Discover movies & tv shows by genre (#1610) ## Description Adds genre rows for movies & TV shows to the discover page. These work the same as on the Seerr web UI, clicking one will show media in the genre. ### Related issues Related to https://github.com/damontecres/Wholphin/issues/702 ### Testing Just on the emulator so far ## Screenshots discover_genres Large ## AI or LLM usage None --- .../wholphin/data/model/DiscoverItem.kt | 8 +- .../wholphin/services/SeerrService.kt | 6 +- .../ui/discover/DiscoverFocusedItem.kt | 53 +++ .../wholphin/ui/discover/DiscoverGenreRow.kt | 82 +++++ .../ui/discover/DiscoverRequestGrid.kt | 125 ++++--- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 345 +++++++++++++++--- .../wholphin/ui/nav/Destination.kt | 5 +- .../wholphin/util/DiscoverRequestPager.kt | 68 +++- app/src/main/res/values/strings.xml | 2 + 9 files changed, 564 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.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 db03ff48..471cad6d 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 @@ -92,13 +92,7 @@ data class DiscoverItem( val destination: Destination get() { - val jfType = - when (type) { - SeerrItemType.MOVIE -> BaseItemKind.MOVIE - SeerrItemType.TV -> BaseItemKind.SERIES - SeerrItemType.PERSON -> BaseItemKind.PERSON - SeerrItemType.UNKNOWN -> null - } + val jfType = type.baseItemKind return if (jellyfinItemId != null && jfType != null) { Destination.MediaItem( itemId = jellyfinItemId, 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 9643da39..1a484f90 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 @@ -161,10 +161,12 @@ class SeerrService null } - private suspend fun createImageUrl( + suspend fun createImageUrl( imageType: ImageType, path: String?, mediaInfo: MediaInfo?, + backdropHeight: Int = 1080, + backdropWidth: Int = 1920, ): String? { if (mediaInfo != null) { val itemId = @@ -193,7 +195,7 @@ class SeerrService val prefix = when (imageType) { ImageType.PRIMARY -> "/t/p/w500" - ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_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/ui/discover/DiscoverFocusedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt new file mode 100644 index 00000000..c9464528 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.discover + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +sealed interface DiscoverFocusedItem { + val id: Int + val title: StringProvider? + val subtitle: String? + val overview: String? + val backDropUrl: String? + + data class Item( + val item: DiscoverItem, + ) : DiscoverFocusedItem { + override val id: Int + get() = item.id + override val title: StringProvider? = item.title?.let { StringStringProvider(it) } + override val subtitle: String? + get() = item.subtitle + override val overview: String? + get() = item.overview + override val backDropUrl: String? + get() = item.backDropUrl + } + + data class Genre( + override val id: Int, + val name: String, + val type: SeerrItemType, + override val backDropUrl: String?, + ) : DiscoverFocusedItem { + override val title: StringProvider = discoverGenreTitle(name, type) + override val subtitle: String? + get() = null + override val overview: String? + get() = null + } +} + +fun discoverGenreTitle( + name: String, + type: SeerrItemType, +): ResArgStringProvider = + when (type) { + SeerrItemType.MOVIE -> ResArgStringProvider(R.string.genre_movies, name) + SeerrItemType.TV -> ResArgStringProvider(R.string.genre_tv_shows, name) + else -> throw UnsupportedOperationException("$type not supported") + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt new file mode 100644 index 00000000..c61c0799 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.GenreCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverGenreRow( + title: StringProvider, + items: DataLoadingState>, + onClickItem: (Int, Genre) -> Unit, + onCardFocus: (Int) -> Unit, + modifier: Modifier = Modifier, + onLongClickItem: (Int, Genre) -> Unit = { _, _ -> }, +) { + when (items) { + is DataLoadingState.Error -> { + ErrorMessage(items.message, items.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = title.getString(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + ItemRow( + title = title.getString(), + items = items.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index, item, mod, onClick, onLongClick -> + GenreCard( + genre = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = + mod + .height(Cards.heightEpisode) + .onFocusChanged { + if (it.isFocused) onCardFocus.invoke(index) + }, + ) + }, + modifier = modifier, + horizontalPadding = 16.dp, + showViewMore = false, + viewMoreCardContent = {}, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index aa463ad6..08a9efe1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -9,12 +9,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService @@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler +import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.DiscoverRequestPager import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler @@ -52,13 +53,13 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, - @Assisted val type: DiscoverRequestType, + @Assisted val type: DiscoverPagerType, @Assisted startIndex: Int, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - type: DiscoverRequestType, + type: DiscoverPagerType, startIndex: Int, ): DiscoverRequestViewModel } @@ -71,53 +72,83 @@ class DiscoverRequestViewModel try { val pager = when (type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) + is DiscoverPagerType.RequestType -> { + when (type.type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + } } - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } + is DiscoverPagerType.Genre -> { + when (type.type) { + SeerrItemType.MOVIE -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(genre = type.genreId), + seerrService::createDiscoverItem, + viewModelScope, + ) + } - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } + SeerrItemType.TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(genre = type.genreId), + seerrService::createDiscoverItem, + viewModelScope, + ) + } - DiscoverRequestType.UPCOMING_TV -> { - DiscoverRequestPager( - api, - UpcomingTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_MOVIES -> { - DiscoverRequestPager( - api, - UpcomingMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UNKNOWN -> { - throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + else -> { + throw UnsupportedOperationException("Type not supported: ${type.type}") + } + } } }.init(startIndex) _state.update { @@ -168,7 +199,7 @@ fun DiscoverRequestGrid( Column( modifier = modifier, ) { - GridTitle(stringResource(destination.type.stringRes)) + GridTitle(destination.type.title.getString()) CardGrid( initialPosition = destination.startIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index ccdda6d8..17c52c37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -23,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -39,6 +39,7 @@ 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.SeerrService +import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString @@ -47,16 +48,21 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -126,6 +132,83 @@ class SeerrDiscoverViewModel ), ) } + viewModelScope.launchIO { + val movieGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderMovieGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s movie genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting movie genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + movieGenres = movieGenres, + ) + } + } + + viewModelScope.launchIO { + val tvGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderTvGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s tv genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + tvGenres = tvGenres, + ) + } + } } private fun fetchAndUpdateState( @@ -149,11 +232,13 @@ class SeerrDiscoverViewModel } } - fun updateBackdrop(item: DiscoverItem?) { + fun updateBackdrop(item: DiscoverFocusedItem?) { viewModelScope.launchIO { if (item != null) { backdropService.submit("discover_${item.id}", item.backDropUrl) - fetchRating(item) + if (item is DiscoverFocusedItem.Item) { + fetchRating(item.id, item.item.type) + } } else { backdropService.clearBackdrop() } @@ -167,25 +252,28 @@ class SeerrDiscoverViewModel .build() // TODO this is not very efficient - fun fetchRating(item: DiscoverItem) { + fun fetchRating( + id: Int, + type: SeerrItemType, + ) { viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(item.id) + val cachedResult = ratingCache.getIfPresent(id) if (cachedResult != null) { return@launchIO } val result = try { - when (item.type) { + when (type) { SeerrItemType.MOVIE -> { DiscoverRating( seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = item.id, + movieId = id, ), ) } SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id)) } SeerrItemType.PERSON -> { @@ -198,17 +286,17 @@ class SeerrDiscoverViewModel } } catch (ex: ClientException) { if (ex.statusCode == 404) { - Timber.w("No rating found for %s", item.id) + Timber.w("No rating found for %s", id) DiscoverRating(null, null) } else { - Timber.e(ex, "Error getting rating for %s", item.id) + Timber.e(ex, "Error getting rating for %s", id) return@launchIO } } catch (ex: Exception) { - Timber.e(ex, "Error getting rating for %s", item.id) + Timber.e(ex, "Error getting rating for %s", id) return@launchIO } - ratingCache.put(item.id, result) + ratingCache.put(id, result) rating.update { ratingCache.asMap().toMap() } @@ -216,6 +304,10 @@ class SeerrDiscoverViewModel } } +fun Int.toUUID() = UUID(0L, toLong()) + +fun UUID.toInt() = leastSignificantBits.toInt() + data class DiscoverRowData( val title: StringProvider, val items: DataLoadingState>, @@ -237,8 +329,20 @@ data class DiscoverState( val trending: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, + val movieGenres: DataLoadingState> = DataLoadingState.Pending, + val tvGenres: DataLoadingState> = DataLoadingState.Pending, ) +private const val ROW_TRENDING = 0 +private const val ROW_MOVIES = ROW_TRENDING + 1 +private const val ROW_GENRES_MOVIE = ROW_MOVIES + 1 +private const val ROW_UPCOMING_MOVIES = ROW_GENRES_MOVIE + 1 +private const val ROW_TV = ROW_UPCOMING_MOVIES + 1 +private const val ROW_GENRES_TV = ROW_TV + 1 +private const val ROW_UPCOMING_TV = ROW_GENRES_TV + 1 + +private const val LAST_ROW = ROW_UPCOMING_TV + @OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( @@ -248,16 +352,62 @@ fun SeerrDiscoverPage( viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val rows = - listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember { List(LAST_ROW + 1) { FocusRequester() } } var position by rememberPosition(0, -1) - val focusedItem = + val focusedItem: DiscoverFocusedItem? = remember(position) { position.let { - (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + val discoverRow = + when (position.row) { + ROW_TRENDING -> state.trending + ROW_MOVIES -> state.movies + ROW_UPCOMING_MOVIES -> state.upcomingMovies + ROW_TV -> state.tv + ROW_UPCOMING_TV -> state.upcomingTv + else -> null + } + if (discoverRow != null) { + (discoverRow.items as? DataLoadingState.Success) + ?.data + ?.getOrNull(it.column) + ?.let { + DiscoverFocusedItem.Item(it) + } + } else { + when (position.row) { + ROW_GENRES_MOVIE -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.MOVIE, + backDropUrl = it.imageUrl, + ) + } + } + + ROW_GENRES_TV -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.TV, + backDropUrl = it.imageUrl, + ) + } + } + + else -> { + null + } + } + } } } LaunchedEffect(focusedItem) { @@ -277,25 +427,27 @@ fun SeerrDiscoverPage( ) { val details = remember(focusedItem, ratingMap) { - buildList { - focusedItem - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - }.let { - val rating = focusedItem?.id?.let { ratingMap[it] } - val str = - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) - QuickDetailsData(str) + (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> + buildList { + focusedItem.item + .releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem.id.let { ratingMap[it] } + val str = + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + QuickDetailsData(str) + } } } HomePageHeader( - title = focusedItem?.title, + title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = true, @@ -309,6 +461,7 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) + val density = LocalDensity.current val spaceAbovePx = with(density) { @@ -316,6 +469,41 @@ fun SeerrDiscoverPage( 50.dp.toPx() } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + + @Composable + fun discoverItemRow( + rowIndex: Int, + row: DiscoverRowData, + ) { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> + position = RowColumn(rowIndex, index) + positionCallback.invoke(position) + }, + focusRequester = focusRequesters[rowIndex], + enableViewMore = row.type != DiscoverRequestType.UNKNOWN, + onClickViewMore = { + (row.items as? DataLoadingState.Success>)?.data?.size?.let { + position = RowColumn(rowIndex, it) + positionCallback.invoke(position) + } + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult(DiscoverPagerType.RequestType(row.type)), + ) + }, + modifier = + Modifier + .fillMaxWidth(), + ) + } + } CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { @@ -327,36 +515,81 @@ fun SeerrDiscoverPage( .focusRestorer() .fillMaxSize(), ) { - itemsIndexed(rows) { rowIndex, row -> + item { discoverItemRow(ROW_TRENDING, state.trending) } + + // Movies + item { discoverItemRow(ROW_MOVIES, state.movies) } + item { CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> - position = RowColumn(rowIndex, index) - positionCallback.invoke(position) - }, - focusRequester = focusRequesters[rowIndex], - enableViewMore = row.type != DiscoverRequestType.UNKNOWN, - onClickViewMore = { - (row.items as? DataLoadingState.Success>)?.data?.size?.let { - position = RowColumn(rowIndex, it) - positionCallback.invoke(position) - } + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.movies_title), + ) + }, + items = state.movieGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(row.type), + Destination.DiscoverMoreResult( + type = + DiscoverPagerType.Genre( + genreId = genre.id.toInt(), + name = genre.name, + type = SeerrItemType.MOVIE, + ), + startIndex = 0, + ), ) }, - modifier = - Modifier - .fillMaxWidth(), + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_MOVIE, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_MOVIE]), ) } } + item { discoverItemRow(ROW_UPCOMING_MOVIES, state.upcomingMovies) } + + // TV + item { discoverItemRow(ROW_TV, state.tv) } + item { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.tv_shows_title), + ) + }, + items = state.tvGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_TV, index) + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = + DiscoverPagerType.Genre( + genreId = genre.id.toInt(), + name = genre.name, + type = SeerrItemType.TV, + ), + startIndex = 0, + ), + ) + }, + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_TV, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_TV]), + ) + } + } + item { discoverItemRow(ROW_UPCOMING_TV, state.upcomingTv) } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 206ce221..390c4eb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -14,7 +14,7 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.util.StringProvider -import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import kotlinx.serialization.Contextual @@ -146,8 +146,9 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + @Serializable data class DiscoverMoreResult( - val type: DiscoverRequestType, + val type: DiscoverPagerType, val startIndex: Int = SEERR_PAGE_SIZE, ) : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt index cee890ce..c4e7ae44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -5,9 +5,14 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.discover.discoverGenreTitle +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable /** * A [RequestPager] for Seerr server queries @@ -45,6 +50,27 @@ enum class DiscoverRequestType( UNKNOWN(R.string.unknown), } +@Serializable +sealed interface DiscoverPagerType { + val title: StringProvider + + @Serializable + data class RequestType( + val type: DiscoverRequestType, + ) : DiscoverPagerType { + override val title: StringProvider = ResStringProvider(type.stringRes) + } + + @Serializable + data class Genre( + val genreId: Int, + val name: String, + val type: SeerrItemType, + ) : DiscoverPagerType { + override val title: StringProvider = discoverGenreTitle(name, type) + } +} + /** * Specifies how a [RequestPager] should prepare and execute API calls */ @@ -55,27 +81,37 @@ interface DiscoverRequestHandler { ): QueryResult } -val DiscoverTvRequestHandler = - object : DiscoverRequestHandler { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult = - api.api.searchApi.discoverTvGet(page = pageNumber).let { +class DiscoverTvRequestHandler( + private val genre: Int? = null, +) : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi + .discoverTvGet( + page = pageNumber, + genre = genre?.toString(), + ).let { QueryResult(it.results.orEmpty(), it.totalResults ?: 0) } - } +} -val DiscoverMovieRequestHandler = - object : DiscoverRequestHandler { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult = - api.api.searchApi.discoverMoviesGet(page = pageNumber).let { +class DiscoverMovieRequestHandler( + private val genre: Int? = null, +) : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi + .discoverMoviesGet( + page = pageNumber, + genre = genre?.toString(), + ).let { QueryResult(it.results.orEmpty(), it.totalResults ?: 0) } - } +} val TrendingRequestHandler = object : DiscoverRequestHandler { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index facdec73..0a97d522 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,6 +821,8 @@ Tomatoes Sort Split into separate rows + %1$s Movies + %1$s TV Shows Quality profile Default Root folder From 7422f15bffa11ce432834785f466855138d72df8 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Jun 2026 11:11:39 -0400 Subject: [PATCH 096/475] Fix possible crash if remembered tab no longer exists (#1613) ## Description If remember tabs is enabled and the current remembered index is somehow removed, the app would crash. I think the only way a regular user can encounter this is if the server removes a live TV recording folder that the user has selected/remembered. All other tabbed pages use a fixed list. ### Related issues None ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/TabbedPage.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt index 7d89e666..28e163a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -37,10 +37,14 @@ class TabViewModel private val rememberedTabService: RememberedTabService, private val backdropService: BackdropService, @param:Assisted private val itemId: String, + @param:Assisted private val tabCount: Int, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(itemId: String): TabViewModel + fun create( + itemId: String, + tabCount: Int, + ): TabViewModel } private val _state = MutableStateFlow(UNSET) @@ -52,7 +56,9 @@ class TabViewModel if (shouldRememberTabs()) { Timber.v("Getting remembered tab for %s", itemId) try { - rememberedTabService.getRememberedTab(itemId) + rememberedTabService + .getRememberedTab(itemId) + ?.coerceIn(0..( - key = itemId, - creationCallback = { it.create(itemId) }, + key = "$itemId-${tabs.size}", + creationCallback = { it.create(itemId, tabs.size) }, ), tabContent: @Composable (Int, TabDetails) -> Unit, ) { val selectedTabIndex by viewModel.state.collectAsState() + Column( modifier = modifier, ) { From 899e3decc2b8dcee6a058a02713596abe723db9e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Jun 2026 17:23:37 -0400 Subject: [PATCH 097/475] Prefer non-forced when subtitle preference is always on (#1618) ## Description If subtitles preferences are "Always", this PR will prefer non-forced tracks even if any forced ones are default tracks. This is in-line with the web UI from my testing and another review of the code. ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/790#issuecomment-4708705576 ### Testing Unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/StreamChoiceService.kt | 10 ++-- .../services/StreamChoiceServiceTest.kt | 53 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 3a3a3e9c..c0a9465e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -277,11 +277,13 @@ class StreamChoiceService SubtitlePlaybackMode.ALWAYS -> { if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { - it.language.equals(subtitleLanguage, true) || - it.language.isUnknown + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) } } else { - candidates.firstOrNull() + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } } @@ -371,3 +373,5 @@ private val String?.isUnknown: Boolean this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) + +private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index e16bc4e5..61f0f136 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -102,6 +102,56 @@ class StreamChoiceServiceTest( ), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), ) } } @@ -277,6 +327,7 @@ class TestStreamChoiceServiceSmart( TestInput( 0, SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", subtitles = listOf( subtitle(0, "eng", false), @@ -488,7 +539,7 @@ class TestStreamChoiceServiceMultipleChoices( fun data(): Collection = listOf( TestInput( - 0, + 1, SubtitlePlaybackMode.ALWAYS, subtitles = listOf( From 552c3b550088e0c3905b19e9028109dd15ab7954 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Jun 2026 17:23:47 -0400 Subject: [PATCH 098/475] Fix inconsistent playback end behavior issues (#1616) ## Description The exact behavior of what happens when playback ends based on the user's settings would change between versions. This PR defines the behaviors better and includes unit tests to verify future changes won't break it again. ### Related issues Fixes #1548 Related to https://github.com/damontecres/Wholphin/pull/1257 ### Testing Emulator & unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackViewModel.kt | 28 +++-- .../ui/playback/PlaybackViewModelTests.kt | 105 +++++++++++++++++- 2 files changed, 124 insertions(+), 9 deletions(-) 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 6a531119..a6ae07d5 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 @@ -1034,14 +1034,26 @@ class PlaybackViewModel } is PlaylistItem.Media -> { - if (currentItem is PlaylistItem.Intro) { - Timber.v("Current item is intro, so playing next up immediately") - playNextUp() - } else if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { - Timber.v("Setting next up to ${nextItem.id}") - _state.update { it.copy(nextUp = nextItem.item) } - } else { - controllerViewState.showControls() + val prefs = preferences.appPreferences.playbackPreferences + when { + currentItem is PlaylistItem.Intro -> { + Timber.v("Current item is intro, so playing next up immediately") + playNextUp() + } + + prefs.showNextUpWhen == ShowNextUpWhen.NEXT_UP_NEVER && !prefs.autoPlayNext -> { + Timber.v("Never show or auto play next up, returning") + navigationManager.goBack() + } + + prefs.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER -> { + Timber.v("Setting next up to ${nextItem.id}") + _state.update { it.copy(nextUp = nextItem.item) } + } + + else -> { + controllerViewState.showControls() + } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index ad4d7a43..d2d824db 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.ShowNextUpWhen @@ -456,11 +457,12 @@ class PlaybackViewModelTests { } @Test - fun `Show next up never`() = + fun `Show next up never with automatic play`() = runTest(testDispatcher) { setupForPlaylist() setupPreferences { showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = true } val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) @@ -476,7 +478,108 @@ class PlaybackViewModelTests { viewModel.state.value.also { state -> Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) } + verify(exactly = 0) { mockNavigationManager.goBack() } + } + + @Test + fun `Show next up never without automatic play`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Playback ends with no next up`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + viewModel.playNextUp() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertFalse(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Intro playback ends always play next`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + setupForPlaylist() + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals( + listOf(PlaylistItem.Intro(BaseItem(intro))) + playlistItems.items, + state.playlist.items, + ) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + verify(exactly = 0) { mockNavigationManager.goBack() } + + val mediaItems = mutableListOf() + verify(exactly = 2) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any()) + } + Assert.assertEquals(2, mediaItems.size) + Assert.assertEquals(intro.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie.id.toString(), mediaItems[1].mediaId) } @Test From d705a3ea01991fd8b66f4a2f33a612d8018bc4e9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Jun 2026 17:24:37 -0400 Subject: [PATCH 099/475] Add filtering & sorting for discover grid pages (#1614) ## Description This PR adds sort & filter options for the discover grid pages. Sort options: - Popularity - Release date - "Community rating" (TMDB user vote) - Name Filter options: - Genre - Studio/Network - "Community rating" (TMDB user vote) - Parental rating - Production status (TV only) Also, two more tabs are added to the discover page for browsing movies and TV. These are essentially the same as clicking "View more" on the respective rows on the Discover tab. ### Limitations This PR hardcodes the region used for studio listing and filtering to "United States". I'll work on a follow up PR to enable users to specify this. The sort & filters are not persisted. I think they should be on the two new tabs, but not elsewhere, but this will be a follow up PR. ### Related issues Related to #702 Implements some of #1511 Rewrites the filter logic in #1610 ### Testing Emulator, but needs further testing ## Screenshots discover_filters Large ## AI or LLM usage None --- .../wholphin/data/filter/DiscoverFilter.kt | 286 +++++++++++++++ .../wholphin/data/filter/ItemFilterBy.kt | 21 +- .../wholphin/data/filter/SearchFilter.kt | 27 ++ .../wholphin/services/FilterOptionCache.kt | 95 ++++- .../wholphin/ui/components/FilterByButton.kt | 291 ++++++++++++++++ .../wholphin/ui/components/SortByButton.kt | 93 +++++ .../ui/discover/DiscoverGridHeader.kt | 104 ++++++ .../wholphin/ui/discover/DiscoverPage.kt | 40 +++ .../ui/discover/DiscoverRequestGrid.kt | 329 ++++++++++++------ .../wholphin/ui/discover/SeerrDiscoverPage.kt | 34 +- .../wholphin/ui/nav/Destination.kt | 7 +- .../wholphin/ui/nav/DestinationContent.kt | 2 + .../wholphin/util/DiscoverRequestPager.kt | 25 +- app/src/main/res/values/strings.xml | 2 + app/src/main/seerr/seerr-api.yml | 1 + 15 files changed, 1200 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt new file mode 100644 index 00000000..08863fff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -0,0 +1,286 @@ +package com.github.damontecres.wholphin.data.filter + +import androidx.annotation.StringRes +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.SearchApi +import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet +import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response +import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.ui.data.flip +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.SortOrder +import java.time.format.DateTimeFormatter +import kotlin.time.Duration + +private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d") + +enum class DiscoverSort( + val key: String, + @StringRes val stringRes: Int, +) { + POPULARITY("popularity", R.string.popularity), + RELEASE_DATE("release_date", R.string.sort_by_date_released), + TMDB_VOTE("vote_average", R.string.community_rating), + ALPHABETICAL("original_title", R.string.sort_by_name), +} + +private val SortOrder.key get() = + when (this) { + SortOrder.ASCENDING -> "asc" + SortOrder.DESCENDING -> "desc" + } + +@Serializable +data class DiscoverSortAndDirection( + val sort: DiscoverSort = DiscoverSort.POPULARITY, + val direction: SortOrder = SortOrder.DESCENDING, +) { + val key = "${sort.key}.${direction.key}" + + fun flip() = copy(direction = direction.flip()) +} + +@Serializable +data class DiscoverFilter( + // /api/v1/languages + val language: String? = null, + val genreIds: List? = null, + val networkIds: List? = null, + // /api/v1/search/keyword + val keywordIds: List? = null, + val excludeKeywordIds: List? = null, + val sortBy: DiscoverSortAndDirection? = null, +// val firstAirDateGte: LocalDate? = null, +// val firstAirDateLte: LocalDate? = null, + val withRuntimeGte: Duration? = null, + val withRuntimeLte: Duration? = null, + val voteAverageGte: Int? = null, + val voteAverageLte: Int? = null, + val voteCountGte: Int? = null, + val voteCountLte: Int? = null, + // /api/v1/watchproviders/regions + val watchRegion: String? = null, // US + // networkIds? + // val watchProviders: String? = null, + val status: List? = null, + val certification: List? = null, +// val certificationGte: String? = null, +// val certificationLte: String? = null, +// val certificationCountry: String? = null, // US +// val certificationMatchExact: Boolean? = null, +) { + /** + * Returns how many of filters are actually being used in this + */ + fun countFilters(filterOptions: List>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List>): DiscoverFilter { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter) + } + return newFilter + } + + suspend fun discoverTv( + searchApi: SearchApi, + page: Int, + ): DiscoverTvGet200Response = + searchApi.discoverTvGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, + network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key, +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, + status = status?.joinToString("|") { it.ordinal.toString() }, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT }, + ) + + suspend fun discoverMovies( + searchApi: SearchApi, + page: Int, + ): DiscoverMoviesGet200Response = + searchApi.discoverMoviesGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, +// network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key, +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, +// status = status, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT }, + ) +} + +val discoverMovieFilters = + listOf( + DiscoverMovieGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverRatingFilter, + DiscoverMovieContentRatingFilter, + ) + +val discoverTvFilters = + listOf( + DiscoverTvGenreFilter, + DiscoverTvStudiosFilter, + DiscoverRatingFilter, + DiscoverTvContentRatingFilter, + DiscoverTvStatusFilter, + ) + +/** + * A way to filter discover + * + * Gets and sets values within a [DiscoverFilter] + */ +sealed interface DiscoverFilterBy : FilterBy + +data object DiscoverMovieGenreFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.genreIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverTvGenreFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.genreIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverMovieStudiosFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.networkIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStudiosFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.networkIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStatusFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.production_status + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.status + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(status = value) +} + +data object DiscoverMovieContentRatingFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.certification + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverTvContentRatingFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.certification + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverRatingFilter : DiscoverFilterBy { + override val stringRes: Int = R.string.community_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte + + override fun set( + value: Int?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(voteAverageGte = value) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 37fd9514..b92d251f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions = ) /** - * A way to filter libraries - * - * Gets and sets values within a [GetItemsFilter] + * Abstract way to filter data */ -sealed interface ItemFilterBy { +sealed interface FilterBy { @get:StringRes val stringRes: Int val supportMultiple: Boolean - fun get(filter: GetItemsFilter): T? + fun get(filter: FilterType): DataType? fun set( - value: T?, - filter: GetItemsFilter, - ): GetItemsFilter + value: DataType?, + filter: FilterType, + ): FilterType } +/** + * A way to filter jellyfin libraries + * + * Gets and sets values within a [GetItemsFilter] + */ +sealed interface ItemFilterBy : FilterBy + data object GenreFilter : ItemFilterBy> { override val stringRes: Int = R.string.genres diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt new file mode 100644 index 00000000..6b30e9ff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.data.filter + +import com.github.damontecres.wholphin.data.model.GetItemsFilter + +interface SearchFilter> { + /** + * Returns how many of filters are actually being used in this [GetItemsFilter] + */ + fun countFilters(filterOptions: List>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this as T) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List>): T { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter as T) + } + return newFilter as T + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt index 710fbeba..dc055bd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -1,14 +1,24 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterBy import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterVideoType import com.github.damontecres.wholphin.data.filter.GenreFilter -import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter import com.github.damontecres.wholphin.data.filter.StudioFilter @@ -20,6 +30,7 @@ import com.mayakapps.kache.InMemoryKache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.filterApi import org.jellyfin.sdk.api.client.extensions.genresApi @@ -47,6 +58,7 @@ class FilterOptionCache @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, private val api: ApiClient, private val serverRepository: ServerRepository, + private val seerrService: SeerrService, ) { private val cache = InMemoryKache>(16) { @@ -61,7 +73,7 @@ class FilterOptionCache */ suspend fun getFilterOptionValues( parentId: UUID?, - filterOption: ItemFilterBy<*>, + filterOption: FilterBy<*, *>, ): List { val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) return try { @@ -81,7 +93,7 @@ class FilterOptionCache private suspend fun getFilterOptionValues( userId: UUID?, parentId: UUID?, - filterOption: ItemFilterBy<*>, + filterOption: FilterBy<*, *>, ) = when (filterOption) { GenreFilter -> { api.genresApi @@ -173,11 +185,86 @@ class FilterOptionCache FilterValueOption("$it", it) } } + + is DiscoverFilterBy -> { + getDiscoverFilterOptionValues(filterOption) + } + } + + private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List { + if (seerrService.active.firstOrNull() != true) { + return emptyList() + } + return when (filterOption) { + DiscoverMovieGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderMovieGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderTvGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverMovieStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersMoviesGet("US") + .filter { it.name != null && it.id != null } + // TODO not what this is for +// .sortedBy { it.displayPriority } + .map { + // TODO logo? + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersTvGet("US") + .filter { it.name != null && it.id != null } +// .sortedBy { it.displayPriority } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStatusFilter -> { + TvDetails.Status.entries.map { + FilterValueOption(it.value, it) + } + } + + DiscoverMovieContentRatingFilter -> { + listOf("NR", "G", "PG", "PG-13", "R", "NC-17") + .map { FilterValueOption(it, it) } + } + + DiscoverTvContentRatingFilter -> { + listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA") + .map { FilterValueOption(it, it) } + } + + DiscoverRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } } private data class FilterOptionCacheKey( val userId: UUID?, val parentId: UUID?, - val filterOption: ItemFilterBy<*>, + val filterOption: FilterBy<*, *>, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index 378c8807..9789d48f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -37,8 +37,19 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterVideoType @@ -427,6 +438,286 @@ fun ExpandableFilterButton( } } +// TODO this all duplicated code from FilterByButton +@Composable +fun DiscoverFilterByButton( + filterOptions: List>, + current: DiscoverFilter, + onFilterChange: (DiscoverFilter) -> Unit, + getPossibleValues: suspend (DiscoverFilterBy<*>) -> List, + modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, +) { + var dropDown by remember { mutableStateOf(false) } + var nestedDropDown by remember { mutableStateOf?>(null) } + val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) } + + Box(modifier = modifier) { + ExpandableFilterButton( + filterCount = filterCount, + onClick = { + onShow?.invoke(true) + dropDown = true + }, + modifier = Modifier, + ) + + DropdownMenu( + expanded = dropDown, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + onDismissRequest = { + onShow?.invoke(false) + dropDown = false + nestedDropDown = null + }, + ) { + if (filterCount > 0) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(current.delete(filterOptions)) + dropDown = false + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + filterOptions + .forEachIndexed { index, filterOption -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + val currentValue = remember(current) { filterOption.get(current) } + TvDropdownMenuItem( + leadingIcon = { + if (currentValue != null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + Text( + text = stringResource(filterOption.stringRes), + ) + }, + onClick = { + nestedDropDown = filterOption + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + DropdownMenu( + expanded = nestedDropDown != null, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), + offset = DpOffset(80.dp, 16.dp), + onDismissRequest = { + nestedDropDown = null + }, + ) { + nestedDropDown?.let { filterOption -> + filterOption as DiscoverFilterBy + val currentValue = remember(current) { filterOption.get(current) } + + var possibleValues by remember { mutableStateOf?>(null) } + LaunchedEffect(Unit) { + possibleValues = getPossibleValues.invoke(filterOption) + } + + if (currentValue != null) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 5.dp, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(filterOption.set(null, current)) + nestedDropDown = null + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + + if (possibleValues == null) { + CircularProgress(Modifier.size(48.dp)) + } else if (possibleValues?.isEmpty() == true) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + possibleValues.orEmpty().forEachIndexed { index, value -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + + val isSelected = + remember(currentValue) { + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + (currentValue as? List) + .orEmpty() + .contains(value.value) + } + + DiscoverTvStatusFilter -> { + (currentValue as? List) + .orEmpty() + .contains(value.value) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + (currentValue as? List) + .orEmpty() + .contains(value.value) + } + + DiscoverRatingFilter -> { + (currentValue as? Int) == value.value + } + } + } + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 8.dp, + interactionSource = interactionSource, + leadingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + if (filterOption == DiscoverRatingFilter) { + SimpleStarRating( + "${value.name}+", + starColor = if (focused) EmptyStarColor else FilledStarColor, + ) + } else { + Text( + text = value.name, + ) + } + }, + onClick = { + val newFilter = + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + val list = (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as Int) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverTvStatusFilter -> { + val list = + (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as TvDetails.Status) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + val list = (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as String) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverRatingFilter -> { + filterOption.set( + value.value as? Int, + current, + ) + } + } + + onFilterChange.invoke(newFilter) + if (!filterOption.supportMultiple) { + nestedDropDown = null + } + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } + } + } +} + @PreviewTvSpec @Composable private fun ExpandableFilterButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt index 53511a3a..3e2555db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.flip @@ -115,3 +117,94 @@ fun SortAndDirection.toAnnotatedString() = append(" ") append(stringResource(getStringRes(sort))) } + +// TODO code duplicated from SortByButton +@Composable +fun DiscoverSortByButton( + sortOptions: List, + current: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + modifier: Modifier = Modifier, +) { + val currentSort = current.sort + val currentDirection = current.direction + var sortByDropDown by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + TextButton( + onClick = { sortByDropDown = true }, + onLongClick = { + onSortChange.invoke(current.flip()) + }, + ) { + Text( + text = current.toAnnotatedString(), + ) + } + + DropdownMenu( + expanded = sortByDropDown, + onDismissRequest = { sortByDropDown = false }, + ) { + sortOptions +// .sortedBy { it.name } + .forEach { sortOption -> + TvDropdownMenuItem( + leadingIcon = { + if (sortOption == currentSort) { + if (currentDirection == SortOrder.ASCENDING) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } else { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + } + }, + text = { + Text( + text = stringResource(sortOption.stringRes), + ) + }, + onClick = { + sortByDropDown = false + val newDirection = + if (currentSort == sortOption) { + currentDirection.flip() + } else { + currentDirection + } + onSortChange.invoke( + DiscoverSortAndDirection( + sortOption, + newDirection, + ), + ) + }, + ) + } + } + } +} + +@Composable +fun DiscoverSortAndDirection.toAnnotatedString() = + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontAwesome)) { + append( + stringResource( + if (direction == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ), + ) + } + append(" ") + append(stringResource(sort.stringRes)) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt new file mode 100644 index 00000000..2064faa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.runtime.Composable +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.focus.focusRestorer +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.ui.components.DiscoverFilterByButton +import com.github.damontecres.wholphin.ui.components.DiscoverSortByButton +import com.github.damontecres.wholphin.ui.components.GridTitle + +@Composable +fun DiscoverGridHeader( + showHeader: Boolean, + showTitle: Boolean, + title: String, + sortAndDirection: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + sortOptions: List, + getPossibleFilterValues: suspend (DiscoverFilterBy<*>) -> List, + onClickShowViewOptions: () -> Unit, + modifier: Modifier = Modifier, + currentFilter: DiscoverFilter = DiscoverFilter(), + filterOptions: List> = listOf(), + onFilterChange: (DiscoverFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, +) { + AnimatedVisibility( + showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + GridTitle(title) + } + val endPadding = + 16.dp // + if (sortAndDirection.sort == DiscoverSort.ALPHABETICAL) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .focusRestorer() + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.focusRestorer(), + ) { + if (sortOptions.isNotEmpty()) { + DiscoverSortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + DiscoverFilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(filterButtonFocusRequester), + onShow = onShowFilterDropdown, + ) + } +// ExpandableFaButton( +// title = R.string.view_options, +// iconStringRes = R.string.fa_sliders, +// onClick = onClickShowViewOptions, +// modifier = Modifier, +// ) + } + } + } + } + } +} 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 3ef3424a..6eff501e 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 @@ -14,7 +14,9 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabbedPage +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.util.DiscoverRequestType @Composable fun DiscoverPage( @@ -27,6 +29,8 @@ fun DiscoverPage( TabDetails(R.string.discover), TabDetails(R.string.request), TabDetails(R.string.search), + TabDetails(R.string.movies_title), + TabDetails(R.string.tv_shows_title), ) } var showHeader by rememberSaveable { mutableStateOf(true) } @@ -74,6 +78,42 @@ fun DiscoverPage( ) } + // Movies + 3 -> { + DiscoverRequestGrid( + viewModelKey = "movies", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_MOVIES, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // TV + 4 -> { + DiscoverRequestGrid( + viewModelKey = "tv", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_TV, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + else -> { ErrorMessage("Invalid tab index $tabIndex", null) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index 08a9efe1..57a43ba4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -1,26 +1,39 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth 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.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.discoverMovieFilters +import com.github.damontecres.wholphin.data.filter.discoverTvFilters import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.launchDefault @@ -28,7 +41,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler -import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.DiscoverRequestPager import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler @@ -40,6 +52,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -53,14 +67,17 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, - @Assisted val type: DiscoverPagerType, + private val filterOptionCache: FilterOptionCache, + @Assisted val type: DiscoverRequestType, @Assisted startIndex: Int, + @Assisted initialFilter: DiscoverFilter, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - type: DiscoverPagerType, + type: DiscoverRequestType, startIndex: Int, + initialFilter: DiscoverFilter, ): DiscoverRequestViewModel } @@ -69,140 +86,216 @@ class DiscoverRequestViewModel init { viewModelScope.launchDefault { - try { - val pager = - when (type) { - is DiscoverPagerType.RequestType -> { - when (type.type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler(), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler(), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_TV -> { - DiscoverRequestPager( - api, - UpcomingTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_MOVIES -> { - DiscoverRequestPager( - api, - UpcomingMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UNKNOWN -> { - throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") - } - } - } - - is DiscoverPagerType.Genre -> { - when (type.type) { - SeerrItemType.MOVIE -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler(genre = type.genreId), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - SeerrItemType.TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler(genre = type.genreId), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - else -> { - throw UnsupportedOperationException("Type not supported: ${type.type}") - } - } - } - }.init(startIndex) - _state.update { - it.copy( - loading = DataLoadingState.Success(pager), - ) + val (sortOptions, potentialFilterOptions) = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSort.entries.toList() to discoverTvFilters + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSort.entries.toList() to discoverMovieFilters + DiscoverRequestType.TRENDING -> emptyList() to emptyList() + DiscoverRequestType.UPCOMING_TV -> emptyList() to emptyList() + DiscoverRequestType.UPCOMING_MOVIES -> emptyList() to emptyList() + DiscoverRequestType.UNKNOWN -> emptyList() to emptyList() } - } catch (ex: Exception) { - Timber.e(ex, "Error initializing %s", type) - _state.update { - it.copy( - loading = DataLoadingState.Error(ex), - ) + val sortAndDirection = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSortAndDirection() + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.TRENDING -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_TV -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.UNKNOWN -> DiscoverSortAndDirection() } + val filterOptions = + if (potentialFilterOptions.isNotEmpty()) { + // If the initial filter includes filtering (eg genres), then don't allow for changing it + potentialFilterOptions.filter { it.get(initialFilter) == null } + } else { + potentialFilterOptions + } + _state.update { + it.copy( + startIndex = startIndex, + sortOptions = sortOptions, + filterOptions = filterOptions, + sortAndDirection = sortAndDirection, + ) + } + fetchData(sortAndDirection, initialFilter, startIndex) + } + } + + private var fetchJob: Job? = null + + private suspend fun fetchData( + sortAndDirection: DiscoverSortAndDirection, + filter: DiscoverFilter, + startIndex: Int = 0, + ) { + fetchJob?.cancel() + try { + _state.update { + it.copy( + filter = filter, + sortAndDirection = sortAndDirection, + loading = DataLoadingState.Loading, + ) + } + val filter = filter.copy(sortBy = sortAndDirection) + fetchJob = + viewModelScope.launchDefault { + val pager = + when (type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + }.init(startIndex) + _state.update { + it.copy( + loading = DataLoadingState.Success(pager), + ) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error initializing %s", type) + _state.update { + it.copy( + loading = DataLoadingState.Error(ex), + ) } } } + + fun onSortChange(newSort: DiscoverSortAndDirection) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(newSort, state.value.filter) + } + } + + fun onFilterChange(newFilter: DiscoverFilter) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(state.value.sortAndDirection, newFilter) + } + } + + suspend fun getPossibleFilterValues(filter: DiscoverFilterBy<*>): List = + filterOptionCache.getFilterOptionValues(null, filter) } data class DiscoverRequestState( val loading: DataLoadingState> = DataLoadingState.Pending, + val startIndex: Int = 0, + val sortOptions: List = emptyList(), + val filterOptions: List> = emptyList(), + val filter: DiscoverFilter = DiscoverFilter(), + val sortAndDirection: DiscoverSortAndDirection = DiscoverSortAndDirection(), ) @Composable fun DiscoverRequestGrid( destination: Destination.DiscoverMoreResult, + showTitle: Boolean, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, + viewModelKey: String? = null, viewModel: DiscoverRequestViewModel = hiltViewModel( - creationCallback = { it.create(destination.type, destination.startIndex) }, + key = viewModelKey, + creationCallback = { + it.create( + destination.type, + destination.startIndex, + destination.initialFilter, + ) + }, ), ) { val state by viewModel.state.collectAsState() - when (val s = state.loading) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier) - } - is DataLoadingState.Error -> { - ErrorMessage(s, modifier) - } + var showHeader by remember { mutableStateOf(state.startIndex < 6) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + DiscoverGridHeader( + showHeader = showHeader, + showTitle = showTitle, + title = + destination.titleOverride?.getString() + ?: stringResource(destination.type.stringRes), + sortAndDirection = state.sortAndDirection, + onSortChange = viewModel::onSortChange, + sortOptions = state.sortOptions, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + onClickShowViewOptions = {}, + currentFilter = state.filter, + filterOptions = state.filterOptions, + onFilterChange = viewModel::onFilterChange, + modifier = Modifier.fillMaxWidth(), + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier) + } - is DataLoadingState.Success> -> { - val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - GridTitle(destination.type.title.getString()) + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier) + } + is DataLoadingState.Success> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( - initialPosition = destination.startIndex, + initialPosition = state.startIndex, pager = s.data, onClickItem = { index, item -> viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) @@ -222,6 +315,12 @@ fun DiscoverRequestGrid( width = Dp.Unspecified, ) }, + columns = 6, + positionCallback = { columns, index -> + showHeader = index < columns + positionCallback.invoke(columns, index) + }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 17c52c37..360bdfb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.QuickDetailsData @@ -53,7 +54,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState -import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder @@ -495,7 +495,9 @@ fun SeerrDiscoverPage( positionCallback.invoke(position) } viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(DiscoverPagerType.RequestType(row.type)), + Destination.DiscoverMoreResult( + type = row.type, + ), ) }, modifier = @@ -534,11 +536,15 @@ fun SeerrDiscoverPage( position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( Destination.DiscoverMoreResult( - type = - DiscoverPagerType.Genre( - genreId = genre.id.toInt(), - name = genre.name, - type = SeerrItemType.MOVIE, + type = DiscoverRequestType.DISCOVER_MOVIES, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.MOVIE, ), startIndex = 0, ), @@ -571,11 +577,15 @@ fun SeerrDiscoverPage( position = RowColumn(ROW_GENRES_TV, index) viewModel.navigationManager.navigateTo( Destination.DiscoverMoreResult( - type = - DiscoverPagerType.Genre( - genreId = genre.id.toInt(), - name = genre.name, - type = SeerrItemType.TV, + type = DiscoverRequestType.DISCOVER_TV, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.TV, ), startIndex = 0, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 390c4eb3..96863e05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.navigation3.runtime.NavKey +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.DiscoverItem @@ -14,7 +15,7 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.util.StringProvider -import com.github.damontecres.wholphin.util.DiscoverPagerType +import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import kotlinx.serialization.Contextual @@ -148,8 +149,10 @@ sealed class Destination( @Serializable data class DiscoverMoreResult( - val type: DiscoverPagerType, + val type: DiscoverRequestType, val startIndex: Int = SEERR_PAGE_SIZE, + val initialFilter: DiscoverFilter = DiscoverFilter(), + val titleOverride: StringProvider? = null, ) : Destination(false) @Serializable 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 db723522..12f4f4d9 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 @@ -405,6 +405,8 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverRequestGrid( destination = destination, + showTitle = true, + positionCallback = { _, _ -> }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt index c4e7ae44..ceb4f160 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi @@ -82,35 +83,27 @@ interface DiscoverRequestHandler { } class DiscoverTvRequestHandler( - private val genre: Int? = null, + val filter: DiscoverFilter = DiscoverFilter(), ) : DiscoverRequestHandler { override suspend fun execute( api: SeerrApi, pageNumber: Int, ): QueryResult = - api.api.searchApi - .discoverTvGet( - page = pageNumber, - genre = genre?.toString(), - ).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } + filter.discoverTv(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } } class DiscoverMovieRequestHandler( - private val genre: Int? = null, + val filter: DiscoverFilter = DiscoverFilter(), ) : DiscoverRequestHandler { override suspend fun execute( api: SeerrApi, pageNumber: Int, ): QueryResult = - api.api.searchApi - .discoverMoviesGet( - page = pageNumber, - genre = genre?.toString(), - ).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } + filter.discoverMovies(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } } val TrendingRequestHandler = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a97d522..3dbbe6de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -826,4 +826,6 @@ Quality profile Default Root folder + Popularity + Production status diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 254bdb09..7c128bbd 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1199,6 +1199,7 @@ components: $ref: '#/components/schemas/Season' status: type: string + enum: ["Returning Series", "Planned", "In Production", "Ended", "Cancelled", "Pilot"] tagline: type: string type: From 8a684545ac6ca2f16d0089c2a7ac91d80458d752 Mon Sep 17 00:00:00 2001 From: Christian Tismer-Sperling Date: Wed, 24 Jun 2026 23:02:53 +0200 Subject: [PATCH 100/475] Respect system 24-hour setting in clock time format (#1619) ## Description The clock ignored the device's system 24-hour preference: getTimeFormatter derived 12/24h purely from the locale. This reads DateFormat.is24HourFormat and builds the time pattern from it, so the clock follows the system setting. ### Related issues ### Testing Tested on the emulator toggling the system 24-hour setting on/off. assembleDefaultDebug, testDefaultDebugUnitTest and pre-commit (ktlint) pass. ## Screenshots ## AI or LLM usage The analysis and the fix were done by me with AI assistance (Claude). I wrote the description myself; the English wording was polished with AI. I understand the code and can maintain it. --- .../damontecres/wholphin/ui/Formatting.kt | 32 +++++++++++++++---- .../wholphin/ui/components/QuickDetails.kt | 24 +++++--------- .../wholphin/ui/detail/PlaylistDetails.kt | 7 ++-- .../ui/playback/overlay/PlaybackController.kt | 8 +++-- .../wholphin/ui/util/LocalClock.kt | 12 ++++--- 5 files changed, 49 insertions(+), 34 deletions(-) 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 9ae6cd50..2ea1bf87 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 @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui +import android.content.Context +import android.text.format.DateFormat import androidx.annotation.StringRes import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString @@ -12,19 +14,35 @@ import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.format.FormatStyle +import java.util.Date import java.util.Locale -private var timeFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) +/** + * Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current + * locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an + * in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application + * context may carry a stale locale. + */ +fun formatTime( + context: Context, + time: LocalDateTime, +): String = formatTime(context, time.toLocalTime()) -fun getTimeFormatter(): DateTimeFormatter { - if (timeFormatter.locale != Locale.getDefault()) { - timeFormatter = timeFormatter.withLocale(Locale.getDefault()) - } - return timeFormatter +fun formatTime( + context: Context, + time: LocalTime, +): String { + val instant = + time + .atDate(LocalDate.now()) + .atZone(ZoneId.systemDefault()) + .toInstant() + return DateFormat.getTimeFormat(context).format(Date.from(instant)) } private var dateFormatter: DateTimeFormatter = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index e9f34133..6db89c75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString @@ -25,7 +26,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.preferences.DisplayToggle import com.github.damontecres.wholphin.ui.dot -import com.github.damontecres.wholphin.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import org.jellyfin.sdk.model.DateTime @@ -138,21 +139,11 @@ fun TimeRemaining( modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, ) { - val resources = LocalResources.current val now by LocalClock.current.now - val remainingStr = - remember(remaining, now, resources) { - val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) - buildAnnotatedString { - dot() - append(resources.getString(R.string.ends_at, endTimeStr)) - } - } - Text( - text = remainingStr, - style = textStyle, - maxLines = 1, + EndsAt( + endsAt = now.plusSeconds(remaining.inWholeSeconds), modifier = modifier, + textStyle = textStyle, ) } @@ -163,9 +154,10 @@ fun EndsAt( textStyle: TextStyle = MaterialTheme.typography.titleSmall, ) { val resources = LocalResources.current + val context = LocalContext.current val remainingStr = - remember(endsAt, resources) { - val endTimeStr = getTimeFormatter().format(endsAt) + remember(endsAt, resources, context) { + val endTimeStr = formatTime(context, endsAt) buildAnnotatedString { dot() append(resources.getString(R.string.ends_at, endTimeStr)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 070268cb..ea1640ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -94,7 +94,7 @@ import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -803,10 +803,11 @@ fun PlaylistItem( trailingContent = { item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> val now by LocalClock.current.now + val context = LocalContext.current val endTimeStr = - remember(item, now) { + remember(item, now, context) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - getTimeFormatter().format(endTime) + formatTime(context, endTime) } Column { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt index 528b6546..86cd81b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -32,7 +33,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType import kotlinx.coroutines.delay @@ -199,8 +200,9 @@ fun Controller( ) } + val context = LocalContext.current var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(player) { + LaunchedEffect(player, context) { while (isActive) { val remaining = (player.duration - player.currentPosition) @@ -208,7 +210,7 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = getTimeFormatter().format(endTime) + endTimeStr = formatTime(context, endTime) delay(1.seconds) } } 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 8664d604..2639a919 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 @@ -7,7 +7,8 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.ui.getTimeFormatter +import androidx.compose.ui.platform.LocalContext +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -25,19 +26,20 @@ data class Clock( */ val now: MutableState = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [getTimeFormatter] + * The current time formatted as a string; populated by [ProvideLocalClock] via [formatTime]. */ - val timeString: MutableState = mutableStateOf(getTimeFormatter().format(now.value)), + val timeString: MutableState = mutableStateOf(""), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { + val context = LocalContext.current val clock = remember { Clock() } - LaunchedEffect(Unit) { + LaunchedEffect(context) { withContext(WholphinDispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = getTimeFormatter().format(now) + val time = formatTime(context, now) clock.now.value = now clock.timeString.value = time delay(2_000) From e5887dfa15108ec533fccae40af987fd555077b5 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 24 Jun 2026 19:26:05 -0400 Subject: [PATCH 101/475] Better UX for separate SDR vs HDR subtitle styles (#1622) ## Description This PR moves the separated HDR subtitle style behind a toggle. This means users must now opt-in to using separated SDR vs HDR subtitle styles. This is a better UX for users who want to customize the style but use the same styles for both. Users who have changed the HDR subtitles previously will have the toggle enabled automatically. If the user hasn't changed HDR style or their SDR & HDR styles are the the same, the toggle will be disabled. Also, fixes a bug where when playback switched between SDR or HDR, the subtitle styles might not be updated. ### Related issues Closes #1603 ### Testing Emulator & unit tests for migration ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/AppUpgradeHandler.kt | 9 + .../wholphin/ui/playback/PlaybackPage.kt | 19 +- .../subtitle/SubtitlePreferencesContent.kt | 204 +++++++++--------- .../preferences/subtitle/SubtitleSettings.kt | 49 ++++- .../preferences/subtitle/SubtitleStylePage.kt | 16 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestSubtitleHdrMigration.kt | 54 +++++ 8 files changed, 239 insertions(+), 114 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt 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 018b50ba..cc814210 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 @@ -29,6 +29,7 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences 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.preferences.subtitle.shouldEnableSeparateHdrToggle import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext @@ -409,5 +410,13 @@ class AppUpgradeHandler Timber.e(ex, "Error saving migrated tabs") } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle() + } + } + } } } 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 6fe79290..e72dcc8a 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 @@ -4,7 +4,6 @@ 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 import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing @@ -84,8 +83,8 @@ import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyTo import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler @@ -421,10 +420,13 @@ fun PlaybackPageContent( analyticsState = state.analyticsState, ) + var subtitleStyleAppliedForHdr by remember { mutableStateOf(state.currentMediaInfo.videoStream?.hdr == true) } val subtitleSettings = remember(state.currentMediaInfo) { Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}") - if (state.currentMediaInfo.videoStream?.hdr == true) { + val useSeparate = + preferences.appPreferences.interfacePreferences.subtitlesPreferences.useSeparateHdr + if (useSeparate && state.currentMediaInfo.videoStream?.hdr == true) { preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.appPreferences.interfacePreferences.subtitlesPreferences @@ -447,11 +449,7 @@ fun PlaybackPageContent( AndroidView( factory = { context -> SubtitleView(context).apply { - subtitleSettings.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } + subtitleSettings.applyTo(this) playerInstance.assHandler?.let { assHandler -> if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { Timber.v("Adding AssSubtitleView") @@ -470,6 +468,11 @@ fun PlaybackPageContent( } }, update = { subtitleView -> + if (subtitleStyleAppliedForHdr != state.currentMediaInfo.videoStream?.hdr) { + // If the media has switched between SDR & HDR, reapply subtitle settings + Timber.v("Switching SDR/HDR, reapplying subtitle style") + subtitleSettings.applyTo(subtitleView) + } subtitleView.setCues(state.subtitleCues) if (state.subtitleCues.size > cueCount) { // The output creates a painter for each cue, so need to apply the changes when the number of cues increases diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt index 8abc6dbe..02f70d11 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -10,11 +10,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -79,112 +82,119 @@ fun SubtitlePreferencesContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - LazyColumn( - state = state, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(16.dp), + Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), ) { - stickyHeader { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - prefList.forEachIndexed { groupIndex, group -> - item { - Text( - text = stringResource(group.title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - ) - } - val groupPreferences = - group.preferences + - group.conditionalPreferences - .filter { it.condition.invoke(preferences) } - .map { it.preferences } - .flatten() - groupPreferences.forEachIndexed { prefIndex, pref -> - pref as AppPreference + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + ) { + prefList.forEachIndexed { groupIndex, group -> item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - onFocus.invoke(groupIndex, prefIndex) + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } } - } - when (pref) { - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - scope.launch(ExceptionHandler()) { - val newValue = - SubtitlePreferences - .newBuilder() - .apply { resetSubtitles() } - .build() - onPreferenceChange.invoke(newValue) - } - }, - interactionSource = interactionSource, - ) + if (preferences.useSeparateHdr && pref == SubtitleSettings.SeparateHdr) { + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + } } - - else -> { - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = viewModel.navigationManager::navigateTo, - onValueChange = { newValue -> - val validation = pref.validate(newValue) - when (validation) { - is PreferenceValidation.Invalid -> { - // TODO? - Toast - .makeText( - context, - validation.message, - Toast.LENGTH_SHORT, - ).show() + when (pref) { + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + scope.launch(ExceptionHandler()) { + val newValue = + SubtitlePreferences + .newBuilder() + .apply { resetSubtitles() } + .build() + onPreferenceChange.invoke(newValue) } + }, + interactionSource = interactionSource, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - onPreferenceChange.invoke( - pref.setter( - preferences, - newValue, - ), - ) + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + onPreferenceChange.invoke( + pref.setter( + preferences, + newValue, + ), + ) + } } } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).bringIntoViewRequester(bringIntoViewRequester), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index b7f3d764..0fa5469f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import android.graphics.Typeface import android.os.Build import android.view.Display +import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -12,6 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference @@ -20,9 +22,12 @@ import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.EdgeStyle +import com.github.damontecres.wholphin.preferences.InterfacePreferences import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor @@ -245,6 +250,16 @@ object SubtitleSettings { setter = { prefs, _ -> prefs }, ) + val SeparateHdr = + AppSwitchPreference( + title = R.string.different_for_hdr, + defaultValue = false, + getter = { it.useSeparateHdr }, + setter = { prefs, value -> + prefs.update { useSeparateHdr = value } + }, + ) + val HdrSettings = AppDestinationPreference( title = R.string.hdr_subtitle_style, @@ -299,7 +314,14 @@ object SubtitleSettings { title = R.string.hdr, preferences = listOf( - HdrSettings, + SeparateHdr, + ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.useSeparateHdr }, + preferences = listOf(HdrSettings), + ), ), ), ) @@ -333,6 +355,13 @@ object SubtitleSettings { ) } + @OptIn(UnstableApi::class) + fun SubtitlePreferences.applyTo(view: SubtitleView) { + view.setStyle(toSubtitleStyle()) + view.setFixedTextSize(Dimension.SP, fontSize.toFloat()) + view.setBottomPaddingFraction(margin.toFloat() / 100f) + } + fun SubtitlePreferences.calculateEdgeSize(density: Density): Float = with(density) { (edgeThickness / 2f).dp.toPx() } fun SubtitlePreferences.applyToMpv( @@ -400,3 +429,21 @@ object SubtitleSettings { } inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() + +fun InterfacePreferences.shouldEnableSeparateHdrToggle(): Boolean { + // If already separate, leave it + if (subtitlesPreferences.useSeparateHdr) return true + val defaultStyle = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + // If SDR and HDR are the same, don't separate + return if (subtitlesPreferences == hdrSubtitlesPreferences) { + false + } else { + // If different, but HDR has not been changed from original settings, don't separate + hdrSubtitlesPreferences != defaultStyle + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt index 52b3570a..a96ab8fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -51,7 +51,7 @@ import timber.log.Timber @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, - hdrSettings: Boolean, + isPageForHdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { @@ -67,7 +67,7 @@ fun SubtitleStylePage( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { DisposableEffect(context) { - if (hdrSettings) { + if (isPageForHdrSettings) { Timber.v("Switching color mode to HDR") context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR } @@ -77,7 +77,7 @@ fun SubtitleStylePage( } } val prefs = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -192,8 +192,8 @@ fun SubtitleStylePage( } val display = LocalView.current.display val prefList = - remember(hdrSettings, display) { - if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + remember(isPageForHdrSettings, display) { + if (!isPageForHdrSettings && SubtitleSettings.shouldShowHdr(display)) { // If not on HDR page and display is HDR capable, then show the HDR button SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup } else { @@ -202,13 +202,13 @@ fun SubtitleStylePage( } SubtitlePreferencesContent( title = - if (hdrSettings) { + if (isPageForHdrSettings) { stringResource(R.string.hdr_subtitle_style) } else { stringResource(R.string.subtitle_style) }, preferences = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -217,7 +217,7 @@ fun SubtitleStylePage( onPreferenceChange = { newSubtitlePrefs -> viewModel.preferenceDataStore.updateData { it.updateInterfacePreferences { - if (hdrSettings) { + if (isPageForHdrSettings) { hdrSubtitlesPreferences = newSubtitlePrefs } else { subtitlesPreferences = newSubtitlePrefs diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 12631e17..d2443c60 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -141,6 +141,7 @@ message SubtitlePreferences{ int32 margin = 11; int32 edge_thickness = 12; int32 image_subtitle_opacity = 13; + bool use_separate_hdr = 14; } message LiveTvPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3dbbe6de..dd999158 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -828,4 +828,5 @@ Root folder Popularity Production status + Use different style for HDR diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt new file mode 100644 index 00000000..fec67c6b --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.InterfacePreferences +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle +import org.junit.Assert +import org.junit.Test + +class TestSubtitleHdrMigration { + private fun subtitles(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + block.invoke(this) + }.build() + + private fun shouldSeparate( + sdr: SubtitlePreferences, + hdr: SubtitlePreferences, + ): Boolean = + InterfacePreferences + .newBuilder() + .apply { + subtitlesPreferences = sdr + hdrSubtitlesPreferences = hdr + }.build() + .shouldEnableSeparateHdrToggle() + + @Test + fun `Test separate enabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 30 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertTrue(shouldSeparate) + } + + @Test + fun `Test separate disabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 60 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } + + @Test + fun `Test HDR unchanged`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } +} From f5ce9745ba1209fb3c6e9f4613be012cf6009751 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 26 Jun 2026 12:43:56 -0400 Subject: [PATCH 102/475] Several UI/UX minor changes (#1623) ## Description Gathering some smaller UI/UX changes: - Default to the user's preferred subtitle language when searching/downloading subtitles and if unset, fall back to the UI's language - Only show >0s runtime on quick details & don't show negative ends at - On the OS channel row for recently added, only include movies & episodes - Only show movies & episodes to "Recently Added" Android OS channel - Fix context menu for adding songs & albums to the music queue ### Related issues Related to #1339 & #1484 Fixes #1617 Fixes https://github.com/damontecres/Wholphin/issues/1329#issuecomment-4440684328 ### Testing Just on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/model/BaseItem.kt | 1 + .../damontecres/wholphin/services/MusicService.kt | 2 ++ .../services/tvprovider/TvProviderWorker.kt | 1 + .../github/damontecres/wholphin/ui/Extensions.kt | 2 +- .../wholphin/ui/components/ContextMenu.kt | 5 +++-- .../wholphin/ui/components/ContextMenuUtils.kt | 1 + .../wholphin/ui/data/AddPlaylistViewModel.kt | 13 +++++++++++++ .../wholphin/ui/detail/music/MusicViewModel.kt | 8 ++++++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../wholphin/ui/playback/SubtitleSearchUtils.kt | 11 ++++++++++- 10 files changed, 41 insertions(+), 5 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 5ca32695..a5526f87 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 @@ -160,6 +160,7 @@ data class BaseItem( data.runTimeTicks ?.ticks ?.roundMinutes + ?.takeIf { it > Duration.ZERO } ?.let { add(it.toString()) } data.timeRemaining ?.roundMinutes diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index de99d579..c1aeb280 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -228,6 +228,8 @@ class MusicService start() } } + } else { + Timber.w("Tried to add non-audio type %s: %s", item.type, item.id) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 4a0e5dde..0df2193b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -264,6 +264,7 @@ class TvProviderWorker .getLatestMedia( GetLatestMediaRequest( fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), imageTypeLimit = 1, parentId = null, groupItems = true, 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 37353a8f..c3037d75 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 @@ -244,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration? get() = userData?.playbackPositionTicks?.let { if (it > 0) { - runTimeTicks?.minus(it)?.ticks + runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO } } else { null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index 3a592bbc..cf7b32d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -339,7 +339,8 @@ private fun buildContextMenuItems( onClickPlayWith: () -> Unit, ): List = buildList { - if (showGoTo) { + // Songs should not show Go to + if (showGoTo && item.type != BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.go_to), @@ -466,7 +467,7 @@ private fun buildContextMenuItems( } } } - if (item.type == BaseItemKind.MUSIC_ALBUM) { + if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.add_to_queue), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt index 0e930b65..80e989ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -91,6 +91,7 @@ class ContextMenuUtils( onClearChosenStreams = { // Not supported on this page }, + onClickAddToQueue = playlistViewModel::addToQueue, ) } val contextMenu = 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 c2b8c94a..a41487c7 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,14 +5,19 @@ import android.widget.Toast 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.services.MusicService import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +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 import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -27,7 +32,9 @@ class AddPlaylistViewModel @Inject constructor( @param:ApplicationContext private val context: Context, + private val api: ApiClient, private val playlistCreator: PlaylistCreator, + private val musicService: MusicService, ) : ViewModel() { val playlistState = MutableStateFlow(PlaylistLoadingState.Pending) @@ -72,4 +79,10 @@ class AddPlaylistViewModel } } } + + fun addToQueue(item: BaseItem) { + viewModelScope.launchDefault { + addToQueue(api, musicService, item, -1) + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt index 812927f1..87062cf2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -147,3 +147,11 @@ fun ViewModel.addToQueue( } } } + +suspend fun addToQueue( + api: ApiClient, + musicService: MusicService, + item: BaseItem, + index: Int, +) { +} 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 a6ae07d5..e9592ebb 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 @@ -139,7 +139,7 @@ class PlaybackViewModel val navigationManager: NavigationManager, private val playlistCreator: PlaylistCreator, private val itemPlaybackDao: ItemPlaybackDao, - private val serverRepository: ServerRepository, + internal val serverRepository: ServerRepository, private val itemPlaybackRepository: ItemPlaybackRepository, private val playerFactory: PlayerFactory, private val datePlayedService: DatePlayedService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 95d52a1a..d733e176 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.showToast @@ -41,7 +42,15 @@ sealed interface SubtitleSearchStatus { /** * Trigger a search for subtitles in the given language for the currently playing media */ -fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { +fun PlaybackViewModel.searchForSubtitles( + language: String = + serverRepository + .currentUserDto + ?.configuration + ?.subtitleLanguagePreference + ?.takeIf { it.isNotNullOrBlank() } + ?: Locale.current.language, +) { subtitleSearchState.update { it.copy( status = SubtitleSearchStatus.Searching, From a8ade9171e7f74b65e678fb1efff226e0122d472 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 26 Jun 2026 12:50:45 -0400 Subject: [PATCH 103/475] Update wholphin-extensions with newer MpvPlayer (#1365) ## Description Uses the newer version of `wholphin-extensions` which now has the Kotlin code, so this PR removes the MPV Kotlin code from this repo. Also updates gradle to use providers. There are no user facing changes ### Dev notes `wholphin-mpv` can be provided by: 1. [Fetching pre-built binaries](https://github.com/damontecres/wholphin-extensions#pre-built-binaries) (Preferred method for devs) 2. Building `wholphin-extensions` from [source](https://github.com/damontecres/wholphin-extensions#build-from-source) 3. Using `wholphin-mpv-stub` (the new module added in this PR) which allows for compiling Wholphin, but will throw exceptions if MPV is used during runtime ### Related issues Related to #1172 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .github/workflows/pr.yml | 3 + DEVELOPMENT.md | 2 + app/build.gradle.kts | 40 +- .../wholphin/services/PlayerFactory.kt | 2 +- .../wholphin/ui/playback/PlaybackPage.kt | 2 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../preferences/subtitle/SubtitleSettings.kt | 2 +- .../damontecres/wholphin/util/mpv/MPVLib.kt | 311 ---- .../wholphin/util/mpv/MPVProperty.kt | 46 - .../wholphin/util/mpv/MpvLogger.kt | 21 - .../wholphin/util/mpv/MpvPlayer.kt | 1254 ----------------- .../damontecres/wholphin/util/mpv/Utils.kt | 11 + build.gradle.kts | 1 + gradle/libs.versions.toml | 11 +- settings.gradle.kts | 1 + wholphin-mpv-stub/.gitignore | 1 + wholphin-mpv-stub/README.md | 5 + wholphin-mpv-stub/build.gradle.kts | 38 + wholphin-mpv-stub/consumer-rules.pro | 0 wholphin-mpv-stub/proguard-rules.pro | 21 + .../src/main/AndroidManifest.xml | 4 + .../github/damontecres/wholphin/mpv/MPVLib.kt | 26 + .../damontecres/wholphin/mpv/MpvPlayer.kt | 22 + .../wholphin/mpv/MpvStubException.kt | 3 + 24 files changed, 177 insertions(+), 1652 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt create mode 100644 wholphin-mpv-stub/.gitignore create mode 100644 wholphin-mpv-stub/README.md create mode 100644 wholphin-mpv-stub/build.gradle.kts create mode 100644 wholphin-mpv-stub/consumer-rules.pro create mode 100644 wholphin-mpv-stub/proguard-rules.pro create mode 100644 wholphin-mpv-stub/src/main/AndroidManifest.xml create mode 100644 wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt create mode 100644 wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt create mode 100644 wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7ebdcb17..2d681fce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,3 +42,6 @@ jobs: ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" + env: + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a2338108..bdc7dfa5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -42,6 +42,8 @@ If you want to include these in a local build, see the [instructions here](https You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry. +Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception. + ## Code organization Code is split into several packages: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7230f0c..e61a1fa8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,12 +20,21 @@ plugins { alias(libs.plugins.openapi.generator) } -val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false -val shouldSign = isCI && System.getenv("KEY_ALIAS") != null -val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() -val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() -val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists() -val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername") +val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() } +val shouldSign = + isCI.zip( + providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() }, + ) { isCI, hasKey -> + isCI && hasKey + } +val ffmpegModuleExists = + providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() } +val av1ModuleExists = + providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() } +val mpvModuleExists = + providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() } +val extensionsRepoActive = + providers.provider { project.hasProperty("WholphinExtensionsUsername") } // See https://issuetracker.google.com/issues/402800800 val isBuildingBundle = @@ -69,7 +78,7 @@ configure { } signingConfigs { - if (shouldSign) { + if (shouldSign.get()) { create("ci") { file("ci.keystore").writeBytes( Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), @@ -95,7 +104,7 @@ configure { "proguard-rules.pro", ) isDebuggable = false - if (shouldSign) { + if (shouldSign.get()) { signingConfig = signingConfigs.getByName("ci") } else { val localPropertiesFile = project.rootProject.file("local.properties") @@ -357,32 +366,33 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.desugar.jdk.libs) - if (ffmpegModuleExists) { + if (ffmpegModuleExists.get()) { logger.info("Using local ffmpeg decoder") implementation(files("libs/lib-decoder-ffmpeg-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt ffmpeg decoder") implementation(libs.wholphin.extensions.ffmpeg) } else { logger.warn("Media3 ffmpeg decoder was NOT found") } - if (av1ModuleExists) { + if (av1ModuleExists.get()) { logger.info("Using local av1 decoder") implementation(files("libs/lib-decoder-av1-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt av1 decoder") implementation(libs.wholphin.extensions.av1) } else { logger.warn("Media3 av1 decoder was NOT found") } - if (mpvModuleExists) { + if (mpvModuleExists.get()) { logger.info("Using local libMPV build") implementation(files("libs/wholphin-mpv-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt libMPV") implementation(libs.wholphin.extensions.mpv) } else { - logger.warn("libMPV was NOT found") + logger.warn("libMPV was NOT found, using stub library") + implementation(project(":wholphin-mpv-stub")) } testImplementation(libs.mockk.android) 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 0517497c..01b82777 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 @@ -25,13 +25,13 @@ import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.session.MediaSession +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode 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.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.WholphinDispatchers -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 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 e72dcc8a..6e64be5b 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 @@ -67,6 +67,7 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences @@ -90,7 +91,6 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus 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.delay import kotlinx.coroutines.launch 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 e9592ebb..b297c0c6 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 @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen @@ -69,7 +70,6 @@ import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.checkForSupport -import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index 0fa5469f..a737fe2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.mpv.MPVLib import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference import com.github.damontecres.wholphin.preferences.AppDestinationPreference @@ -29,7 +30,6 @@ import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup -import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor import timber.log.Timber diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt deleted file mode 100644 index fad62f2b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ /dev/null @@ -1,311 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -/* -This file is copied from https://github.com/mpv-android/mpv-android/blob/master/app/src/main/java/is/xyz/mpv/MPVLib.kt - -Copyright (c) 2016 Ilya Zhuravlev -Copyright (c) 2016 sfan5 - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, publish, distribute, -sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or -substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT -NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import android.content.Context -import android.graphics.Bitmap -import android.view.Surface -import java.util.concurrent.CopyOnWriteArrayList - -// Wrapper for native library - -@Suppress("unused") -object MPVLib { - init { - val libs = arrayOf("mpv", "player") - for (lib in libs) { - System.loadLibrary(lib) - } - } - - external fun create(appctx: Context) - - external fun init() - - external fun destroy() - - external fun attachSurface(surface: Surface) - - external fun detachSurface() - - external fun command(cmd: Array) - - external fun setOptionString( - name: String, - value: String, - ): Int - - external fun grabThumbnail(dimension: Int): Bitmap? - - external fun getPropertyInt(property: String): Int? - - external fun setPropertyInt( - property: String, - value: Int, - ) - - external fun getPropertyDouble(property: String): Double? - - external fun setPropertyDouble( - property: String, - value: Double, - ) - - external fun getPropertyBoolean(property: String): Boolean? - - external fun setPropertyBoolean( - property: String, - value: Boolean, - ) - - external fun getPropertyString(property: String): String? - - external fun setPropertyString( - property: String, - value: String, - ) - - external fun observeProperty( - property: String, - format: Int, - ) - - private val observers = CopyOnWriteArrayList() - - @JvmStatic - fun addObserver(o: EventObserver) { - observers.add(o) - } - - @JvmStatic - fun removeObserver(o: EventObserver) { - observers.remove(o) - } - - @JvmStatic - fun eventProperty( - property: String, - value: Long, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Boolean, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Double, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: String, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty(property: String) { - for (o in observers) { - o.eventProperty(property) - } - } - - @JvmStatic - fun event(eventId: Int) { - for (o in observers) { - o.event(eventId) - } - } - - @JvmStatic - fun eventEndFile( - reason: Int, - error: Int, - ) { - for (o in observers) { - o.eventEndFile(reason, error) - } - } - - private val log_observers = CopyOnWriteArrayList() - - @JvmStatic - fun addLogObserver(o: LogObserver) { - log_observers.add(o) - } - - @JvmStatic - fun removeLogObserver(o: LogObserver) { - log_observers.remove(o) - } - - @JvmStatic - fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - for (o in log_observers) { - o.logMessage(prefix, level, text) - } - } - - interface EventObserver { - fun eventProperty(property: String) - - fun eventProperty( - property: String, - value: Long, - ) - - fun eventProperty( - property: String, - value: Boolean, - ) - - fun eventProperty( - property: String, - value: String, - ) - - fun eventProperty( - property: String, - value: Double, - ) - - fun event(eventId: Int) - - fun eventEndFile( - reason: Int, - error: Int, - ) - } - - interface LogObserver { - fun logMessage( - prefix: String, - level: Int, - text: String, - ) - } - - object MpvFormat { - const val MPV_FORMAT_NONE: Int = 0 - const val MPV_FORMAT_STRING: Int = 1 - const val MPV_FORMAT_OSD_STRING: Int = 2 - const val MPV_FORMAT_FLAG: Int = 3 - const val MPV_FORMAT_INT64: Int = 4 - const val MPV_FORMAT_DOUBLE: Int = 5 - const val MPV_FORMAT_NODE: Int = 6 - const val MPV_FORMAT_NODE_ARRAY: Int = 7 - const val MPV_FORMAT_NODE_MAP: Int = 8 - const val MPV_FORMAT_BYTE_ARRAY: Int = 9 - } - - object MpvEvent { - const val MPV_EVENT_NONE: Int = 0 - const val MPV_EVENT_SHUTDOWN: Int = 1 - const val MPV_EVENT_LOG_MESSAGE: Int = 2 - const val MPV_EVENT_GET_PROPERTY_REPLY: Int = 3 - const val MPV_EVENT_SET_PROPERTY_REPLY: Int = 4 - const val MPV_EVENT_COMMAND_REPLY: Int = 5 - const val MPV_EVENT_START_FILE: Int = 6 - const val MPV_EVENT_END_FILE: Int = 7 - const val MPV_EVENT_FILE_LOADED: Int = 8 - - @Deprecated("") - const val MPV_EVENT_IDLE: Int = 11 - - @Deprecated("") - const val MPV_EVENT_TICK: Int = 14 - const val MPV_EVENT_CLIENT_MESSAGE: Int = 16 - const val MPV_EVENT_VIDEO_RECONFIG: Int = 17 - const val MPV_EVENT_AUDIO_RECONFIG: Int = 18 - const val MPV_EVENT_SEEK: Int = 20 - const val MPV_EVENT_PLAYBACK_RESTART: Int = 21 - const val MPV_EVENT_PROPERTY_CHANGE: Int = 22 - const val MPV_EVENT_QUEUE_OVERFLOW: Int = 24 - const val MPV_EVENT_HOOK: Int = 25 - } - - object MpvLogLevel { - const val MPV_LOG_LEVEL_NONE: Int = 0 - const val MPV_LOG_LEVEL_FATAL: Int = 10 - const val MPV_LOG_LEVEL_ERROR: Int = 20 - const val MPV_LOG_LEVEL_WARN: Int = 30 - const val MPV_LOG_LEVEL_INFO: Int = 40 - const val MPV_LOG_LEVEL_V: Int = 50 - const val MPV_LOG_LEVEL_DEBUG: Int = 60 - const val MPV_LOG_LEVEL_TRACE: Int = 70 - } - - object MpvEndFileReason { - const val MPV_END_FILE_REASON_EOF: Int = 0 - const val MPV_END_FILE_REASON_STOP: Int = 2 - const val MPV_END_FILE_REASON_QUIT: Int = 3 - const val MPV_END_FILE_REASON_ERROR: Int = 4 - const val MPV_END_FILE_REASON_REDIRECT: Int = 5 - } - - object MpvError { - const val MPV_ERROR_SUCCESS = 0 - const val MPV_ERROR_EVENT_QUEUE_FULL = -1 - const val MPV_ERROR_NOMEM = -2 - const val MPV_ERROR_UNINITIALIZED = -3 - const val MPV_ERROR_INVALID_PARAMETER = -4 - const val MPV_ERROR_OPTION_NOT_FOUND = -5 - const val MPV_ERROR_OPTION_FORMAT = -6 - const val MPV_ERROR_OPTION_ERROR = -7 - const val MPV_ERROR_PROPERTY_NOT_FOUND = -8 - const val MPV_ERROR_PROPERTY_FORMAT = -9 - const val MPV_ERROR_PROPERTY_UNAVAILABLE = -10 - const val MPV_ERROR_PROPERTY_ERROR = -11 - const val MPV_ERROR_COMMAND = -12 - const val MPV_ERROR_LOADING_FAILED = -13 - const val MPV_ERROR_AO_INIT_FAILED = -14 - const val MPV_ERROR_VO_INIT_FAILED = -15 - const val MPV_ERROR_NOTHING_TO_PLAY = -16 - const val MPV_ERROR_UNKNOWN_FORMAT = -17 - const val MPV_ERROR_UNSUPPORTED = -18 - const val MPV_ERROR_NOT_IMPLEMENTED = -19 - const val MPV_ERROR_GENERIC = -20 - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt deleted file mode 100644 index f9a0669a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_DOUBLE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_FLAG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_INT64 -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_NONE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_STRING - -object MPVProperty { - const val POSITION = "time-pos" - const val POSITION_FULL = "time-pos/full" - const val DURATION = "duration/full" - const val PAUSED = "pause" - const val PAUSED_FOR_CACHE = "paused-for-cache" - const val SPEED = "speed" - const val TRACK_LIST = "track-list" - const val ASPECT = "video-params/aspect" - const val ROTATE = "video-params/rotate" - const val TRACK_VIDEO = "current-tracks/video/image" - const val METADATA = "metadata" - const val HWDEC = "hwdec-current" - const val MUTE = "mute" - const val TRACK_AUDIO = "current-tracks/audio/selected" - const val SEEKABLE = "seekable" - const val SUBTITLE_TEXT = "sub-text" - - val observedProperties = - mapOf( - POSITION to MPV_FORMAT_INT64, -// POSITION_FULL to MPV_FORMAT_DOUBLE, - DURATION to MPV_FORMAT_DOUBLE, - PAUSED to MPV_FORMAT_FLAG, - PAUSED_FOR_CACHE to MPV_FORMAT_FLAG, - SPEED to MPV_FORMAT_STRING, - TRACK_LIST to MPV_FORMAT_NONE, - ASPECT to MPV_FORMAT_DOUBLE, - ROTATE to MPV_FORMAT_DOUBLE, - TRACK_VIDEO to MPV_FORMAT_NONE, - METADATA to MPV_FORMAT_NONE, - HWDEC to MPV_FORMAT_NONE, - MUTE to MPV_FORMAT_FLAG, - TRACK_AUDIO to MPV_FORMAT_NONE, - SEEKABLE to MPV_FORMAT_FLAG, - SUBTITLE_TEXT to MPV_FORMAT_STRING, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt deleted file mode 100644 index 300d36f0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import timber.log.Timber - -class MpvLogger : MPVLib.LogObserver { - override fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - // https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423 - when { - level <= 10 -> Timber.wtf("%s, %s", prefix, text) - level <= 20 -> Timber.e("%s, %s", prefix, text) - level <= 30 -> Timber.w("%s, %s", prefix, text) - level <= 40 -> Timber.i("%s, %s", prefix, text) - level <= 50 -> Timber.d("%s, %s", prefix, text) - else -> Timber.v("%s, %s", prefix, text) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt deleted file mode 100644 index 3057866d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ /dev/null @@ -1,1254 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.HandlerThread -import android.os.Looper -import android.os.Message -import android.os.Process -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.annotation.OptIn -import androidx.compose.ui.graphics.Color -import androidx.media3.common.AudioAttributes -import androidx.media3.common.BasePlayer -import androidx.media3.common.C -import androidx.media3.common.DeviceInfo -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.TrackGroup -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import androidx.media3.common.VideoSize -import androidx.media3.common.text.CueGroup -import androidx.media3.common.util.Clock -import androidx.media3.common.util.ListenerSet -import androidx.media3.common.util.Size -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.exoplayer.trackselection.TrackSelector -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_RECONFIG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG -import timber.log.Timber -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.atomics.update -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -/** - * This is barebones implementation of a [Player] which plays content using libmpv - * - * It doesn't support every feature or emit every event - */ -@kotlin.OptIn(ExperimentalAtomicApi::class) -@OptIn(UnstableApi::class) -class MpvPlayer( - private val context: Context, - private val enableHardwareDecoding: Boolean, - private val useGpuNext: Boolean, -) : BasePlayer(), - MPVLib.EventObserver, - TrackSelector.InvalidationListener, - Handler.Callback, - SurfaceHolder.Callback { - companion object { - private const val DEBUG = false - - private val initLock = Any() - } - - private var surface: Surface? = null - private val playbackState = AtomicReference(PlaybackState.EMPTY) - private val mpvLogger = MpvLogger() - - // This looper will sent events to the main thread - private val looper = Util.getCurrentOrMainLooper() - private val mainHandler = Handler(looper) - private val listeners = - ListenerSet( - looper, - Clock.DEFAULT, - ) { listener, eventFlags -> - listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) - } - private var availableCommands: Player.Commands = Player.Commands.Builder().build() - private val trackSelector = DefaultTrackSelector(context) - - // This thread/looper will receive commands from the main thread to execute - private val thread: HandlerThread = - HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO) - .also { it.start() } - private val internalHandler: Handler = Handler(thread.looper, this) - - @Volatile - var isReleased = false - private set - - init { - sendCommand(MpvCommand.INITIALIZE, null) - } - - private fun init() { - Timber.v("config-dir=${context.filesDir.path}") - MPVLib.addLogObserver(mpvLogger) - - Timber.v("Creating MPVLib") - MPVLib.create(context) - MPVLib.setOptionString("config", "yes") - MPVLib.setOptionString("config-dir", context.filesDir.path) - - if (enableHardwareDecoding) { - MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } else { - MPVLib.setOptionString("hwdec", "no") - } - MPVLib.setOptionString("gpu-context", "android") - - MPVLib.setOptionString("opengl-es", "yes") - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") - val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 - MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") - MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - - Timber.v("Initializing MPVLib") - MPVLib.init() - - MPVLib.setOptionString("force-window", "no") - MPVLib.setOptionString("idle", "yes") -// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - - MPVLib.addObserver(this@MpvPlayer) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) - - availableCommands = - Player.Commands - .Builder() - .addAll( - COMMAND_PLAY_PAUSE, - COMMAND_PREPARE, - COMMAND_STOP, - COMMAND_SET_SPEED_AND_PITCH, - COMMAND_SET_SHUFFLE_MODE, - COMMAND_SET_REPEAT_MODE, - COMMAND_GET_CURRENT_MEDIA_ITEM, - COMMAND_GET_TIMELINE, - COMMAND_GET_METADATA, -// COMMAND_SET_PLAYLIST_METADATA, - COMMAND_SET_MEDIA_ITEM, -// COMMAND_CHANGE_MEDIA_ITEMS, - COMMAND_GET_TRACKS, -// COMMAND_GET_AUDIO_ATTRIBUTES, -// COMMAND_SET_AUDIO_ATTRIBUTES, -// COMMAND_GET_VOLUME, -// COMMAND_SET_VOLUME, - COMMAND_SET_VIDEO_SURFACE, -// COMMAND_GET_TEXT, - COMMAND_RELEASE, - ).build() - notifyListeners(EVENT_AVAILABLE_COMMANDS_CHANGED) { - onAvailableCommandsChanged( - availableCommands, - ) - } - trackSelector.init(this, DefaultBandwidthMeter.getSingletonInstance(context)) - } - - override fun getApplicationLooper(): Looper = looper - - override fun addListener(listener: Player.Listener) { - if (DEBUG) Timber.v("addListener") - listeners.add(listener) - } - - override fun removeListener(listener: Player.Listener) { - if (DEBUG) Timber.v("removeListener") - listeners.remove(listener) - } - - override fun setMediaItems( - mediaItems: List, - resetPosition: Boolean, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - mediaItems.firstOrNull()?.let { - if (surface != null) { - sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET)) - } - } - } - - override fun setMediaItems( - mediaItems: List, - startIndex: Int, - startPositionMs: Long, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - sendCommand( - MpvCommand.LOAD_FILE, - MediaAndPosition(mediaItems[startIndex], startPositionMs), - ) - } - - override fun addMediaItems( - index: Int, - mediaItems: List, - ): Unit = throw UnsupportedOperationException() - - override fun moveMediaItems( - fromIndex: Int, - toIndex: Int, - newIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: List, - ): Unit = throw UnsupportedOperationException() - - override fun removeMediaItems( - fromIndex: Int, - toIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun getAvailableCommands(): Player.Commands = availableCommands - - override fun prepare() { - if (DEBUG) Timber.v("prepare") - } - - override fun getPlaybackState(): Int { - if (DEBUG) Timber.v("getPlaybackState") - return playbackState.load().state - } - - override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE - - override fun getPlayerError(): PlaybackException? { - // TODO - return null - } - - override fun setPlayWhenReady(playWhenReady: Boolean) { - if (isReleased) return - if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady") - sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady) - } - - override fun getPlayWhenReady(): Boolean { - if (DEBUG) Timber.v("getPlayWhenReady") - if (isReleased) return false - return !playbackState.load().isPaused - } - - override fun setRepeatMode(repeatMode: Int) { - if (DEBUG) Timber.v("setRepeatMode") - } - - override fun getRepeatMode(): Int = REPEAT_MODE_OFF - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - if (DEBUG) Timber.v("setShuffleModeEnabled") - } - - override fun getShuffleModeEnabled(): Boolean = false - - override fun isLoading(): Boolean = playbackState.load().isLoadingFile - - override fun getSeekBackIncrement(): Long = 10_000 - - override fun getSeekForwardIncrement(): Long = 30_000 - - override fun getMaxSeekToPreviousPosition(): Long = 10_000 - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { - if (DEBUG) Timber.v("setPlaybackParameters") - sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed) - } - - override fun getPlaybackParameters(): PlaybackParameters { - if (DEBUG) Timber.v("getPlaybackParameters") - return PlaybackParameters(playbackState.load().speed) - } - - override fun stop() { - if (DEBUG) Timber.v("stop") - if (isReleased) return - pause() - internalHandler.removeCallbacks(updatePlaybackState) - playbackState.update { - PlaybackState.EMPTY - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } - } - - override fun release() { - Timber.i("release") - playbackState.update { - PlaybackState.EMPTY - } - if (!isReleased) { - internalHandler.removeCallbacks(updatePlaybackState) - MPVLib.removeObserver(this@MpvPlayer) - sendCommand(MpvCommand.DESTROY, null) - thread.quitSafely() - } - isReleased = true - } - - override fun getCurrentTracks(): Tracks { - if (DEBUG) Timber.v("getCurrentTracks") - if (isReleased) return Tracks.EMPTY - return playbackState.load().tracks - } - - override fun getTrackSelectionParameters(): TrackSelectionParameters { - if (DEBUG) Timber.v("getTrackSelectionParameters") - - return TrackSelectionParameters - .Builder() - .build() - } - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { - Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters) - if (isReleased) return - val tracks = playbackState.load().tracks - if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { - // Subtitles disabled - Timber.v("TrackSelection: disabling subtitles") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no")) - } - if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { - // Audio disabled - Timber.v("TrackSelection: disabling audio") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no")) - } - Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides") - parameters.overrides.forEach { (trackGroup, trackSelectionOverride) -> - val result = - tracks.groups.firstOrNull { it.mediaTrackGroup == trackGroup }?.let { - val id = it.mediaTrackGroup.getFormat(0).id - val splits = id?.split(":") - val trackId = splits?.getOrNull(1) - val propertyName = - when (it.mediaTrackGroup.type) { - C.TRACK_TYPE_AUDIO -> "aid" - C.TRACK_TYPE_VIDEO -> "vid" - C.TRACK_TYPE_TEXT -> "sid" - else -> null - } - Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id) - if (trackId != null && propertyName != null) { - sendCommand( - MpvCommand.SET_TRACK_SELECTION, - TrackSelection(propertyName, trackId), - ) - true - } else { - false - } - } - if (result != true) { - Timber.w( - "Did not find track to select for type=%s, id=%s", - trackGroup.type, - trackGroup.getFormat(0).id, - ) - } - } - } - - override fun getMediaMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getMediaMetadata") - return MediaMetadata.EMPTY - } - - override fun getPlaylistMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getPlaylistMetadata") - return MediaMetadata.EMPTY - } - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata): Unit = throw UnsupportedOperationException() - - override fun getCurrentTimeline(): Timeline { - if (DEBUG) Timber.v("getCurrentTimeline") - return playbackState.load().timeline - } - - override fun getCurrentPeriodIndex(): Int { - if (DEBUG) Timber.v("getCurrentPeriodIndex") - // TODO - return 0 - } - - override fun getCurrentMediaItemIndex(): Int { - if (DEBUG) Timber.v("getCurrentMediaItemIndex") - return 0 - } - - override fun getDuration(): Long { - if (DEBUG) Timber.v("getDuration") - return playbackState.load().durationMs - } - - override fun getCurrentPosition(): Long { - if (DEBUG) Timber.v("getCurrentPosition") - val state = playbackState.load() - return state.positionMs - } - - override fun getBufferedPosition(): Long { - if (DEBUG) Timber.v("getBufferedPosition") - return currentPosition + totalBufferedDuration - } - - override fun getTotalBufferedDuration(): Long { - if (DEBUG) Timber.v("getTotalBufferedDuration") - if (isReleased) return 0 - return playbackState.load().bufferMs - } - - override fun isPlayingAd(): Boolean { - if (DEBUG) Timber.v("isPlayingAd") - return false - } - - override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET - - override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET - - override fun getContentPosition(): Long = currentPosition - - override fun getContentBufferedPosition(): Long = bufferedPosition - - override fun getAudioAttributes(): AudioAttributes = throw UnsupportedOperationException() - - override fun setVolume(volume: Float): Unit = throw UnsupportedOperationException() - - override fun getVolume(): Float = 1f - - override fun mute() { - volume = 0f - } - - override fun unmute() { - volume = 1f - } - - override fun clearVideoSurface(): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - private var surfaceHolder: SurfaceHolder? = null - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - if (DEBUG) Timber.v("setVideoSurfaceView") - if (surfaceView != null) { - this.surfaceHolder?.removeCallback(this) - this.surfaceHolder = surfaceView.holder - if (surfaceView.holder != null) { - val surface = surfaceView.holder?.surface - surfaceView.holder.addCallback(this) - Timber.v("Got surface holder: isValid=${surface?.isValid}") - if (surface != null && surface.isValid) { - Timber.v("Queued attach") - sendCommand(MpvCommand.ATTACH_SURFACE, surface) - return - } - } - } - clearVideoSurfaceView(null) - } - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - if (surface != null && surface == surfaceView?.holder?.surface) { - Timber.d("clearVideoSurfaceView") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } else { - Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView) - } - } - - override fun surfaceChanged( - holder: SurfaceHolder, - format: Int, - width: Int, - height: Int, - ) { - Timber.v("surfaceChanged: format=$format, width=$width, height=$height") - } - - override fun surfaceCreated(holder: SurfaceHolder) { - Timber.v("surfaceCreated") - sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Timber.v("surfaceDestroyed") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } - - override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun getVideoSize(): VideoSize { - if (DEBUG) Timber.v("getVideoSize") - if (isReleased) return VideoSize.UNKNOWN - return playbackState.load().videoSize - } - - override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN - - override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO - - override fun getDeviceInfo(): DeviceInfo { - if (DEBUG) Timber.v("getDeviceInfo") - return DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build() - } - - override fun getDeviceVolume(): Int = throw UnsupportedOperationException() - - override fun isDeviceMuted(): Boolean = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceVolume(volume: Int): Unit = throw UnsupportedOperationException() - - override fun setDeviceVolume( - volume: Int, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun increaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun increaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun decreaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun decreaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceMuted(muted: Boolean): Unit = throw UnsupportedOperationException() - - override fun setDeviceMuted( - muted: Boolean, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - override fun setAudioAttributes( - audioAttributes: AudioAttributes, - handleAudioFocus: Boolean, - ): Unit = throw UnsupportedOperationException() - - override fun seekTo( - mediaItemIndex: Int, - positionMs: Long, - seekCommand: Int, - isRepeatingCurrentItem: Boolean, - ) { - if (DEBUG) Timber.v("seekTo") - if (isReleased) { - Timber.w("seekTo called after release") - return - } - if (mediaItemIndex == C.INDEX_UNSET) { - return - } - sendCommand(MpvCommand.SEEK, positionMs) - } - - override fun onTrackSelectionsInvalidated() { - // no-op - } - - override fun eventProperty(property: String) { - if (DEBUG) Timber.v("eventProperty: $property") - } - - override fun eventProperty( - property: String, - value: Long, - ) { - if (DEBUG) Timber.v("eventPropertyLong: $property=$value") - when (property) { - MPVProperty.POSITION -> { - playbackState.update { - it.copy(positionMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun eventProperty( - property: String, - value: Boolean, - ) { - if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") - when (property) { - MPVProperty.PAUSED -> { - playbackState.update { - it.copy(isPaused = value) - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } - } - - MPVProperty.PAUSED_FOR_CACHE -> { - Timber.v("paused-for-cache %s", value) - val newState = if (value) STATE_BUFFERING else STATE_READY - playbackState.update { - it.copy(state = newState) - } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(newState) } - } - } - } - - override fun eventProperty( - property: String, - value: String, - ) { - if (DEBUG) Timber.v("eventPropertyString: $property=$value") - } - - override fun eventProperty( - property: String, - value: Double, - ) { - Timber.v("eventPropertyDouble: $property=$value") - when (property) { - MPVProperty.DURATION -> { - playbackState.update { - it.copy(durationMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun event(eventId: Int) { - Timber.v("event: eventId=%s", eventId) - when (eventId) { - MPV_EVENT_START_FILE -> { - internalHandler.post(updatePlaybackState) - } - - MPV_EVENT_FILE_LOADED -> { - Timber.d("event: MPV_EVENT_FILE_LOADED") - playbackState.update { - it.copy( - isLoadingFile = false, - ) - } - updatePlaybackState.run() - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } - - playbackState.load().media?.mediaItem?.let { media -> - media.localConfiguration?.subtitleConfigurations?.forEach { - val url = it.uri.toString() - val title = it.label ?: "External Subtitles" - Timber.v("Adding external subtitle track '$title'") - if (it.language.isNotNullOrBlank()) { - MPVLib.command(arrayOf("sub-add", url, "select", title, it.language!!)) - } else { - MPVLib.command(arrayOf("sub-add", url, "select", title)) - } - } - } - notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } - updateTracksAndNotify() - } - - MPV_EVENT_PLAYBACK_RESTART -> { - Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") - updateTracksAndNotify() - } - - MPV_EVENT_AUDIO_RECONFIG -> { - Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") - updateTracksAndNotify() - } - - MPV_EVENT_VIDEO_RECONFIG -> { - Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") - updateTracksAndNotify() - updateVideoSizeAndNotify() - } - - MPV_EVENT_END_FILE -> { - Timber.d("event: MPV_EVENT_END_FILE") - // Handled by eventEndFile - } - - else -> { - Timber.v("event: $eventId") - } - } - } - - override fun eventEndFile( - reason: Int, - error: Int, - ) { - Timber.d("MPV_EVENT_END_FILE: %s %s", reason, error) - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - when (reason) { - MPV_END_FILE_REASON_EOF -> { - playbackState.update { - it.copy(state = Player.STATE_ENDED) - } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - onPlaybackStateChanged(STATE_ENDED) - } - } - - MPV_END_FILE_REASON_STOP -> { - // User initiated (eg stop, play next, etc) - } - - MPV_END_FILE_REASON_ERROR -> { - Timber.e("libmpv error, error=%s", error) - notifyListeners(EVENT_PLAYER_ERROR) { - onPlayerError( - PlaybackException( - "libmpv error", - null, - error, - ), - ) - } - } - - else -> { - // no-op - } - } - } - - private fun updateTracksAndNotify() { - val tracks = createTracks() - playbackState.update { - it.copy(tracks = tracks) - } - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } - } - - private fun updateVideoSizeAndNotify() { - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - playbackState.update { it.copy(videoSize = videoSize) } - notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) } - } - - private fun loadFile(media: MediaAndPosition) { - Timber.v("loadFile: media=$media") - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - media.mediaItem.mediaId, - media.mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - true, - media.mediaItem.liveConfiguration, - 0L, - C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - media.mediaItem.mediaId, - media.mediaItem.mediaId, - 0, - C.TIME_UNSET, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId - } - playbackState.update { - it.copy( - isLoadingFile = true, - state = STATE_READY, - media = media, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - ) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_READY) } - val url = - media.mediaItem.localConfiguration - ?.uri - .toString() - if (media.startPositionMs > 0) { - MPVLib.command( - arrayOf( - "loadfile", - url, - "replace", - "-1", - "start=${media.startPositionMs / 1000.0}", - ), - ) - } else { - MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) - } - - if (enableHardwareDecoding) { - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } - Timber.d("Called loadfile") - } - - private fun throwIfReleased() { - if (isReleased) { - throw IllegalStateException("Cannot access MpvPlayer after it is released") - } - } - - private fun notifyListeners( - eventId: Int, - block: Player.Listener.() -> Unit, - ) { - mainHandler.post { - listeners.queueEvent(eventId) { - block.invoke(it) - } - listeners.flushEvents() - } - } - - var subtitleDelaySeconds: Double - get() { - if (isReleased) return 0.0 - return playbackState.load().subtitleDelay - } - set(value) { - if (isReleased) return - sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value) - } - - var subtitleDelay: Duration - get() { - if (isReleased) return Duration.ZERO - return subtitleDelaySeconds.seconds - } - set(value) { - if (isReleased) return - subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0 - } - - private val updatePlaybackState: Runnable = - Runnable { - val state = playbackState.load() - if (state.media == null) { - return@Runnable - } - val positionMs = - MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val bufferMs = - MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val durationMs = - MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f - val paused = MPVLib.getPropertyBoolean("pause") ?: false - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - - val mediaItem = state.media.mediaItem - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - mediaItem.mediaId, - mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - false, - mediaItem.liveConfiguration, - 0L, - if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - mediaItem.mediaId, - mediaItem.mediaId, - 0, - state.durationMs.milliseconds.inWholeMicroseconds, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId - } - - playbackState.update { - it.copy( - timestamp = System.currentTimeMillis(), - positionMs = positionMs, - bufferMs = bufferMs, - durationMs = durationMs, - speed = speed, - isPaused = paused, - videoSize = videoSize, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_SOURCE_UPDATE, - ) - } - } - - private fun sendCommand( - cmd: MpvCommand, - obj: Any?, - ) { - internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() - } - - private val queuedCommands = mutableListOf>() - - override fun handleMessage(msg: Message): Boolean { - val cmd = MpvCommand.entries[msg.what] - if (isReleased && cmd != MpvCommand.DESTROY) { - Timber.w("Player is released, ignoring command %s", cmd) - return true - } - if (surface == null && !cmd.isLifecycle) { - // If libmpv isn't ready, ueue the messages - // Note: this means nothing will play until it is attached to a surface, - // so MpvPlayer can't be used for background audio/music playback - Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd) - queuedCommands.add(Pair(cmd, msg.obj)) - } else { - handleCommand(cmd, msg.obj) - } - return true - } - - private fun handleCommand( - cmd: MpvCommand, - obj: Any?, - ) { - Timber.d("handleCommand: cmd=$cmd") - when (cmd) { - MpvCommand.PLAY_PAUSE -> { - val playWhenReady = obj as Boolean - MPVLib.setPropertyBoolean("pause", !playWhenReady) - playbackState.update { - it.copy(isPaused = !playWhenReady) - } - notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { - onPlayWhenReadyChanged( - playWhenReady, - PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - ) - } - } - - MpvCommand.SET_TRACK_SELECTION -> { - val (propertyName, trackId) = obj as TrackSelection - MPVLib.setPropertyString(propertyName, trackId) - updateTracksAndNotify() - } - - MpvCommand.SEEK -> { - val positionMs = obj as Long - MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) - playbackState.update { - it.copy(positionMs = positionMs) - } - } - - MpvCommand.SET_SPEED -> { - val value = obj as Float - MPVLib.setPropertyDouble("speed", value.toDouble()) - playbackState.update { - it.copy(speed = value) - } - } - - MpvCommand.SET_SUBTITLE_DELAY -> { - val value = obj as Double - MPVLib.setPropertyDouble("sub-delay", value) - playbackState.update { - it.copy(subtitleDelay = value) - } - } - - MpvCommand.LOAD_FILE -> { - loadFile(obj as MediaAndPosition) - } - - MpvCommand.ATTACH_SURFACE -> { - val surface = obj as Surface? - if (surface == null || (this.surface != null && this.surface != surface)) { - // If clearing or changing the surface - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") - Timber.d("Detached surface") - } - if (surface != null) { - MPVLib.attachSurface(surface) - this.surface = surface - MPVLib.setOptionString("force-window", "yes") - Timber.d("Attached surface") - if (queuedCommands.isNotEmpty()) { - Timber.d("Processing queued commands") - while (queuedCommands.isNotEmpty()) { - val msg = queuedCommands.removeAt(0) - handleCommand(msg.first, msg.second) - } - } - } - } - - MpvCommand.INITIALIZE -> { - synchronized(initLock) { - init() - } - } - - MpvCommand.DESTROY -> { - synchronized(initLock) { - MPVLib.setPropertyBoolean("pause", true) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.destroy() - Timber.d("MPVLib destroyed") - } - } - } - } -} - -fun MPVLib.setPropertyColor( - property: String, - color: Color, -) = setPropertyString(property, color.mpvFormat) - -private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" - -@OptIn(UnstableApi::class) -private fun createTracks(): Tracks { - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY - val groups = - (0.. - val type = MPVLib.getPropertyString("track-list/$idx/type") - val id = MPVLib.getPropertyInt("track-list/$idx/id") - val lang = MPVLib.getPropertyString("track-list/$idx/lang") - val codec = MPVLib.getPropertyString("track-list/$idx/codec") - val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc") - val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false - val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false - val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false - val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false - val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count") - val title = MPVLib.getPropertyString("track-list/$idx/title") - - if (type != null && id != null) { - // TODO do we need the real mimetypes? - val mimeType = - when (type) { - "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo" - "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo" - "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo" - else -> "unknown/todo" - } - var flags = 0 - if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT - if (isForced) flags = flags or C.SELECTION_FLAG_FORCED - val builder = - Format - .Builder() - .apply { - if (isExternal) { - setId("$idx:e:$id") - } else { - setId("$idx:$id") - } - }.setCodecs(codec) - .setSampleMimeType(mimeType) - .setLanguage(lang) - .setLabel(listOfNotNull(title, codecDescription).joinToString(",")) - .setSelectionFlags(flags) - if (type == "video" && isSelected) { - builder.setWidth(MPVLib.getPropertyInt("width") ?: -1) - builder.setHeight(MPVLib.getPropertyInt("height") ?: -1) - } - channelCount?.let(builder::setChannelCount) - val format = builder.build() -// Timber.v("$idx=$format") - - val trackGroup = TrackGroup(format) - val group = - Tracks.Group( - trackGroup, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(isSelected), - ) - group - } else { - null - } - } - return Tracks(groups) -} - -private data class PlaybackState( - val timestamp: Long, - val isLoadingFile: Boolean, - val media: MediaAndPosition?, - val positionMs: Long, - val bufferMs: Long, - val durationMs: Long, - val isPaused: Boolean, - val speed: Float, - val subtitleDelay: Double, - val videoSize: VideoSize, - @param:Player.State val state: Int, - val tracks: Tracks, - val timeline: Timeline, -) { - companion object { - val EMPTY = - PlaybackState( - timestamp = C.TIME_UNSET, - isLoadingFile = false, - media = null, - positionMs = C.TIME_UNSET, - durationMs = C.TIME_UNSET, - tracks = Tracks.EMPTY, - bufferMs = C.TIME_UNSET, - isPaused = false, - speed = 1f, - videoSize = VideoSize.UNKNOWN, - state = Player.STATE_IDLE, - subtitleDelay = 0.0, - timeline = Timeline.EMPTY, - ) - } -} - -private data class TrackSelection( - val property: String, - val trackId: String, -) - -private data class MediaAndPosition( - val mediaItem: MediaItem, - val startPositionMs: Long, -) - -enum class MpvCommand( - val isLifecycle: Boolean, -) { - PLAY_PAUSE(false), - SEEK(false), - SET_TRACK_SELECTION(false), - SET_SPEED(false), - SET_SUBTITLE_DELAY(false), - LOAD_FILE(false), - ATTACH_SURFACE(true), - INITIALIZE(true), - DESTROY(true), -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt new file mode 100644 index 00000000..18e67f63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util.mpv + +import androidx.compose.ui.graphics.Color +import com.github.damontecres.wholphin.mpv.MPVLib + +fun MPVLib.setPropertyColor( + property: String, + color: Color, +) = setPropertyString(property, color.mpvFormat) + +private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" diff --git a/build.gradle.kts b/build.gradle.kts index 7e765b97..4ff28386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt) apply false + alias(libs.plugins.android.library) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 030c9ee7..185d40d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,7 +45,11 @@ kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" openapi-generator = "7.23.0" runner = "1.7.0" -wholphin-extensions = "0.1.3" +wholphin-extensions = "0.2.1" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +material = "1.13.0" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } @@ -139,6 +143,10 @@ androidx-core-testing = { module = "androidx.arch.core:core-testing", version.re androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -151,3 +159,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a9195399..7fddcdcd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,4 @@ dependencyResolutionManagement { rootProject.name = "Wholphin" include(":app") +include(":wholphin-mpv-stub") diff --git a/wholphin-mpv-stub/.gitignore b/wholphin-mpv-stub/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/wholphin-mpv-stub/.gitignore @@ -0,0 +1 @@ +/build diff --git a/wholphin-mpv-stub/README.md b/wholphin-mpv-stub/README.md new file mode 100644 index 00000000..67956101 --- /dev/null +++ b/wholphin-mpv-stub/README.md @@ -0,0 +1,5 @@ +# wholphin-mpv-stub + +This is a non-functional stub implementation for the `wholphin-mpv` library in https://github.com/damontecres/wholphin-extensions. + +This provides the minimal API so that Wholphin can compile without `wholphin-mpv`, but any runtime usage will error out. diff --git a/wholphin-mpv-stub/build.gradle.kts b/wholphin-mpv-stub/build.gradle.kts new file mode 100644 index 00000000..6002fba9 --- /dev/null +++ b/wholphin-mpv-stub/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.github.damontecres.wholphin.mpv" + compileSdk { + version = release(36) + } + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.androidx.media3.exoplayer) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/wholphin-mpv-stub/consumer-rules.pro b/wholphin-mpv-stub/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/wholphin-mpv-stub/proguard-rules.pro b/wholphin-mpv-stub/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/wholphin-mpv-stub/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/wholphin-mpv-stub/src/main/AndroidManifest.xml b/wholphin-mpv-stub/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/wholphin-mpv-stub/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt new file mode 100644 index 00000000..d7c993f1 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.wholphin.mpv + +/** + * Stubs out MPVLib in wholphin-mpv + */ +object MPVLib { + fun setPropertyString( + key: String, + value: String, + ): Unit = throw MpvStubException() + + fun setPropertyInt( + key: String, + value: Int, + ): Unit = throw MpvStubException() + + fun setPropertyDouble( + key: String, + value: Double, + ): Unit = throw MpvStubException() + + fun setPropertyBoolean( + key: String, + value: Boolean, + ): Unit = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt new file mode 100644 index 00000000..fd4e0e8d --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.mpv + +import android.content.Context +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import kotlin.time.Duration + +/** + * Stubs out MpvPlayer in wholphin-mpv + */ +@OptIn(UnstableApi::class) +class MpvPlayer( + private val context: Context, + private val enableHardwareDecoding: Boolean, + private val useGpuNext: Boolean, +) : SimpleBasePlayer(Looper.getMainLooper()) { + override fun getState(): State = throw MpvStubException() + + var subtitleDelay: Duration = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt new file mode 100644 index 00000000..713d40c3 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt @@ -0,0 +1,3 @@ +package com.github.damontecres.wholphin.mpv + +class MpvStubException : Exception("This build is using wholphin-mpv-stub which does not implement actual functionality") From 21e720c420e0ec174f3eb44a5eece1cc0cefdb22 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 27 Jun 2026 14:28:57 -0400 Subject: [PATCH 104/475] Also check Seerr parent permissions (#1631) ## Description When saving Seerr permissions on the WebUI, if a "parent" permission is checked, only its bit is flipped and its children's bits are not. So clients must check if a user has the permission _or_ its parent. For example, if just "Request 4K" is checked, the flags for "Request 4K Movies" and "Request 4K TV" will not set explicitly. ### Related issues Fixes #1630 ### Testing Emulator & unit tests, also manually reviewed API requests/responses on Seerr's web UI ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/SeerrPermission.kt | 32 ++++---- .../wholphin/test/TestSeerrPermission.kt | 74 +++++++++++++++++++ 2 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index a6886cf2..8e5a5b06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig */ enum class SeerrPermission( private val flag: Int, + vararg val parents: SeerrPermission, ) { // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts NONE(0), @@ -17,28 +18,28 @@ enum class SeerrPermission( REQUEST(32), VOTE(64), AUTO_APPROVE(128), - AUTO_APPROVE_MOVIE(256), - AUTO_APPROVE_TV(512), + AUTO_APPROVE_MOVIE(256, AUTO_APPROVE), + AUTO_APPROVE_TV(512, AUTO_APPROVE), REQUEST_4K(1024), - REQUEST_4K_MOVIE(2048), - REQUEST_4K_TV(4096), + REQUEST_4K_MOVIE(2048, REQUEST_4K), + REQUEST_4K_TV(4096, REQUEST_4K), REQUEST_ADVANCED(8192), REQUEST_VIEW(16384), AUTO_APPROVE_4K(32768), - AUTO_APPROVE_4K_MOVIE(65536), - AUTO_APPROVE_4K_TV(131072), - REQUEST_MOVIE(262144), - REQUEST_TV(524288), + AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K), + AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), + REQUEST_MOVIE(262144, REQUEST), + REQUEST_TV(524288, REQUEST), MANAGE_ISSUES(1048576), - VIEW_ISSUES(2097152), - CREATE_ISSUES(4194304), + VIEW_ISSUES(2097152, MANAGE_ISSUES), + CREATE_ISSUES(4194304, MANAGE_ISSUES), AUTO_REQUEST(8388608), - AUTO_REQUEST_MOVIE(16777216), - AUTO_REQUEST_TV(33554432), + AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST), + AUTO_REQUEST_TV(33554432, AUTO_REQUEST), RECENT_VIEW(67108864), WATCHLIST_VIEW(134217728), MANAGE_BLACKLIST(268435456), - VIEW_BLACKLIST(1073741824), + VIEW_BLACKLIST(1073741824, MANAGE_BLACKLIST), ; internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag @@ -48,5 +49,8 @@ enum class SeerrPermission( * Check whether the user has the given permissions (or is an admin) */ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { - return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) + if (this?.permissions == null) return false + return permission.hasPermission(permissions) || + permission.parents.any { hasPermission(it) } || + SeerrPermission.ADMIN.hasPermission(permissions) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt new file mode 100644 index 00000000..b5dfa5c3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt @@ -0,0 +1,74 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.services.SeerrUserConfig +import org.junit.Assert +import org.junit.Test + +class TestSeerrPermission { + @Test + fun `Is Admin`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2, + ) + Assert.assertTrue(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request any 4k has request movie 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 1024, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request movie 4k and does not have request tv 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2048, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k and children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 7168, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 6144, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } +} From 083d4e4fa82d252564964566c7d4bbd732490319 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 27 Jun 2026 14:29:37 -0400 Subject: [PATCH 105/475] Add experimental settings & video tunneling (#1624) ## Description This PR introduces the concept of "experimental" settings or features. These may cause instabilities or may need further testing. They may be changed or removed any time. Also, the developers won't provide lots of support for them. The goal is expose some specialized settings that might only apply to a small subset of users or test out tweaks to playback. The first experimental feature is a toggle to enable [video tunneling](https://developer.android.com/media/media3/exoplayer/track-selection#tunneling). ### Related issues Related to #1283 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .github/ISSUE_TEMPLATE/00-bug.yml | 12 +++- .github/ISSUE_TEMPLATE/01-playback.yml | 22 +++++-- DEVELOPMENT.md | 6 ++ .../wholphin/preferences/AppPreference.kt | 8 +++ .../preferences/AppPreferencesSerializer.kt | 13 +++++ .../preferences/ExperimentalPreference.kt | 58 +++++++++++++++++++ .../wholphin/services/PlayerFactory.kt | 17 ++++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 46 +++++++++++++++ .../ui/slideshow/SlideshowViewModel.kt | 2 +- app/src/main/proto/WholphinDataStore.proto | 6 ++ app/src/main/res/values/strings.xml | 4 ++ 13 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 1aab31da..27e43e4b 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,6 @@ name: "Bug Report" description: Report a bug -title: "" +title: "" labels: [ "bug" ] @@ -23,6 +23,16 @@ body: 3. See error validations: required: false + - type: dropdown + id: experimental + attributes: + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" + validations: + required: true - type: input id: app-version attributes: diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index cbb287b5..00ee97f3 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,6 @@ name: "Playback Problem" description: Report an issue with playback -title: "<title>" +title: "" labels: [ "bug", "playback" ] @@ -12,12 +12,14 @@ body: description: Please enter a description of the playback issue validations: required: true - - type: textarea - id: mediainfo + - type: dropdown + id: experimental 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 + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" validations: required: true - type: dropdown @@ -32,6 +34,14 @@ body: - External App (please specify) validations: required: true + - type: textarea + id: mediainfo + 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: input id: app-version attributes: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bdc7dfa5..0d818e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -97,3 +97,9 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON. This should be used sparingly. + +#### Experimental + +Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time. + +These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose. 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 8149f8b6..f573c4a7 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 @@ -1266,8 +1266,16 @@ val advancedPreferences = AppPreference.DebugLogging, AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, + ExperimentalPreference.Enable, AppPreference.OssLicenseInfo, ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.experimentalPreferences.enabled }, + preferences = listOf(ExperimentalPreference.ExperimentalSettings), + ), + ), ), ) } 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 ddac936a..1e84663d 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 @@ -174,6 +174,14 @@ class AppPreferencesSerializer showLyrics = true showAlbumArt = true }.build() + + experimentalPreferences = + ExperimentalPreferences + .newBuilder() + .apply { + enabled = false + videoTunnelingEnabled = false + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -252,6 +260,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder musicPreferences = musicPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences = + update { + experimentalPreferences = experimentalPreferences.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/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt new file mode 100644 index 00000000..18e38f79 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -0,0 +1,58 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption + +object ExperimentalPreference { + val Enable = + AppSwitchPreference<AppPreferences>( + title = R.string.experimental_settings, + defaultValue = false, + getter = { it.experimentalPreferences.enabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { enabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val ExperimentalSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.experimental_settings, + destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL), + ) + + val VideoTunneling = + AppSwitchPreference<AppPreferences>( + title = R.string.video_tunneling, + defaultValue = false, + getter = { it.experimentalPreferences.videoTunnelingEnabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { videoTunnelingEnabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) +} + +val experimentalPreferences = + buildList { + add( + PreferenceGroup( + title = R.string.experimental_settings, + preferences = + listOf( + ExperimentalPreference.VideoTunneling, + ), + ), + ) + } + +/** + * Get a value from [ExperimentalPreference] or null if not enabled + */ +fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null + +fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this) 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 01b82777..24736015 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 @@ -26,10 +26,11 @@ import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode 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.preferences.get import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext @@ -61,8 +62,9 @@ class PlayerFactory suspend fun createVideoPlayer( backend: PlayerBackend, - prefs: PlaybackPreferences, + appPreferences: AppPreferences, ): PlayerCreation { + val prefs = appPreferences.playbackPreferences withContext(WholphinDispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") @@ -105,6 +107,7 @@ class PlayerFactory WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = if (useLibAss) { val renderType = @@ -129,7 +132,9 @@ class PlayerFactory extractorsFactory, ) } - val trackSelector = createTrackSelector() + val tunneling = + appPreferences.experimentalPreferences.get { videoTunnelingEnabled } + val trackSelector = createTrackSelector(tunneling) ExoPlayer .Builder(context) @@ -200,11 +205,13 @@ class PlayerFactory .setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true) - private fun createTrackSelector() = + private fun createTrackSelector(tunneling: Boolean? = null) = DefaultTrackSelector(context).apply { setParameters( buildUponParameters() - .setAudioOffloadPreferences( + .apply { + tunneling?.let { setTunnelingEnabled(tunneling) } + }.setAudioOffloadPreferences( AudioOffloadPreferences .Builder() .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) 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 b297c0c6..37d3e5ad 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 @@ -253,7 +253,7 @@ class PlaybackViewModel val playerCreation = playerFactory.createVideoPlayer( playerBackend, - preferences.appPreferences.playbackPreferences, + preferences.appPreferences, ) this.player = playerCreation.player currentPlayer.update { 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 06399fe9..0a843dcb 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 @@ -37,6 +37,7 @@ enum class PreferenceScreenOption { MPV, SCREENSAVER, SKIP_SEGMENTS, + EXPERIMENTAL, ; 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 982e2537..b08e9508 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 @@ -48,13 +48,16 @@ import coil3.imageLoader import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference 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.SkipSegmentPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.experimentalPreferences import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.Release @@ -148,6 +151,7 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences + PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences } val screenTitle = when (preferenceScreenOption) { @@ -157,6 +161,7 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior + PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings } var visible by remember { mutableStateOf(false) } @@ -569,6 +574,46 @@ fun PreferencesContent( ) } + ExperimentalPreference.Enable -> { + var showConfirm by remember { mutableStateOf(false) } + pref as AppSwitchPreference<AppPreferences> + val value = pref.getter.invoke(preferences) + SwitchPreference( + title = stringResource(pref.title), + value = value, + onClick = { + if (value) { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, false) + } + } + } else { + showConfirm = true + } + }, + summaryOn = pref.summaryOn?.let { stringResource(pref.summaryOn) }, + summaryOff = pref.summaryOff?.let { stringResource(pref.summaryOff) }, + ) + if (showConfirm) { + ConfirmDialog( + title = stringResource(R.string.confirm_enable_experimental_title), + body = stringResource(R.string.confirm_enable_experimental_body), + onCancel = { showConfirm = false }, + onConfirm = { + showConfirm = false + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, true) + } + } + }, + ) + } + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -762,6 +807,7 @@ fun PreferencesPage( PreferenceScreenOption.MPV, PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, + PreferenceScreenOption.EXPERIMENTAL, -> { PreferencesContent( initialPreferences, 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 8db01751..c81ffcb7 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 @@ -111,7 +111,7 @@ class SlideshowViewModel val playerCreation = playerFactory.createVideoPlayer( backend = PlayerBackend.EXO_PLAYER, - appPreferences.playbackPreferences, + appPreferences, ) player = playerCreation.player player.addListener(this@SlideshowViewModel) diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index d2443c60..2eb47cef 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -212,6 +212,11 @@ message MusicPreferences { bool show_backdrop = 4; } +message ExperimentalPreferences { + bool enabled = 1; + bool video_tunneling_enabled = 2; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -229,4 +234,5 @@ message AppPreferences { bool sign_in_automatically = 11; PhotoPreferences photo_preferences = 12; MusicPreferences music_preferences = 13; + ExperimentalPreferences experimental_preferences = 14; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd999158..312f78b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -829,4 +829,8 @@ <string name="popularity">Popularity</string> <string name="production_status">Production status</string> <string name="different_for_hdr">Use different style for HDR</string> + <string name="experimental_settings">Experimental settings</string> + <string name="confirm_enable_experimental_title">Enable experimental settings?</string> + <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> + <string name="video_tunneling">Video tunneling</string> </resources> From 00338c5c113ca9669c27079b363dd6b1cd2697c3 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 27 Jun 2026 14:29:46 -0400 Subject: [PATCH 106/475] Fix MPV minify crash (#1636) ## Description Fixes a crash when trying to play content via MPV on release builds. After #1365, the package name for `MPVLib` changed, so the existing proguard rule no longer applied. This meant the native code could no longer find the right class. ### Related issues Fixes #1633 https://github.com/damontecres/wholphin-extensions/pull/9 fixes this upstream ### Testing Tested release builds on emulator ## Screenshots N/A ## AI or LLM usage None --- app/proguard-rules.pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8210c734..d45ace7b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ -keepattributes SourceFile,LineNumberTable --keep class com.github.damontecres.wholphin.util.mpv.* { *; } +-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { From 1c9081951aefe318713a06e9d035e98a5f15ee4e Mon Sep 17 00:00:00 2001 From: calebvetter <39844454+calebvetter@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:42:01 -0700 Subject: [PATCH 107/475] Fix AFTKRT + HSL/TS + EAC3 7.1 playback issues (#1580) ## Description This is a targeting fix for a very specific bug: on Fire TV 4k Max (AFTKRT), and possibly others but untested, ExoPlayer can't play an HLS/TS stream that has 8-channel (7.1) EAC3 audio. Direct play is not affected (7.1 EAC3 plays fine), and fewer channels work fine (i.e. 5.1 EAC3) even in an HLS/TS wrapper. This bug + device is tracked in KnownDefects. If more models are tested and found to have the same issue, they can be added to the list. Top candidates are probably AFTKA and AFTKM since they are also in the models with HEVC DoVi/HDR10+ playback issues and so probably have similar underlying limitations, but I don't have those devices to test. The fix is to first allow the app to make a playback request to the server and receive the stream info Jellyfin has decided to send. Then it evaluates the settings and stream for the following: - Playback is using ExoPlayer - Device is in the modelsWithEac3HlsPassthroughBug list (currently only AFTKRT) - Stream is not direct play - Stream is HLS with MPEG-TS container - Audio is 8-channel EAC3 We have to use a two-request fix because we don't know what Jellyfin is going to decide for the stream until we receive it. That way normal playable streams aren't getting overwritten by the fix when they shouldn't be. So the only streams that get the two request path applied is the exact fix for this bug. To apply post-playback-info fixes (including this one) a helper file was created PostPlaybackInfoFixes. In the future, if there are more bugs that can only be determined after the decided stream info is received, they can be added here. This keeps things logical and maintainable. ### Related issues The bug was able to be reproduced on multiple items with this exact scenario by simply playing back the item (making sure ExoPlayer is set as the default player in settings). ### Testing Tested on the target device: Amazon Fire TV 4k Max (2nd Gen), model AFTKRT. The following media was tested: - ExoPlayer, multiple titles with HEVC, EAC3 7.1 copied into HLS/TS (all failed, only processing 1-2fps) - Direct play of the same titles (works) - EAC3 5.1, HLS, MPEG-TS (works) - MPV with failing titles (works) The following device and settings were tested and had no effect on the above media results, ruling out external connections and settings: - HDMI to an LG TV, both connected to a soundbar via ARC, and disconnected using internal speakers - HDMI to a Dell computer monitor - Fire TV Settings > Display & Sound > Audio > Surround Sound > all 4 settings tested (Best Available, PCM, Dolby Digital Plus, Dolby Digital) ## AI or LLM usage Codex was used to debug the issue, create the initial change, and do iterative testing on the device to narrow the scope. The code was then carefully reviewed and I made structural decisions so everything fits well within the project's established conventions, removing bloat, added comments and kept it targeted, clear and maintainable. --- .../wholphin/util/profile/DeviceProfileUtils.kt | 13 +++++++++++++ .../wholphin/util/profile/KnownDefects.kt | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 2de5310e..a18d1805 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -203,6 +203,19 @@ fun createDeviceProfile( } // / Codec profiles + // Prevent 8-channel EAC3 passthrough in TS on certain devices + if (KnownDefects.eac3HlsPassthroughBug && Codec.Audio.EAC3 in allowedAudioCodecs) { + codecProfile { + type = CodecType.VIDEO_AUDIO + container = Codec.Container.TS + codec = Codec.Audio.EAC3 + + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 7 + } + } + } + // H264 profile codecProfile { type = CodecType.VIDEO diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt index a4cd60c4..d2a3c042 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -14,6 +14,14 @@ private val modelsWithDoViHdr10PlusBug = "AFTKM", // Amazon Fire TV 4K (2nd Gen) ) +/** + * List of device models with known playback issues when 8-channel EAC3 is copied into MPEG-TS. + */ +private val modelsWithEac3TsPassthroughBug = + listOf( + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + ) + /** * List of device models that support H264 Hi10P 5.2, but don't advertise it * @@ -30,5 +38,6 @@ private val modelsWithHi10P52Support = object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug + val eac3HlsPassthroughBug = Build.MODEL in modelsWithEac3TsPassthroughBug val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } From ad25a1eb7cd73733defa90644fb278b49571b3c7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 28 Jun 2026 14:18:21 -0400 Subject: [PATCH 108/475] Fix issue templates --- .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 27e43e4b..5f52226f 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,5 @@ name: "Bug Report" description: Report a bug -title: "" labels: [ "bug" ] diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 00ee97f3..389d4e8a 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,5 @@ name: "Playback Problem" description: Report an issue with playback -title: "" labels: [ "bug", "playback" ] From 0e62b9582cd33d9801f5fda6a1127d99babc79a6 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 1 Jul 2026 19:18:16 -0400 Subject: [PATCH 109/475] Improved lifecycle handling & code clean up (#1653) ## Description Add some improvements related to life cycle handling whenever the app is resumed: - Refreshing the server-side user config - Refreshing the home page automatically Also 401/403 errors accessing recording folders will be skipped since this typically means the user lost access while the app was running. Refreshing the server-side user config (above) will also help with this. Finally, there's some general code clean up in `MainActivity` to move non-UI related code out of Compose. ### Related issues Closes #1647 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 228 ++++++++++-------- .../wholphin/data/ServerRepository.kt | 11 + .../wholphin/services/NavDrawerService.kt | 22 +- .../damontecres/wholphin/ui/main/HomePage.kt | 4 +- 4 files changed, 161 insertions(+), 104 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 8d9beccb..a5b6e100 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend @@ -50,6 +51,7 @@ 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.collectLatestIn import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault @@ -72,7 +74,8 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient @@ -80,6 +83,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -138,6 +142,7 @@ class MainActivity : AppCompatActivity() { lateinit var screensaverService: ScreensaverService private var signInAuto = true + private var playerBackend: PlayerBackend? = null private val json = Json { @@ -172,7 +177,7 @@ class MainActivity : AppCompatActivity() { if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() if (lastDest.isPlayback) { - Timber.v("Restoring back stack with playback") + Timber.v("Restoring back stack without playback") backStack = backStack.toMutableList().apply { removeAt(lastIndex) } } } @@ -209,55 +214,64 @@ class MainActivity : AppCompatActivity() { Timber.e(ex, "Error with keepScreenOn") }.launchIn(lifecycleScope) + userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> + signInAuto = prefs.signInAutomatically + playerBackend = prefs.playbackPreferences.playerBackend + } + viewModel.appStart() setContent { - val appPreferences by userPreferencesDataStore.data.collectAsState(null) - if (appPreferences == null) { - // Show loading page if it is taking a while to get app preferences - var showLoading by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - delay(500) - Timber.i("Showing loading page") - showLoading = true - } - if (showLoading) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - LoadingPage() + Surface( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + val appPreferences by userPreferencesDataStore.data.collectAsState(null) + if (appPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500.milliseconds) + Timber.i("Showing loading page") + showLoading = true } - } - } - appPreferences?.let { appPreferences -> - LaunchedEffect(appPreferences.signInAutomatically) { - signInAuto = appPreferences.signInAutomatically - } - CoilConfig( - prefs = appPreferences, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - ProvideLocalClock { - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - appPreferences = appPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - modifier = Modifier.fillMaxSize(), - ) + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } else { + appPreferences?.let { appPreferences -> + CoilConfig( + prefs = appPreferences, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, + ) + LaunchedEffect(appPreferences.debugLogging) { + DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging + } + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, + ) { + ProvideLocalClock { + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + modifier = Modifier.fillMaxSize(), + ) + } + } } } } @@ -279,6 +293,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() Timber.d("onResume") + viewModel.appResume() lifecycleScope.launchDefault { screensaverService.pulse() } @@ -342,8 +357,6 @@ class MainActivity : AppCompatActivity() { // the back stack rather than crashing in onSaveInstanceState. Timber.w(ex, "Could not persist back stack; skipping") } - val playerBackend = - runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) } @@ -440,63 +453,71 @@ class MainActivityViewModel private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, ) : ViewModel() { + private val mutex = Mutex() + fun appStart() { viewModelScope.launchDefault { - try { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() - } - appUpgradeHandler.copySubfont(false) - val prefs = - preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val profileProtected = - serverRepository.current.value?.user?.let { - it.hasPin || it.requireLogin - } == true - if (prefs.signInAutomatically && !profileProtected) { - val current = - serverRepository.restoreSession( - prefs.currentServerId?.toUUIDOrNull(), - prefs.currentUserId?.toUUIDOrNull(), + mutex.withLock { + try { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), ) - if (current != null) { - if (current.user.hasPin || current.user.requireLogin) { - navigationManager.navigateTo(SetupDestination.UserList(current.server)) - } else { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) - } - } else { - // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) + appUpgradeHandler.run() } - } else { - navigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - serverRepository.serverDao.getServer(currentServerId)?.server - if (currentServer != null) { - navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + appUpgradeHandler.copySubfont(false) + val prefs = + preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val profileProtected = + serverRepository.current.value?.user?.let { + it.hasPin || it.requireLogin + } == true + if (prefs.signInAutomatically && !profileProtected) { + val current = + serverRepository.restoreSession( + prefs.currentServerId?.toUUIDOrNull(), + prefs.currentUserId?.toUUIDOrNull(), + ) + if (current != null) { + if (current.user.hasPin || current.user.requireLogin) { + navigationManager.navigateTo(SetupDestination.UserList(current.server)) + } else { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } } else { + // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + serverRepository.serverDao.getServer(currentServerId)?.server + if (currentServer != null) { + navigationManager.navigateTo( + SetupDestination.UserList( + currentServer, + ), + ) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + navigationManager.navigateTo(SetupDestination.ServerList) } - } catch (ex: Exception) { - Timber.e(ex, "Error during appStart") - navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchDefault { @@ -504,6 +525,19 @@ class MainActivityViewModel deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() } } + + fun appResume() { + viewModelScope.launchDefault { + mutex.withLock { + try { + Timber.v("Updating userDto") + serverRepository.updateUserDto() + } catch (ex: Exception) { + Timber.w(ex, "Error during appResume") + } + } + } + } } private val Destination?.isPlayback: Boolean 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 4f3ccbd1..5d94b008 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -298,6 +299,16 @@ class ServerRepository response.content } + /** + * Update [currentUserDto] by querying the server + */ + suspend fun updateUserDto() { + val userDto by apiClient.userApi.getCurrentUser() + _currentUserDto.update { + if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it + } + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( 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 b8322efe..a70dff71 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 @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType @@ -132,13 +133,22 @@ class NavDrawerService .content.items val recordingFolders = if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = userId) - .content.items - .map { it.id } - .toSet() + try { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } catch (ex: InvalidStatusException) { + if (ex.status == 401 || ex.status == 403) { + Timber.w("Got HTTP %s querying for recording folders", ex.status) + emptySet() + } else { + throw ex + } + } } else { - setOf() + emptySet() } val libraries = userViews 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 afaaac56..27d84670 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -103,8 +104,9 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { + LifecycleStartEffect(Unit) { viewModel.init() + onStopOrDispose { } } val state by viewModel.state.collectAsState() val loading = state.loadingState From 06d7c8502737068f540b5240d8b4634d4022ef99 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:39:05 -0400 Subject: [PATCH 110/475] Update Github Actions (#1620) --- .github/actions/setup/action.yml | 4 ++-- .github/workflows/pr.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 85070a55..28f6f9a2 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: zulu java-version: '21' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2d681fce..e0d516cd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.14' - name: Run pre-commit From 1d7ddd713b53e62fa2c31fb3fbbffd6c40431ee3 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 3 Jul 2026 11:36:54 -0400 Subject: [PATCH 111/475] Bump & clean up some dependencies (#1660) ## Description Updates several dependencies and cleans up some naming and duplicates. There are no user facing changes in this PR ### Dev notes There is a workaround needed to upgrade to Kotlin 2.4.0 to avoid https://github.com/google/ksp/issues/2964 There are a few more dependencies, but they require increasing the target SDK to 37, but I don't want to do that yet. API 37 requires requesting a [new permission for local network access](https://developer.android.com/privacy-and-security/local-network-permission). Since that is a critical requirement for Wholphin/Jellyfin, I need to spend more time reviewing the dev guide to make sure it's handled correctly. ### Related issues Replaces #1593 Replaces #1512 Replaces #1465 Replaces part of #1446 ### Testing Unit tests and quick emulator run ## Screenshots N/A ## AI or LLM usage None --- .github/workflows/pr.yml | 3 --- app/build.gradle.kts | 13 ++++++------ gradle/libs.versions.toml | 27 ++++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 ++-- gradlew.bat | 4 ++-- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e0d516cd..2d5a73f6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,6 +42,3 @@ jobs: ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - env: - ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" - ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e61a1fa8..a32917d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,9 @@ kotlin { languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = JvmTarget.JVM_11 javaParameters = true + + // Workaround for https://github.com/google/ksp/issues/2964 + moduleName = "${project.group}_${project.name}" } } @@ -332,7 +335,6 @@ dependencies { implementation(libs.androidx.room.common.jvm) implementation(libs.androidx.room.ktx) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) @@ -360,10 +362,6 @@ dependencies { implementation(libs.kache) implementation(libs.kache.file) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.desugar.jdk.libs) if (ffmpegModuleExists.get()) { @@ -395,6 +393,8 @@ dependencies { implementation(project(":wholphin-mpv-stub")) } + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) @@ -402,7 +402,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.hilt.android.testing) - androidTestImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 185d40d1..a98a6ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,17 +6,17 @@ auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" -hiltWork = "1.3.0" +hiltCompiler = "1.3.0" kache = "2.1.1" -kotlin = "2.3.21" -ksp = "2.3.8" +kotlin = "2.4.0" +ksp = "2.3.9" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.05.01" -mockk = "1.14.9" +composeBom = "2026.06.01" +mockk = "1.14.11" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.41.0" -okhttpBom = "5.3.2" +okhttpBom = "5.4.0" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -25,16 +25,16 @@ tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" androidx-media3 = "1.10.1" -coil = "3.4.0" +coil = "3.5.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.2" +nav3Core = "1.1.3" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.10.0" datastore = "1.2.1" kotlinx-serialization = "1.11.0" -protobuf-javalite = "4.35.0" -hilt = "2.59.2" +protobuf-javalite = "4.35.1" +hilt = "2.60" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -49,7 +49,7 @@ wholphin-extensions = "0.2.1" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -material = "1.13.0" +material = "1.14.0" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } @@ -72,7 +72,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } @@ -81,9 +81,8 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", 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" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltCompiler" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad7..a9db1155 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.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b0..8508ef68 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel From f6e00c782055da0aaf64d7fedfa3f62c795b91ca Mon Sep 17 00:00:00 2001 From: Tal Sarid <85511949+tal-sarid@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:43:06 +0300 Subject: [PATCH 112/475] Add localized duration formatting and related string resources (#1571) ## Description Instead of using English-only duration format (e.g. `HHh MMm SSs`) in various places, I've added a format function that is based on several new localized resources keys. That includes: 1. Media runtime in Item view 2. Time remaining in Item view 3. End time in Player view 4. Time passed and time remained in Player view ### Related issues None ### Testing I installed the app on my Google TV Streamer, switched to my language, browsed all item views that I could think of to see that the duration labels are all localized in my language. I didn't commit those strings, I will do it through the Weblate after it will get merged. I also played a few items and saw that the labels there are localized as well. ## Screenshots It looks the same in English as before, currently no other language has custom resources for it yet. ## AI or LLM usage Used Claude Sonnet to detect places that I missed that are related to this localization change. Also it helped me to get access to my format function from various places in the code. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../damontecres/wholphin/data/model/BaseItem.kt | 15 +++++++++++++-- .../damontecres/wholphin/ui/Formatting.kt | 17 +++++++++++++++++ .../wholphin/ui/cards/ChapterCard.kt | 7 ++++++- .../ui/components/CollectionFolderList.kt | 8 +++++--- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 3 ++- .../wholphin/ui/detail/PlaylistDetails.kt | 6 +++++- .../discover/DiscoverMovieDetailsHeader.kt | 8 +++++--- .../ui/detail/discover/DiscoverSeriesDetails.kt | 7 ++++--- .../ui/detail/livetv/LiveTvViewModel.kt | 9 ++++----- .../wholphin/ui/detail/music/SongListDisplay.kt | 4 +++- .../ui/playback/overlay/PlaybackController.kt | 2 +- .../ui/playback/overlay/PlaybackControls.kt | 12 +++++++++--- .../ui/playback/overlay/PlaybackOverlay.kt | 6 +++++- app/src/main/res/values/strings.xml | 7 +++++++ 14 files changed, 86 insertions(+), 25 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 a5526f87..5d5c8923 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 @@ -5,12 +5,15 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable 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 com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -161,10 +164,18 @@ data class BaseItem( ?.ticks ?.roundMinutes ?.takeIf { it > Duration.ZERO } - ?.let { add(it.toString()) } + ?.let { add(WholphinApplication.instance.resources.formatDuration(it)) } data.timeRemaining ?.roundMinutes - ?.let { add("$it left") } + ?.let { + val resources = WholphinApplication.instance.resources + add( + resources.getString( + R.string.time_left, + resources.formatDuration(it), + ), + ) + } } details.forEachIndexed { index, string -> append(string) 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 2ea1bf87..a9ccd690 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui import android.content.Context +import android.content.res.Resources import android.text.format.DateFormat import androidx.annotation.StringRes import androidx.compose.foundation.text.appendInlineContent @@ -21,6 +22,7 @@ import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Date import java.util.Locale +import kotlin.time.Duration /** * Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current @@ -259,3 +261,18 @@ fun formatTypeName(type: BaseItemKind): Int = BaseItemKind.LIVE_TV_PROGRAM -> TODO() BaseItemKind.PLAYLISTS_FOLDER -> TODO() } + +/** + * Format a [Duration] into a localized "Xh Ym Zs" string using string resources. + */ +fun Resources.formatDuration(duration: Duration): String = + duration.toComponents { hours, minutes, seconds, _ -> + when { + hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) + hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes) + hours > 0 -> getString(R.string.duration_hours, hours.toInt()) + minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) + minutes > 0 -> getString(R.string.duration_minutes, minutes) + else -> getString(R.string.duration_seconds, seconds) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 54d1d2fc..d3c87124 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -9,15 +9,18 @@ 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.roundSeconds import kotlin.time.Duration @@ -62,8 +65,10 @@ fun ChapterCard( text = it, ) } + val resources = LocalResources.current + val positionText = remember(position) { resources.formatDuration(position.roundSeconds) } Text( - text = position.roundSeconds.toString(), + text = positionText, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 3c69e5ce..e82d87a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -38,6 +38,7 @@ 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.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItemDefaults @@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.AlphabetButtons import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.rememberInt @@ -357,7 +359,7 @@ fun ListItemGeneric( ?.runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) }, scale = scale, @@ -419,7 +421,7 @@ fun ListItemMovie( ?.runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) if (item?.played == true) { WatchedIcon(padding = 0.dp) @@ -546,7 +548,7 @@ fun ListItemAlbum( .runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) } } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index f0591868..08bd0c55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec @@ -153,7 +154,7 @@ fun ItemDetailsDialog( ) } source.runTimeTicks?.let { - add(runtimeLabel to it.ticks.toString()) + add(runtimeLabel to resources.formatDuration(it.ticks)) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index ea1640ed..7d66c08b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -94,6 +95,7 @@ import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault @@ -809,9 +811,11 @@ fun PlaylistItem( val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) formatTime(context, endTime) } + val resources = LocalResources.current + val durationText = remember(duration) { resources.formatDuration(duration) } Column { Text( - text = duration.toString(), + text = durationText, ) if (item.type != BaseItemKind.AUDIO) { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 60d3806b..8b6f3e98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow @@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString @@ -64,16 +66,16 @@ fun DiscoverMovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 4.dp + val resources = LocalResources.current val details = - remember(movie, rating) { + remember(movie, rating, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime ?.toDouble() ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } val release = movie.releases ?.results 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 be9acc64..947dae96 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 @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.nav.Destination @@ -446,8 +447,9 @@ fun DiscoverSeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 4.dp + val resources = LocalResources.current val details = - remember(series, rating) { + remember(series, rating, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -455,8 +457,7 @@ fun DiscoverSeriesDetailsHeader( ?.takeIf { !it.isNaN() && it > 0 } ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } // TODO }.let { listToDotString( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 01279c97..a2a4bd01 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -20,6 +20,7 @@ import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -643,18 +644,16 @@ data class TvProgram( append(time) if (!isFake) { + val resources = WholphinApplication.instance.resources dot() - duration - .roundMinutes - .toString() - .let(::append) + append(resources.formatDuration(duration.roundMinutes)) if (now.isAfter(start) && now.isBefore(end)) { dot() java.time.Duration .between(now, end) .toKotlinDuration() .roundMinutes - .let { append("$it left") } + .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } } seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { dot() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 011bb8ea..3879cca4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.DenseListItem @@ -32,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -115,7 +117,7 @@ fun SongListItem( } val trailingContent = @Composable { Text( - text = runtime.toString(), + text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "", ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt index 86cd81b1..2909e666 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -215,7 +215,7 @@ fun Controller( } } Text( - text = "Ends $endTimeStr", + text = stringResource(R.string.ends_at, endTimeStr), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index 820e634f..79f44929 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -70,6 +71,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.formatDuration import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType @@ -274,9 +276,13 @@ fun SeekBar( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - val remaining = ((player.duration - position) / 1000).seconds + val resources = LocalResources.current + val positionSec = position / 1000 + val remainingSec = (player.duration - position) / 1000 + val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } Text( - text = (position / 1000).seconds.toString(), + text = positionText, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = @@ -284,7 +290,7 @@ fun SeekBar( .padding(8.dp), ) Text( - text = "-$remaining", + text = remainingText, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 2c2aefe8..a6ade035 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -56,6 +57,7 @@ import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.AnalyticsState import com.github.damontecres.wholphin.ui.playback.ControllerViewState @@ -304,8 +306,10 @@ fun PlaybackOverlay( ) } } + val resources = LocalResources.current + val seekText = remember(seekProgressMs / 1000L) { resources.formatDuration((seekProgressMs / 1000L).seconds) } Text( - text = (seekProgressMs / 1000L).seconds.toString(), + text = seekText, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 312f78b2..0af5ea00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,12 @@ <string name="continue_watching">Continue watching</string> <string name="critic_rating">Critic Rating</string> <string name="decimal_seconds">%.2f seconds</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> <string name="default_track">Default</string> <string name="delete">Delete</string> <string name="died">Died</string> @@ -144,6 +150,7 @@ <string name="suggestions">Suggestions</string> <string name="switch_servers">Switch servers</string> <string name="switch_user">Switch</string> + <string name="time_left">%1$s left</string> <string name="top_unwatched">Top Rated Unwatched</string> <string name="trailers_title">Trailers</string> <string name="tv_dvr_schedule">DVR Schedule</string> From 013755208dee800c83964cc22048bedef656794c Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 7 Jul 2026 13:56:22 -0400 Subject: [PATCH 113/475] Update Seerr integration version & start blocklist handling logic (#1671) ## Description Start testing against Seerr `3.3.0` which has been out for over a month. The only significant change is that the new "blocklisted" status is added, but it uses the same code as the previous "deleted" status. So generally, Seerr `3.2.0` will still work. This starts work on supporting Seerr's blocklist which, unfortunately, is expected to be enforced on the client side. For now, blocklisted items still show, but the request button is disabled and shows "Unavailable" instead. Finally, some Seerr parent permissions I missed in #1631 are added. ### Related issues Related to #1626 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- README.md | 2 +- .../wholphin/data/model/DiscoverItem.kt | 4 +- .../wholphin/data/model/SeerrPermission.kt | 12 +++--- .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../wholphin/ui/cards/DiscoverItemCard.kt | 37 ++++++++++++++++++- .../discover/ExpandableDiscoverButtons.kt | 30 +++++++++++++-- .../ui/detail/discover/RequestSeasons.kt | 5 +++ app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 81 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 07f491a1..b37ac85a 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! -Seerr integration is tested with `v3.2.0`. Older versions may not work. +Seerr integration is tested with `v3.3.0`. Older versions may not work. ## Contributions 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 471cad6d..ef11aff3 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 @@ -56,12 +56,14 @@ enum class SeerrItemType( enum class SeerrAvailability( val status: Int, ) { + // https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14 UNKNOWN(1), PENDING(2), PROCESSING(3), PARTIALLY_AVAILABLE(4), AVAILABLE(5), - DELETED(6), + BLOCKLISTED(6), + DELETED(7), ; companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index 8e5a5b06..aafdebf7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -23,8 +23,8 @@ enum class SeerrPermission( REQUEST_4K(1024), REQUEST_4K_MOVIE(2048, REQUEST_4K), REQUEST_4K_TV(4096, REQUEST_4K), - REQUEST_ADVANCED(8192), - REQUEST_VIEW(16384), + REQUEST_ADVANCED(8192, MANAGE_REQUESTS), + REQUEST_VIEW(16384, MANAGE_REQUESTS), AUTO_APPROVE_4K(32768), AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K), AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), @@ -36,10 +36,10 @@ enum class SeerrPermission( AUTO_REQUEST(8388608), AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST), AUTO_REQUEST_TV(33554432, AUTO_REQUEST), - RECENT_VIEW(67108864), - WATCHLIST_VIEW(134217728), - MANAGE_BLACKLIST(268435456), - VIEW_BLACKLIST(1073741824, MANAGE_BLACKLIST), + RECENT_VIEW(67108864, MANAGE_REQUESTS), + WATCHLIST_VIEW(134217728, MANAGE_REQUESTS), + MANAGE_BLOCKLIST(268435456), + VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST), ; internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag 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 8ec10cea..7dbcac1e 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 @@ -38,6 +38,7 @@ object AppColors { val Purple = Color(147, 51, 234) val Green = Color(74, 222, 128) val Yellow = Color(234, 179, 8) + val Red = Color(204, 38, 38) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 815c6f35..a6476bd5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -130,7 +130,16 @@ fun DiscoverItemCard( AvailableIndicator(Modifier.align(Alignment.TopEnd)) } - else -> {} + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed +// BlocklistedIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + null, + -> { + } } if (showOverlay) { val color = @@ -284,6 +293,31 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } } +@Composable +fun BlocklistedIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Red.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_xmark), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + @PreviewTvSpec @Composable private fun Preview() { @@ -292,6 +326,7 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() + BlocklistedIndicator() } } } 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 2c3fc036..c5921b3b 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 @@ -60,7 +60,11 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.go_to - SeerrAvailability.DELETED -> R.string.delete // TODO + // TODO + SeerrAvailability.DELETED -> R.string.delete + + // TODO Blocklist + SeerrAvailability.BLOCKLISTED -> R.string.unavailable } val icon = when (availability) { @@ -74,13 +78,29 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.fa_play - SeerrAvailability.DELETED -> R.string.fa_video // TODO + SeerrAvailability.DELETED -> R.string.fa_video + + // TODO + SeerrAvailability.BLOCKLISTED -> R.string.fa_xmark } item("first") { ExpandableFaButton( title = text, iconStringRes = icon, - enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + enabled = + when (availability) { + SeerrAvailability.UNKNOWN -> canRequest + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> true + + SeerrAvailability.BLOCKLISTED, + SeerrAvailability.DELETED, + -> false + }, onClick = { when (availability) { SeerrAvailability.UNKNOWN -> { @@ -99,7 +119,9 @@ fun ExpandableDiscoverButtons( goToOnClick.invoke() } - SeerrAvailability.DELETED -> { + SeerrAvailability.DELETED, + SeerrAvailability.BLOCKLISTED, + -> { // TODO } } 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 index 04ce859b..7adb018e 100644 --- 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 @@ -282,6 +282,11 @@ fun SeasonListItem( SeerrAvailability.AVAILABLE -> { AvailableIndicator() } + + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed +// BlocklistedIndicator() + } } }, trailingContent = { diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 770a6b72..7e13f584 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -56,4 +56,5 @@ <string name="fa_compass" translatable="false"></string> <string name="fa_repeat" translatable="false"></string> <string name="fa_compact_disc" translatable="false"></string> + <string name="fa_xmark" translatable="false"></string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0af5ea00..13686543 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -840,4 +840,6 @@ <string name="confirm_enable_experimental_title">Enable experimental settings?</string> <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> <string name="video_tunneling">Video tunneling</string> + <string name="blocklisted">Blocklisted</string> + <string name="unavailable">Unavailable</string> </resources> From 5cef9527970022d4d7d2d6cc35ae65357877563c Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Mon, 22 Jun 2026 12:38:31 +0000 Subject: [PATCH 114/475] Translated using Weblate (Polish) Currently translated at 100.0% (589 of 589 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 37 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c5bd37dc..0f978d04 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -120,10 +120,16 @@ <string name="one_click_pause">Pauza jednym kliknięciem</string> <string name="one_click_pause_summary_on">Wciśnij środek by pauzować/wznowić</string> <plurals name="interviews"> - <item quantity="one">Wywiady</item> + <item quantity="one">Wywiad</item> + <item quantity="few">Wywiady</item> + <item quantity="many">Wywiadów</item> + <item quantity="other">Wywiadów</item> </plurals> <plurals name="scenes"> - <item quantity="one">Sceny</item> + <item quantity="one">Scena</item> + <item quantity="few">Sceny</item> + <item quantity="many">Scen</item> + <item quantity="other">Scen</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Czołówka</item> @@ -262,7 +268,10 @@ <string name="official_rating">Ocena rodzicielska</string> <string name="extras_title">Dodatki</string> <plurals name="other_extras"> - <item quantity="one">Inne</item> + <item quantity="one">Inny</item> + <item quantity="few">Inne</item> + <item quantity="many">Innych</item> + <item quantity="other">Innych</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Usunięta scena</item> @@ -464,7 +473,7 @@ <string name="voice_error_start_failed">Nie udało się uruchomić rozpoznawania głosu</string> <string name="voice_error_timeout">Przekroczono limit czasu rozpoznawania głosu</string> <plurals name="theme_songs"> - <item quantity="one">Piosenka tematyczne</item> + <item quantity="one">Piosenka tematyczna</item> <item quantity="few">Piosenki tematyczne</item> <item quantity="many">Piosenek tematycznych</item> <item quantity="other">Piosenek tematycznych</item> @@ -580,16 +589,28 @@ <string name="aired_episode_order">Kolejność emisji odcinków</string> <string name="runtime_sort">Czas trwania</string> <plurals name="clips"> - <item quantity="one">Klipy</item> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipów</item> + <item quantity="other">Klipów</item> </plurals> <plurals name="samples"> - <item quantity="one">Próbki</item> + <item quantity="one">Próbka</item> + <item quantity="few">Próbki</item> + <item quantity="many">Próbek</item> + <item quantity="other">Próbek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Filmy krótkometrażowe</item> + <item quantity="one">Film krótkometrażowy</item> + <item quantity="few">Filmy krótkometrażowe</item> + <item quantity="many">Filmów krótkometrażowych</item> + <item quantity="other">Filmów krótkometrażowych</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krótkie filmy</item> + <item quantity="one">Krótki filmy</item> + <item quantity="few">Krótkie filmy</item> + <item quantity="many">Krótkich filmy</item> + <item quantity="other">Krótkich filmy</item> </plurals> <string name="aspect_ratio">Proporcje ekranu</string> <string name="guest_stars">Aktorzy gościnni</string> From 22c00fea1bd1a64b804e45785d3cd7aab631a85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Tue, 23 Jun 2026 05:18:18 +0000 Subject: [PATCH 115/475] Translated using Weblate (Estonian) Currently translated at 100.0% (594 of 594 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, 5 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index b7ecec40..37c18c75 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -646,4 +646,9 @@ <item quantity="one">Isik</item> <item quantity="other">Inimesed</item> </plurals> + <string name="genre_movies">%1$s filmi</string> + <string name="genre_tv_shows">%1$s telesaadet</string> + <string name="quality_profile">Kvaliteedi profiil</string> + <string name="is_default">Vaikimisi</string> + <string name="root_folder">Juurkaust</string> </resources> From c5a0f266d923bcf72c3d3071c5ffc9157ab61609 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 23 Jun 2026 09:21:09 +0000 Subject: [PATCH 116/475] Translated using Weblate (Slovak) Currently translated at 100.0% (594 of 594 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 148 +++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index d615e77a..113ddbcc 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -144,14 +144,20 @@ <string name="switch_user">Zmeniť</string> <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> <plurals name="trailers"> - <item quantity="one">Trailery</item> + <item quantity="one">Trailer</item> + <item quantity="few">Trailery</item> + <item quantity="many">Trailerov</item> + <item quantity="other">Trailerov</item> </plurals> <string name="tv_dvr_schedule">DVR rozvrh</string> <string name="tv_guide">Sprievodca</string> <string name="tv_season">Séria</string> <string name="tv_seasons">Série</string> <plurals name="tv_shows"> - <item quantity="one">Seriály</item> + <item quantity="one">Seriál</item> + <item quantity="few">Seriály</item> + <item quantity="many">Seriálov</item> + <item quantity="other">Seriálov</item> </plurals> <string name="ui_interface">Rozhranie</string> <string name="unknown">Neznámy</string> @@ -178,37 +184,70 @@ <string name="runtime_sort">Dĺžka</string> <string name="extras_title">Bonusový materiál</string> <plurals name="other_extras"> - <item quantity="one">Ostatné</item> + <item quantity="one">Iné</item> + <item quantity="few">Iné</item> + <item quantity="many">Iných</item> + <item quantity="other">Iných</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Zo zákulisia</item> + <item quantity="few">Zo zákulisia</item> + <item quantity="many">Zo zákulisia</item> + <item quantity="other">Zo zákulisia</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tématické piesne</item> + <item quantity="one">Tématická pieseň</item> + <item quantity="few">Tématické piesne</item> + <item quantity="many">Tématických piesní</item> + <item quantity="other">Tématických piesní</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tématické videá</item> + <item quantity="one">Tématické video</item> + <item quantity="few">Tématické videá</item> + <item quantity="many">Tématických videí</item> + <item quantity="other">Tématických videí</item> </plurals> <plurals name="clips"> - <item quantity="one">Klipy</item> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipov</item> + <item quantity="other">Klipov</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Vymazané scény</item> + <item quantity="one">Vymazaná scéna</item> + <item quantity="few">Vymazané scény</item> + <item quantity="many">Vymazaných scén</item> + <item quantity="other">Vymazaných scén</item> </plurals> <plurals name="interviews"> - <item quantity="one">Rozhovory</item> + <item quantity="one">Rozhovor</item> + <item quantity="few">Rozhovory</item> + <item quantity="many">Rozhovorov</item> + <item quantity="other">Rozhovorov</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scény</item> + <item quantity="one">Scéna</item> + <item quantity="few">Scény</item> + <item quantity="many">Scén</item> + <item quantity="other">Scén</item> </plurals> <plurals name="samples"> - <item quantity="one">Vzorky</item> + <item quantity="one">Vzorka</item> + <item quantity="few">Vzorky</item> + <item quantity="many">Vzoriek</item> + <item quantity="other">Vzoriek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Krátkometrážne filmy</item> + <item quantity="one">Krátkometrážny film</item> + <item quantity="few">Krátkometrážne filmy</item> + <item quantity="many">Krátkometrážnych filmov</item> + <item quantity="other">Krátkometrážnych filmov</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krátke videá</item> + <item quantity="one">Krátke video</item> + <item quantity="few">Krátke videá</item> + <item quantity="many">Krátkych videí</item> + <item quantity="other">Krátkych videí</item> </plurals> <string name="favorite_items_title">Obľúbené %s</string> <plurals name="downloads"> @@ -569,4 +608,89 @@ <string name="play_next">Prehrať nasledujúce</string> <string name="music_videos">Hudobné videá</string> <string name="lyrics">Texty piesní</string> + <string name="trailers_title">Trailery</string> + <string name="tv_shows_title">Seriály</string> + <string name="other_extras_title">Ostatné</string> + <string name="behind_the_scenes_title">Zo zákulisia</string> + <string name="theme_songs_title">Úvodné piesne</string> + <string name="theme_videos_title">Úvodné videá</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystrihnuté scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukážky</string> + <string name="featurettes_title">Bonusy</string> + <string name="shorts_title">Krátke filmy</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filmy</item> + <item quantity="many">Filmov</item> + <item quantity="other">Filmov</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osoby</item> + <item quantity="many">Osôb</item> + <item quantity="other">Osôb</item> + </plurals> + <string name="require_login">Vyžadovať prihlásenie pre profil</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Kombinované výsledky vyhľadávania</string> + <string name="combined_search_results_on">Zobraziť všetky výsledky v jednom zozname</string> + <string name="combined_search_results_off">Zobraziť výsledky podľa kategórie</string> + <string name="show_voice_search_button">Zobraziť tlačidlo hlasového vyhľadávania</string> + <string name="visible_ui">Viditeľné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="view_options_type_grid">Mriežka</string> + <string name="view_options_type_list">Zoznam</string> + <string name="view_options_type_list_compact">Kompaktný zoznam</string> + <string name="hide_lyrics">Skryť text piesne</string> + <string name="show_lyrics">Zobraziť text piesne</string> + <string name="song_has_lyrics">Skladba má text piesne</string> + <string name="remove_from_queue">Odstrániť z fronty</string> + <string name="show_album_cover">Zobraziť obal albumu</string> + <string name="show_visualizer">Zobraziť vizualizér</string> + <string name="show_backdrop">Zobraziť pozadie</string> + <string name="play_as_type">Prehrať ako?</string> + <string name="visualizer_rationale">Na vizualizáciu práve prehrávaného zvuku je potrebné povolenie na nahrávanie zvuku.</string> + <string name="continue_string">Pokračovať</string> + <string name="separate_types">Oddeliť typy</string> + <string name="prefer_logos">Uprednostniť zobrazovanie log namiesto názvov</string> + <string name="updated_toast">Wholphin bol aktualizovaný na verziu %s</string> + <string name="remove_continue_watching">Odstrániť z Pokračovať v pozeraní</string> + <string name="remove_next_up">Odstrániť seriál zo sekcie Nasleduje</string> + <string name="view_removed_next_up">Zobraziť seriály odstránené zo sekcie Nasleduje</string> + <string name="view_more">Zobraziť viac</string> + <string name="discover_tv">Objavujte seriály</string> + <string name="discover_movies">Objavujte filmy</string> + <string name="studios_in">Štúdiá v %1$s</string> + <string name="last_played">Naposledy prehrané</string> + <string name="system_default">Predvolené nastavenie systému</string> + <string name="ass_subtitle_mode_libass">Priame prehrávanie s libass</string> + <string name="ass_subtitle_mode_exoplayer">Priame prehrávanie s vstavaným ExoPlayerom</string> + <string name="ass_subtitle_mode_transcode">Vypáliť titulky/prekódovať na serveri</string> + <string name="ass_subtitle_playback">Prehrávanie titulkov SSA/ASS</string> + <string name="cinema_mode">Kino režim</string> + <string name="show_more">Zobraziť ďalších %1$s</string> + <string name="skip_behavior">Správanie pri preskočení</string> + <string name="skip_behavior_summary">Pre intrá, titulky a podobne</string> + <string name="play_with">Prehrať pomocou</string> + <string name="transcoding">Prekódovanie</string> + <string name="user_interface_language">Jazyk používateľského rozhrania</string> + <string name="display_toggles_title">Zobrazenie hodnotení</string> + <string name="display_toggles_summary">Prispôsobte, čo sa má zobrazovať</string> + <string name="profile_protection">Ochrana profilu</string> + <string name="continue_watching_click_behavior">Správanie pri kliknutí v sekciách Pokračovať v pozeraní/Nasleduje</string> + <string name="continue_watching_click_summary_on">Prehrať po kliknutí</string> + <string name="continue_watching_click_summary_off">Zobraziť podrobnosti po kliknutí</string> + <string name="stars">Hviezdičky</string> + <string name="tomatoes">Paradajky</string> + <string name="sort">Zoradiť</string> + <string name="split_into_separate_rows">Rozdeliť do samostatných riadkov</string> + <string name="genre_movies">%1$s Filmov</string> + <string name="genre_tv_shows">%1$s Seriálov</string> + <string name="quality_profile">Profil kvality</string> + <string name="is_default">Predvolené</string> + <string name="root_folder">Koreňový priečinok</string> </resources> From 54163bc37f2e996a42027d37cc7b6a50c168d689 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Tue, 23 Jun 2026 05:04:40 +0000 Subject: [PATCH 117/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (594 of 594 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 93b1b0b7..d25bebbd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -625,4 +625,9 @@ <plurals name="people"> <item quantity="other">演職人員</item> </plurals> + <string name="genre_movies">%1$s 電影</string> + <string name="genre_tv_shows">%1$s 電視劇</string> + <string name="quality_profile">品質設定</string> + <string name="is_default">預設</string> + <string name="root_folder">根目錄</string> </resources> From 2c62d36cbe5f02d33a2707e9cbec7723c519c83f Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Tue, 23 Jun 2026 04:43:55 +0000 Subject: [PATCH 118/475] Translated using Weblate (Italian) Currently translated at 100.0% (594 of 594 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 76 +++++++++++++++++++++----- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 76a6d012..11e2463a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -81,20 +81,24 @@ <string name="font">Carattere</string> <string name="background">Sfondo</string> <plurals name="behind_the_scenes"> - <item quantity="one">Dietro le Scena</item> - <item quantity="many">Dietro le Scene</item> - <item quantity="other">Dietro le Scene</item> + <item quantity="one">Dietro la scena</item> + <item quantity="many">Dietro le scene</item> + <item quantity="other">Dietro le scene</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Sigla</item> - <item quantity="many">sigle</item> - <item quantity="other">sigle</item> + <item quantity="many">Sigle</item> + <item quantity="other">Sigle</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviste</item> + <item quantity="one">Intervista</item> + <item quantity="many">Interviste</item> + <item quantity="other">Interviste</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scene</item> + <item quantity="one">Scena</item> + <item quantity="many">Scene</item> + <item quantity="other">Scene</item> </plurals> <string name="watch_live">Guarda diretta</string> <string name="tv_guide">Guida TV</string> @@ -171,7 +175,9 @@ <string name="switch_servers">Cambia server</string> <string name="switch_user">Cambia</string> <plurals name="trailers"> - <item quantity="one">I trailer</item> + <item quantity="one">Trailer</item> + <item quantity="many">Trailer</item> + <item quantity="other">Trailer</item> </plurals> <string name="tv_dvr_schedule">Programma DVR</string> <string name="video_scale">Scala Video</string> @@ -206,13 +212,19 @@ <string name="background_opacity">Opacità sfondo</string> <string name="skip_segment_outro">Salta Outro</string> <plurals name="theme_videos"> - <item quantity="one">Temi Video</item> + <item quantity="one">Tema Video</item> + <item quantity="many">Temi Video</item> + <item quantity="other">Temi Video</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Scene Eliminate</item> + <item quantity="one">Scena Eliminata</item> + <item quantity="many">Scene Eliminate</item> + <item quantity="other">Scene Eliminate</item> </plurals> <plurals name="shorts"> - <item quantity="one">Video brevi</item> + <item quantity="one">Video breve</item> + <item quantity="many">Video brevi</item> + <item quantity="other">Video brevi</item> </plurals> <string name="advanced_settings">Impostazioni Avanzate</string> <string name="check_for_updates">Cerca aggiornamenti</string> @@ -241,10 +253,14 @@ </plurals> <string name="remember_selected_tab">Ricorda schede selezionate</string> <plurals name="samples"> - <item quantity="one">Campioni</item> + <item quantity="one">Campione</item> + <item quantity="many">Campioni</item> + <item quantity="other">Campioni</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> + <item quantity="one">Contenuto extra</item> + <item quantity="many">Contenuti extra</item> + <item quantity="other">Contenuti extra</item> </plurals> <string name="combine_continue_next"><![CDATA[Combina Continua a Guardare & Prossimo Episodio]]></string> <string name="downmix_stereo">Converti sempre in stereo</string> @@ -254,6 +270,8 @@ <string name="runtime_sort">Durata</string> <plurals name="clips"> <item quantity="one">Clip</item> + <item quantity="many">Clip</item> + <item quantity="other">Clip</item> </plurals> <plurals name="downloads"> <item quantity="one">%s scaricamento</item> @@ -622,4 +640,36 @@ <string name="display_toggles_summary">Personalizza quali mostrare</string> <string name="continue_watching_click_behavior">Comportamento click su Continua a guardare / Prossimo</string> <string name="sort">Ordina</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Altri</string> + <string name="behind_the_scenes_title">Dietro le scene</string> + <string name="theme_songs_title">Sigle</string> + <string name="theme_videos_title">Video delle sigle</string> + <string name="clips_title">Clip</string> + <string name="deleted_scenes_title">Scene cancellate</string> + <string name="interviews_title">Interviste</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Campioni</string> + <string name="shorts_title">Corti</string> + <string name="featurettes_title">Contenuti extra</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="many">Film</item> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie TV</item> + <item quantity="many">Serie TV</item> + <item quantity="other">Serie TV</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persona</item> + <item quantity="many">Persone</item> + <item quantity="other">Persone</item> + </plurals> + <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Serie TV</string> + <string name="quality_profile">Profilo di qualità</string> + <string name="is_default">Predefinito</string> + <string name="root_folder">Cartella principale</string> </resources> From 5390332e0f22d22b719ed5d5027ec2de89ce947b Mon Sep 17 00:00:00 2001 From: rgabbo <rgabbo@noreply.codeberg.org> Date: Tue, 23 Jun 2026 14:21:57 +0000 Subject: [PATCH 119/475] Translated using Weblate (Italian) Currently translated at 100.0% (594 of 594 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 11e2463a..e5e5ae22 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -110,7 +110,7 @@ <string name="critic_rating">Valutazione della critica</string> <string name="default_track">Predefinita</string> <string name="discovered_servers">Server Scoperti</string> - <string name="download_and_update"><![CDATA[Scarica & Aggiorna]]></string> + <string name="download_and_update"><![CDATA[Scarica e Aggiorna]]></string> <string name="downloading">Scaricando…</string> <string name="forced_track">Forzata</string> <string name="go_to">Vai a</string> From 587c289775399aeb57e3be9d9b047ec8ee0c69c7 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Tue, 23 Jun 2026 00:31:05 +0000 Subject: [PATCH 120/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (594 of 594 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, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a774ec7a..327360ba 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -625,4 +625,9 @@ <plurals name="people"> <item quantity="other">演职人员</item> </plurals> + <string name="genre_movies">%1$s 电影</string> + <string name="genre_tv_shows">%1$s 电视节目</string> + <string name="quality_profile">质量配置</string> + <string name="is_default">默认</string> + <string name="root_folder">根文件夹</string> </resources> From a33670ef111bef8a616ea6c9fd1252d54118cc10 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 23 Jun 2026 10:19:14 +0000 Subject: [PATCH 121/475] Translated using Weblate (Turkish) Currently translated at 100.0% (594 of 594 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f6e8c935..f7ef0f11 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ <string name="cancel_series_recording">Dizi kaydını iptal et</string> <string name="cancel">İptal</string> <string name="chapters">Bölümler</string> - <string name="choose_stream">%1$s seç</string> + <string name="choose_stream">Seç: %1$s</string> <string name="clear_image_cache">Resim önbelleğini temizle</string> <string name="collection">Koleksiyon</string> <string name="collections">Koleksiyonlar</string> @@ -184,10 +184,10 @@ <string name="success">Başarılı</string> <string name="official_rating">Yaş Derecelendirmesi</string> <string name="runtime_sort">Süre</string> - <string name="extras_title">Ekstralar</string> + <string name="extras_title">Özel Özellikler</string> <plurals name="other_extras"> - <item quantity="one">Diğer Ekstra</item> - <item quantity="other">Diğer Ekstralar</item> + <item quantity="one">Diğer</item> + <item quantity="other">Diğer</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Sahne Arkası</item> @@ -645,5 +645,10 @@ <string name="samples_title">Örnekler</string> <string name="featurettes_title">Özel Seçenekler</string> <string name="shorts_title">Kısa Videolar</string> - <string name="people_title">Kişiler</string> + <string name="people_title">Oyuncular</string> + <string name="genre_movies">%1$s Filmleri</string> + <string name="genre_tv_shows">%1$s Dizileri</string> + <string name="quality_profile">Kalite Profili</string> + <string name="is_default">Varsayılan</string> + <string name="root_folder">Kök Klasör</string> </resources> From d0f44513f6a2fc6c34408415da4ce6a873721ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Wed, 24 Jun 2026 07:04:22 +0000 Subject: [PATCH 122/475] Translated using Weblate (Estonian) Currently translated at 100.0% (596 of 596 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 37c18c75..7e85819c 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -651,4 +651,6 @@ <string name="quality_profile">Kvaliteedi profiil</string> <string name="is_default">Vaikimisi</string> <string name="root_folder">Juurkaust</string> + <string name="popularity">Populaarsus</string> + <string name="production_status">Tootmise olek</string> </resources> From c6bbd0a2def493f0e8b25aadc67e855bd9ed0c7b Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Wed, 24 Jun 2026 09:23:37 +0000 Subject: [PATCH 123/475] Translated using Weblate (Italian) Currently translated at 100.0% (596 of 596 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e5e5ae22..3e596190 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -672,4 +672,6 @@ <string name="quality_profile">Profilo di qualità</string> <string name="is_default">Predefinito</string> <string name="root_folder">Cartella principale</string> + <string name="popularity">Popolarità</string> + <string name="production_status">Stato di produzione</string> </resources> From ac45a7b64a1c399b217d915f1f487f648a8a9877 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Wed, 24 Jun 2026 11:08:45 +0000 Subject: [PATCH 124/475] Translated using Weblate (Turkish) Currently translated at 100.0% (596 of 596 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 f7ef0f11..6b70b147 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -651,4 +651,6 @@ <string name="quality_profile">Kalite Profili</string> <string name="is_default">Varsayılan</string> <string name="root_folder">Kök Klasör</string> + <string name="popularity">Popülerlik</string> + <string name="production_status">Yapım Durumu</string> </resources> From 56455344ed08a110bd9512ba671f163f8ed70e99 Mon Sep 17 00:00:00 2001 From: JorgeLNJunior <jorgelnjunior@noreply.codeberg.org> Date: Wed, 24 Jun 2026 23:22:33 +0000 Subject: [PATCH 125/475] Translated using Weblate (Portuguese (Brazil)) Currently translated at 93.7% (559 of 596 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 26 +++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5cde8a4f..ced62615 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -150,9 +150,6 @@ <string name="switch_user">Trocar</string> <string name="top_unwatched">Não assistidos com melhor avaliação</string> <plurals name="trailers"> - <item quantity="one">Trailer</item> - <item quantity="many">Trailers</item> - <item quantity="other">Trailers</item> </plurals> <string name="tv_dvr_schedule">Agenda do DVR</string> <string name="tv_guide">Guia</string> @@ -627,4 +624,27 @@ <string name="skip_behavior_summary">Para introduções, créditos, e etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificação</string> + <string name="movies_title">Filmes</string> + <string name="people_title">Pessoas</string> + <string name="trailers_title">Trailers</string> + <string name="tv_shows_title">Programas de TV</string> + <string name="other_extras_title">Outro</string> + <string name="behind_the_scenes_title">Nos Bastidores</string> + <string name="theme_songs_title">Músicas Tema</string> + <string name="deleted_scenes_title">Cenas Deletadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Cenas</string> + <string name="samples_title">Amostras</string> + <string name="combined_search_results">Resultados de pesquisa combinados</string> + <string name="combined_search_results_on">Mostrar todos os resultados em lista única</string> + <string name="combined_search_results_off">Mostrar resultados por categoria</string> + <string name="show_voice_search_button">Mostrar botão de pesquisa por voz</string> + <string name="visible_ui">Visível</string> + <string name="hidden_ui">Oculto</string> + <string name="results">Resultados</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_grid">Grade</string> + <string name="view_options_type_list_compact">Lista Compacta</string> + <string name="theme_videos_title">Vídeos Tema</string> + <string name="clips_title">Clipes</string> </resources> From 94a568ae95affe137c1c0a18b06d1f8f4f561897 Mon Sep 17 00:00:00 2001 From: Codeberg Translate <translate@codeberg.org> Date: Wed, 24 Jun 2026 23:26:10 +0000 Subject: [PATCH 126/475] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-pt-rBR/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ced62615..2f2047af 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -149,8 +149,6 @@ <string name="switch_servers">Trocar de servidores</string> <string name="switch_user">Trocar</string> <string name="top_unwatched">Não assistidos com melhor avaliação</string> - <plurals name="trailers"> - </plurals> <string name="tv_dvr_schedule">Agenda do DVR</string> <string name="tv_guide">Guia</string> <string name="tv_season">Temporada</string> From e69afe1848b6cfb59a54ab3abfddaa29ba52e8bb Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Thu, 25 Jun 2026 13:27:43 +0000 Subject: [PATCH 127/475] Translated using Weblate (German) Currently translated at 100.0% (597 of 597 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 772a2084..1e9f3018 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -646,4 +646,12 @@ <item quantity="one">Darsteller</item> <item quantity="other">Darsteller</item> </plurals> + <string name="genre_movies">%1$s Filme</string> + <string name="genre_tv_shows">%1$s Serien</string> + <string name="quality_profile">Qualitätsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Wurzelordner</string> + <string name="popularity">Beliebtheit</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Für HDR anderen Stiel verwenden</string> </resources> From 44d7880468bcbebc43c011dd69726392d494aa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Thu, 25 Jun 2026 12:15:21 +0000 Subject: [PATCH 128/475] Translated using Weblate (Estonian) Currently translated at 100.0% (597 of 597 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7e85819c..fa528b24 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -653,4 +653,5 @@ <string name="root_folder">Juurkaust</string> <string name="popularity">Populaarsus</string> <string name="production_status">Tootmise olek</string> + <string name="different_for_hdr">Kasuta HDR-i puhul muud stiili</string> </resources> From 4f9abd0edc29924437d1c3ff6eed0131a976cb09 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Thu, 25 Jun 2026 07:42:03 +0000 Subject: [PATCH 129/475] Translated using Weblate (Indonesian) Currently translated at 98.1% (586 of 597 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 31 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ae0e29de..e8a2a4cf 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -156,19 +156,19 @@ <item quantity="other">Lainnya</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="other">Di Balik Layar</item> + <item quantity="other">Di balik layar</item> </plurals> <plurals name="theme_songs"> - <item quantity="other">Lagu Tema</item> + <item quantity="other">Lagu tema</item> </plurals> <plurals name="theme_videos"> - <item quantity="other">Video Tema</item> + <item quantity="other">Video tema</item> </plurals> <plurals name="clips"> <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="other">Adegan Dihapus</item> + <item quantity="other">Adegan dihapus</item> </plurals> <plurals name="interviews"> <item quantity="other">Wawancara</item> @@ -183,7 +183,7 @@ <item quantity="other">Featurette</item> </plurals> <plurals name="shorts"> - <item quantity="other">Klip Pendek</item> + <item quantity="other">Klip pendek</item> </plurals> <plurals name="hours"> <item quantity="other">%d jam</item> @@ -602,4 +602,25 @@ <string name="continue_watching_click_summary_off">Tampilkan detail saat diklik</string> <string name="stars">Bintang</string> <string name="tomatoes">Tomatoes</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Lainnya</string> + <string name="behind_the_scenes_title">Di Balik Layar</string> + <string name="theme_songs_title">Lagu Tema</string> + <string name="theme_videos_title">Video Tema</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Adegan Dihapus</string> + <plurals name="movies"> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">Acara TV</item> + </plurals> + <string name="interviews_title">Wawancara</string> + <string name="scenes_title">Adegan</string> + <string name="samples_title">Sampel</string> + <string name="featurettes_title">Featurette</string> + <string name="shorts_title">Shorts</string> + <string name="sort">Urutkan</string> + <string name="split_into_separate_rows">Pisahkan menjadi baris terpisah</string> + <string name="genre_movies">%1$s Film</string> </resources> From 818692a40542d215b33c140059e355903295e8dd Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 26 Jun 2026 02:41:56 +0000 Subject: [PATCH 130/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (597 of 597 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 d25bebbd..6fa099f3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -630,4 +630,7 @@ <string name="quality_profile">品質設定</string> <string name="is_default">預設</string> <string name="root_folder">根目錄</string> + <string name="popularity">熱門程度</string> + <string name="production_status">製作狀態</string> + <string name="different_for_hdr">HDR 使用不同的樣式</string> </resources> From b2f1cf145560530617fa54facb49e83a46415b8a Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 26 Jun 2026 02:43:59 +0000 Subject: [PATCH 131/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (597 of 597 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 327360ba..a4c56437 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -630,4 +630,7 @@ <string name="quality_profile">质量配置</string> <string name="is_default">默认</string> <string name="root_folder">根文件夹</string> + <string name="popularity">热度</string> + <string name="production_status">制作状态</string> + <string name="different_for_hdr">为 HDR 使用不同的样式</string> </resources> From c8cf9181167044a59e316a7c998b1066475b2993 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Thu, 25 Jun 2026 17:50:23 +0000 Subject: [PATCH 132/475] Translated using Weblate (Turkish) Currently translated at 100.0% (597 of 597 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 6b70b147..f79fa3e2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -653,4 +653,5 @@ <string name="root_folder">Kök Klasör</string> <string name="popularity">Popülerlik</string> <string name="production_status">Yapım Durumu</string> + <string name="different_for_hdr">HDR için farklı stil kullan</string> </resources> From b887507301e8cada1ce1dd2b243d50a24f156235 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Sat, 27 Jun 2026 16:35:43 +0000 Subject: [PATCH 133/475] Translated using Weblate (Polish) Currently translated at 100.0% (597 of 597 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0f978d04..8ba6da28 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -688,4 +688,12 @@ <item quantity="many">Serialów</item> <item quantity="other">Serialów</item> </plurals> + <string name="genre_movies">Filmy z gatunku: %1$s</string> + <string name="genre_tv_shows">Seriale z gatunku: %1$s</string> + <string name="quality_profile">Profil jakości</string> + <string name="is_default">Domyślny</string> + <string name="root_folder">Folder główny</string> + <string name="popularity">Popularność</string> + <string name="production_status">Status produkcji</string> + <string name="different_for_hdr">Użyj innych styli dla HDR</string> </resources> From d7d283c1882a837ac20a45bdf3cf0c7dc35ca789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Sun, 28 Jun 2026 11:55:33 +0000 Subject: [PATCH 134/475] Translated using Weblate (Estonian) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index fa528b24..4e8ff8a9 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -654,4 +654,8 @@ <string name="popularity">Populaarsus</string> <string name="production_status">Tootmise olek</string> <string name="different_for_hdr">Kasuta HDR-i puhul muud stiili</string> + <string name="experimental_settings">Katselised seadistused</string> + <string name="confirm_enable_experimental_title">Kas võtad katselised seadistused kasutusele?</string> + <string name="confirm_enable_experimental_body">Katselised seadistused ei pruugi olla päris töökindlad!\n\n Lisaks võib arendaja neid omal äranägemisel muuta või sootuks eemaldada.</string> + <string name="video_tunneling">Video tunneldus</string> </resources> From dfafed5801fc8488ac097060a5e4aae9857a33d4 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Sun, 28 Jun 2026 10:12:41 +0000 Subject: [PATCH 135/475] Translated using Weblate (Polish) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8ba6da28..67de8e68 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -696,4 +696,8 @@ <string name="popularity">Popularność</string> <string name="production_status">Status produkcji</string> <string name="different_for_hdr">Użyj innych styli dla HDR</string> + <string name="experimental_settings">Ustawienia eksperymentalne</string> + <string name="confirm_enable_experimental_title">Włączyć ustawienia eksperymentalne?</string> + <string name="confirm_enable_experimental_body">Eksperymentalne ustawienia mogą powodować problemy!\n\nMogą być usunięte bądź zmienione w kolejnym wydaniu.</string> + <string name="video_tunneling">Tunelowanie wideo</string> </resources> From a3de7c330af85b934e5d530a3101b784f642c7f3 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 29 Jun 2026 14:09:11 +0000 Subject: [PATCH 136/475] Translated using Weblate (French) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a97d9d32..3b0a9df4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -667,4 +667,16 @@ <item quantity="many">Acteurs</item> <item quantity="other">Acteur</item> </plurals> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Séries télévisées</string> + <string name="quality_profile">Profil de qualité</string> + <string name="is_default">Par défaut</string> + <string name="root_folder">Dossier racine</string> + <string name="popularity">Popularité</string> + <string name="production_status">État de la production</string> + <string name="different_for_hdr">Utiliser un style différent pour le HDR</string> + <string name="experimental_settings">Conditions expérimentales</string> + <string name="confirm_enable_experimental_title">Activer les paramètres expérimentaux ?</string> + <string name="confirm_enable_experimental_body">Les paramètres expérimentaux peuvent être instables !\n\nIls sont susceptibles d\'être supprimés ou modifiés dans n\'importe quelle version.</string> + <string name="video_tunneling">Tunneling vidéo</string> </resources> From 662ef66319a50cc56c76ad77efc1016ceddc52c5 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Mon, 29 Jun 2026 01:42:03 +0000 Subject: [PATCH 137/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6fa099f3..dea9d7f7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -633,4 +633,8 @@ <string name="popularity">熱門程度</string> <string name="production_status">製作狀態</string> <string name="different_for_hdr">HDR 使用不同的樣式</string> + <string name="video_tunneling">通道播放模式</string> + <string name="experimental_settings">實驗性設定</string> + <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> + <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> </resources> From 52e5aa41d27189ee60851f1da01c3dfa5ae111ab Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Mon, 29 Jun 2026 13:13:15 +0000 Subject: [PATCH 138/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a4c56437..c62b2c87 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -253,9 +253,9 @@ <string name="reset">重置</string> <string name="bold_font">粗体</string> <string name="player_backend">播放后端</string> - <string name="mpv_hardware_decoding">mpv:使用硬件解码</string> + <string name="mpv_hardware_decoding">MPV:使用硬件解码</string> <string name="disable_if_crash">如果遇到崩溃,请禁用</string> - <string name="mpv_options">mpv 选项</string> + <string name="mpv_options">MPV 选项</string> <string name="exoplayer_options">ExoPlayer 选项</string> <string name="skip_segment_unknown">跳过片段</string> <string name="skip_segment_commercial">跳过广告</string> @@ -270,7 +270,7 @@ <string name="remove">移除</string> <string name="dolby_vision">杜比视界</string> <string name="dolby_atmos">杜比全景声</string> - <string name="mpv_use_gpu_next">mpv:使用 gpu-next</string> + <string name="mpv_use_gpu_next">MPV:使用 gpu-next</string> <string name="mpv_conf">编辑 mpv.conf</string> <string name="discard_change">舍弃更改?</string> <string name="subtitle_margin">边距</string> @@ -389,7 +389,7 @@ <string name="voice_error_timeout">语音识别超时</string> <string name="retry">重试</string> <string name="software_decoding_av1">AV1 软件解码</string> - <string name="upgrade_mpv_toast">除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。</string> + <string name="upgrade_mpv_toast">除 HDR 视频外,MPV 现在是默认播放器。\n您可以在设置中更改此设置。</string> <string name="hdr_subtitle_style">HDR 字幕样式</string> <string name="image_subtitle_opacity">图像字幕不透明度</string> <string name="brightness">亮度</string> @@ -475,8 +475,8 @@ <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="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> @@ -633,4 +633,8 @@ <string name="popularity">热度</string> <string name="production_status">制作状态</string> <string name="different_for_hdr">为 HDR 使用不同的样式</string> + <string name="experimental_settings">实验性设置</string> + <string name="confirm_enable_experimental_title">启用实验性设置?</string> + <string name="confirm_enable_experimental_body">实验性设置可能不稳定!\n\n 这些设置可能会在任何版本中被移除或更改。</string> + <string name="video_tunneling">隧道播放模式</string> </resources> From f1a205a465d6b5249b74df9db5b17be48fbd9b00 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Mon, 29 Jun 2026 18:12:30 +0000 Subject: [PATCH 139/475] Translated using Weblate (Turkish) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f79fa3e2..4b549241 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -654,4 +654,8 @@ <string name="popularity">Popülerlik</string> <string name="production_status">Yapım Durumu</string> <string name="different_for_hdr">HDR için farklı stil kullan</string> + <string name="experimental_settings">Deneysel Ayarlar</string> + <string name="confirm_enable_experimental_title">Deneysel ayarları açmak istiyor musun?</string> + <string name="confirm_enable_experimental_body">Deneysel ayarlar kararsız çalışabilir! \n\nBu ayarlar sonraki sürümlerde kaldırılabilir veya değiştirilebilir.</string> + <string name="video_tunneling">Video tünelleme</string> </resources> From eea9a610643b28eaeae3165256eeb7f610457a80 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Wed, 1 Jul 2026 14:35:37 +0000 Subject: [PATCH 140/475] Translated using Weblate (Slovak) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 113ddbcc..abecae1d 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -693,4 +693,11 @@ <string name="quality_profile">Profil kvality</string> <string name="is_default">Predvolené</string> <string name="root_folder">Koreňový priečinok</string> + <string name="popularity">Popularita</string> + <string name="production_status">Stav produkcie</string> + <string name="different_for_hdr">Použiť iný štýl pre HDR</string> + <string name="experimental_settings">Experimentálne nastavenia</string> + <string name="confirm_enable_experimental_title">Povoliť experimentálne nastavenia?</string> + <string name="confirm_enable_experimental_body">Experimentálne nastavenia môžu byť nestabilné!\n\n Môžu byť zmenené alebo odstránené v budúcich verziách.</string> + <string name="video_tunneling">Video tunneling</string> </resources> From 63d4e8e80dc3949f97154d56401044185c0f8869 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Wed, 1 Jul 2026 03:55:45 +0000 Subject: [PATCH 141/475] Translated using Weblate (Indonesian) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index e8a2a4cf..5ac10a6c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -623,4 +623,18 @@ <string name="sort">Urutkan</string> <string name="split_into_separate_rows">Pisahkan menjadi baris terpisah</string> <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Acara TV</string> + <string name="quality_profile">Profil kualitas</string> + <string name="is_default">Default</string> + <string name="root_folder">Folder root</string> + <string name="popularity">Popularitas</string> + <string name="production_status">Status produksi</string> + <string name="different_for_hdr">Gunakan gaya berbeda untuk HDR</string> + <string name="experimental_settings">Pengaturan eksperimental</string> + <string name="confirm_enable_experimental_title">Aktifkan pengaturan eksperimental?</string> + <string name="confirm_enable_experimental_body">Pengaturan eksperimental mungkin tidak stabil!\n\nPengaturan ini dapat dihapus atau diubah pada rilisan mana pun.</string> + <string name="video_tunneling">Video tunneling</string> + <plurals name="people"> + <item quantity="other">Orang</item> + </plurals> </resources> From db4b4b84891f483564dbc5e6d4c0c9488db5f07d Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Tue, 30 Jun 2026 15:33:45 +0000 Subject: [PATCH 142/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 ++-- 1 file changed, 2 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 dea9d7f7..c567b200 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -31,7 +31,7 @@ <string name="recently_released">最近發行</string> <string name="recommended">推薦</string> <string name="record_program">錄製節目</string> - <string name="restart">重頭播放</string> + <string name="restart">從頭播放</string> <string name="save">儲存</string> <string name="search">搜尋</string> <string name="searching">搜尋中…</string> @@ -633,7 +633,7 @@ <string name="popularity">熱門程度</string> <string name="production_status">製作狀態</string> <string name="different_for_hdr">HDR 使用不同的樣式</string> - <string name="video_tunneling">通道播放模式</string> + <string name="video_tunneling">影片直通模式</string> <string name="experimental_settings">實驗性設定</string> <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> From 517b5b05074902c28ed96767301d199a0a979084 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Wed, 1 Jul 2026 10:12:53 +0000 Subject: [PATCH 143/475] Translated using Weblate (Italian) Currently translated at 100.0% (601 of 601 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 3e596190..503a6142 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -674,4 +674,9 @@ <string name="root_folder">Cartella principale</string> <string name="popularity">Popolarità</string> <string name="production_status">Stato di produzione</string> + <string name="different_for_hdr">Usa stile differente per HDR</string> + <string name="experimental_settings">Impostazioni sperimentali</string> + <string name="confirm_enable_experimental_title">Abilita impostazioni sperimentali?</string> + <string name="confirm_enable_experimental_body">Le impostazioni sperimentali possono essere instabili!\nPossono essere rimosse o cambiate in qualsiasi aggiornamento.</string> + <string name="video_tunneling">Tunneling video</string> </resources> From 422ac895750eccc92c024e3929cb380263615348 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Fri, 3 Jul 2026 16:05:12 +0000 Subject: [PATCH 144/475] Translated using Weblate (Polish) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 67de8e68..dc5676a8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -458,7 +458,7 @@ <string name="force_dovi_profile_7">Odtwarzanie bezpośrednie Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">Ignoruj sprawdzanie zgodności urządzenia</string> <string name="discover">Odkryj</string> - <string name="request">Prośby</string> + <string name="request">Poproś</string> <string name="pending">Oczekujące</string> <string name="seerr_integration">Integracja z Seerr</string> <string name="remove_seerr_server">Usuń serwer Seerr</string> From 30ec17e50f3b5fb8bdc376c4ae8cd196e42de2a7 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sun, 5 Jul 2026 09:43:59 +0000 Subject: [PATCH 145/475] Translated using Weblate (German) Currently translated at 99.8% (600 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1e9f3018..7869fd1a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -654,4 +654,7 @@ <string name="popularity">Beliebtheit</string> <string name="production_status">Produktionsstatus</string> <string name="different_for_hdr">Für HDR anderen Stiel verwenden</string> + <string name="experimental_settings">Experimentelle Einstellungen</string> + <string name="confirm_enable_experimental_title">Experimentelle Einstellungen aktivieren?</string> + <string name="confirm_enable_experimental_body">Die experimentellen Einstellungen sind instabil!\n\n Diese könnten mit jedem Update verändert oder entfernt werden.</string> </resources> From 182c258c036276d19659e920775d2343506964c0 Mon Sep 17 00:00:00 2001 From: Waspoedertje <waspoedertje@noreply.codeberg.org> Date: Sun, 5 Jul 2026 01:06:50 +0000 Subject: [PATCH 146/475] Translated using Weblate (Dutch) Currently translated at 100.0% (601 of 601 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 143 +++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 86fb18e9..44518453 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -155,36 +155,47 @@ <string name="extras_title">Extra\'s</string> <plurals name="other_extras"> <item quantity="one">Overig</item> + <item quantity="other">Overig</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Achter de schermen</item> + <item quantity="other">Achter de schermen</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Titelmuziek</item> + <item quantity="other">Titelmuziek</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Titelvideo\'s</item> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideo\'s</item> </plurals> <plurals name="clips"> - <item quantity="one">Clips</item> + <item quantity="one">Clip</item> + <item quantity="other">Clips</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Verwijderde scènes</item> + <item quantity="one">Verwijderde scène</item> + <item quantity="other">Verwijderde scènes</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviews</item> + <item quantity="one">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scènes</item> + <item quantity="one">Scène</item> + <item quantity="other">Scènes</item> </plurals> <plurals name="samples"> - <item quantity="one">Voorvertoningen</item> + <item quantity="one">Voorvertoning</item> + <item quantity="other">Voorvertoningen</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Shorts</item> + <item quantity="one">Short</item> + <item quantity="other">Shorts</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -212,7 +223,7 @@ <string name="check_for_updates">Controleren op updates</string> <string name="combine_continue_next_summary">Alleen van toepassing op tv-series</string> <string name="combine_continue_next"><![CDATA[Hervatten en Volgende combineren]]></string> - <string name="direct_play_ass">ASS-ondertiteling automatisch tonen</string> + <string name="direct_play_ass">ASS-ondertiteling automatischh tonen</string> <string name="direct_play_pgs">PGS-ondertiteling automatisch tonen</string> <string name="downmix_stereo">Geluid altijd omzetten naar stereo</string> <string name="ffmpeg_extension_pref">FFmpeg-decodering gebruiken</string> @@ -533,4 +544,118 @@ <string name="ass_subtitle_playback">SSA/ASS ondertiteling tonen</string> <string name="ass_subtitle_mode_libass">Direct play met libass</string> <string name="play_with">Afspelen met</string> + <string name="other_extras_title">Overig</string> + <string name="behind_the_scenes_title">Achter de schermen</string> + <string name="theme_songs_title">Themaliedjes</string> + <string name="theme_videos_title">Themavideo’s</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Verwijderde scènes</string> + <string name="samples_title">Voorbeelden</string> + <string name="featurettes_title">Korte documentaire</string> + <string name="shorts_title">Korte films</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Films</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Series</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persoon</item> + <item quantity="other">Personen</item> + </plurals> + <string name="require_login">Aanmelding vereisen voor dit profiel</string> + <string name="combined_search_results">Gecombineerde zoekresultaten</string> + <string name="combined_search_results_on">Alle resultaten in één lijst weergeven</string> + <string name="combined_search_results_off">Resultaten per categorie weergeven</string> + <string name="show_voice_search_button">Knop voor gesproken zoekopdrachten weergeven</string> + <string name="visible_ui">Zichtbaar</string> + <string name="hidden_ui">Verborgen</string> + <string name="results">Resultaten</string> + <string name="force_dovi_profile_7">Direct afspelen Dolby Vision Profile 7</string> + <string name="quick_connect">Snel verbinden</string> + <string name="trending">Populair</string> + <string name="software_decoding_av1">AV1-softwaredecodering</string> + <string name="contrast">Contrast</string> + <string name="hue">Tint</string> + <string name="red">Rood</string> + <string name="green">Groen</string> + <string name="blue">Blauw</string> + <string name="blur">Vervaging</string> + <string name="go_to_artist">Ga naar artiest</string> + <string name="go_to_album">Ga naar album</string> + <string name="recently_released_in">Recent uitgebracht in %1$s</string> + <string name="height">Hoogte</string> + <string name="apply_all_rows">Op alle rijen toepassen</string> + <string name="load_from_server">Gebruikersprofiel van de server laden</string> + <string name="save_to_server">Gebruikersprofiel op de server opslaan</string> + <string name="load_from_web_client">Laad van de web client</string> + <string name="use_series">Afbeelding van de serie gebruiken</string> + <string name="add_row_for">Voeg rij toe voor %1$s</string> + <string name="overwrite_server_settings">Instellingen op de server overschrijven?</string> + <string name="overwrite_local_settings">Lokale Instellingen overschrijven?</string> + <string name="next_up_never">Nooit</string> + <string name="trailers_title">Trailers</string> + <string name="confirm_enable_experimental_body">Experimentele instellingen kunnen instabiel zijn!\n\nDeze instellingen kunnen in elke nieuwe versie worden gewijzigd of verwijderd.</string> + <string name="confirm_enable_experimental_title">Experimentele instellingen inschakelen?</string> + <string name="experimental_settings">Experimentele instellingen</string> + <string name="search_and_download_subtitles"><![CDATA[Zoek & download ondertiteling]]></string> + <string name="updated_toast">Wholphin is geüpdatet naar %s</string> + <string name="last_played">Laatst afgespeeld</string> + <string name="ass_subtitle_mode_exoplayer">Direct afspelen met de ingebouwde ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Ondertitels inbranden/transcoderen op de server</string> + <string name="cinema_mode">Bioscoop modus</string> + <string name="show_more">Laat %1$s meer zien</string> + <string name="transcoding">Transcoderen</string> + <string name="user_interface_language">Taal van de gebruikersinterface</string> + <string name="display_toggles_title">Beoordelingsweergave</string> + <string name="display_toggles_summary">Pas aan wat wordt weergegeven</string> + <string name="profile_protection">Profiel beveiliging</string> + <string name="continue_watching_click_behavior">Klikgedrag voor ‘Verder kijken’ en ‘Volgende aflevering’</string> + <string name="continue_watching_click_summary_on">Afspelen op klik</string> + <string name="continue_watching_click_summary_off">Laat details zien op klik</string> + <string name="stars">Sterren</string> + <string name="sort">Sorteren</string> + <string name="split_into_separate_rows">In afzonderlijke rijen weergeven</string> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Kwaliteitsprofiel</string> + <string name="root_folder">Root map</string> + <string name="popularity">Populariteit</string> + <string name="production_status">Productie status</string> + <string name="different_for_hdr">Gebruik verschillende stylen voor HDR</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> + <string name="dts">DTS</string> + <string name="url">URL</string> + <string name="albums">Albums</string> + <string name="instant_mix">Instant mix</string> + <string name="genres_in">Genres in %1$s</string> + <string name="magenta">Magenta</string> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> + <string name="external_player">Externe speler</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="view_options_type_grid">Raster</string> + <string name="view_options_type_list">Lijst</string> + <string name="view_options_type_list_compact">Compacte lijst</string> + <string name="enter_url_api_key"><![CDATA[Vul URL & API sleutel in]]></string> + <string name="api_key">API Sleutel</string> + <string name="mixer">Mixer</string> + <string name="album">Album</string> + <string name="studios_in">Studios in %1$s</string> + <string name="system_default">Systeemstandaard</string> + <string name="tomatoes">Tomatoes</string> + <string name="is_default">Standaard</string> + <string name="video_tunneling">Videotunneling</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scenes</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="dts_x">DTS:X</string> </resources> From b4c6d40edcded0e95287cfe207b18a85565a11b4 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Tue, 7 Jul 2026 03:19:38 +0000 Subject: [PATCH 147/475] Translated using Weblate (German) Currently translated at 100.0% (608 of 608 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7869fd1a..1e51b8c5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -657,4 +657,12 @@ <string name="experimental_settings">Experimentelle Einstellungen</string> <string name="confirm_enable_experimental_title">Experimentelle Einstellungen aktivieren?</string> <string name="confirm_enable_experimental_body">Die experimentellen Einstellungen sind instabil!\n\n Diese könnten mit jedem Update verändert oder entfernt werden.</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s verbleibend</string> + <string name="video_tunneling">Video Tunneling</string> </resources> From b57a6d7b0008568a0d8d922aad1c950e160906b1 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Mon, 6 Jul 2026 14:12:53 +0000 Subject: [PATCH 148/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (608 of 608 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c567b200..0f378ae5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -637,4 +637,11 @@ <string name="experimental_settings">實驗性設定</string> <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> + <string name="duration_hours_minutes_seconds">%1$d小時 %2$d分 %3$d秒</string> + <string name="duration_hours_minutes">%1$d小時 %2$d分</string> + <string name="duration_minutes_seconds">%1$d分 %2$d秒</string> + <string name="duration_hours">%1$d小時</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">還剩 %1$s</string> </resources> From b93e12681e175e1b0e0bdb2976717b913cbcc257 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Mon, 6 Jul 2026 15:15:51 +0000 Subject: [PATCH 149/475] Translated using Weblate (Polish) Currently translated at 100.0% (608 of 608 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dc5676a8..3221c150 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -700,4 +700,11 @@ <string name="confirm_enable_experimental_title">Włączyć ustawienia eksperymentalne?</string> <string name="confirm_enable_experimental_body">Eksperymentalne ustawienia mogą powodować problemy!\n\nMogą być usunięte bądź zmienione w kolejnym wydaniu.</string> <string name="video_tunneling">Tunelowanie wideo</string> + <string name="time_left">Pozostało %1$s</string> + <string name="duration_minutes">%1$d min.</string> + <string name="duration_seconds">%1$d sek.</string> + <string name="duration_hours">%1$d godz.</string> + <string name="duration_hours_minutes_seconds">%1$d godz. %2$d min. %3$d sek.</string> + <string name="duration_hours_minutes">%1$d godz. %2$d min.</string> + <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> </resources> From ab47868b6c8a772d6d7623bd000b1026ef3684f2 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Tue, 7 Jul 2026 05:31:34 +0000 Subject: [PATCH 150/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (608 of 608 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c62b2c87..bcee8122 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -534,15 +534,15 @@ <string name="search_for">搜索 %s</string> <string name="select_all">全选</string> <string name="albums">专辑</string> - <string name="artists">音乐人</string> + <string name="artists">艺人</string> <string name="songs">歌曲</string> - <string name="go_to_artist">前往音乐人</string> + <string name="go_to_artist">前往艺人</string> <string name="now_playing">正在播放</string> <string name="instant_mix">即时混音</string> <string name="go_to_album">前往专辑</string> <string name="add_to_queue">添加到待播列表</string> <string name="remove_from_queue">从待播列表中移除</string> - <string name="album_artist">专辑音乐人</string> + <string name="album_artist">专辑艺人</string> <string name="album">专辑</string> <string name="popular_songs">热门歌曲</string> <string name="play_next">接下来播放</string> @@ -637,4 +637,11 @@ <string name="confirm_enable_experimental_title">启用实验性设置?</string> <string name="confirm_enable_experimental_body">实验性设置可能不稳定!\n\n 这些设置可能会在任何版本中被移除或更改。</string> <string name="video_tunneling">隧道播放模式</string> + <string name="duration_hours_minutes_seconds">%1$d小时%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小时%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> + <string name="duration_hours">%1$d小时</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">还剩%1$s</string> </resources> From e0143c0e26e4f91d1fdae66caa7e0dc428baea4f Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Mon, 6 Jul 2026 18:53:29 +0000 Subject: [PATCH 151/475] Translated using Weblate (Turkish) Currently translated at 100.0% (608 of 608 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4b549241..c7a300b2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -658,4 +658,11 @@ <string name="confirm_enable_experimental_title">Deneysel ayarları açmak istiyor musun?</string> <string name="confirm_enable_experimental_body">Deneysel ayarlar kararsız çalışabilir! \n\nBu ayarlar sonraki sürümlerde kaldırılabilir veya değiştirilebilir.</string> <string name="video_tunneling">Video tünelleme</string> + <string name="duration_hours_minutes_seconds">%1$dsa %2$ddk %3$dsn</string> + <string name="duration_hours_minutes">%1$dsa %2$ddk</string> + <string name="duration_minutes_seconds">%1$ddk %2$dsn</string> + <string name="duration_hours">%1$dsa</string> + <string name="duration_minutes">%1$ddk</string> + <string name="duration_seconds">%1$dsn</string> + <string name="time_left">%1$s kaldı</string> </resources> From f2287a85e894f6d130262bc07781231922785089 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 8 Jul 2026 11:22:42 -0400 Subject: [PATCH 152/475] Release v1.0.3 From d481aea85a07b1dabe2fe9b138869c6f3d0ed02b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 9 Jul 2026 13:15:42 -0400 Subject: [PATCH 153/475] Experimental option to transcode surround sound audio to AC3 (#947) ## Description Adds an experimental setting to prefer transcoding audio with more than 2 channels to AC3. Stereo audio does not transcode. This is basically a workaround for HDMI ARC (not eARC) or optical audio devices because instead of 2 uncompressed PCM channels, 6 AC3 channels (ie 5.1) is possible. An downside of enabling this is that embedded subtitles be burned in causing a video transcode as well. ### Related issues Fixes #255 Incorporates some code from #1410 ### Testing Just on the emulator so far to observe if the settings trigger the transcode or not ## Screenshots N/A ## AI or LLM usage None --------- Co-authored-by: darkflame91 <darkflame91@gmail.com> --- .../wholphin/preferences/AppPreference.kt | 23 ++++- .../preferences/ExperimentalPreference.kt | 26 ++++++ .../wholphin/services/DeviceProfileService.kt | 34 ++++---- .../wholphin/services/MediaReportService.kt | 13 ++- .../wholphin/services/MusicService.kt | 54 ++++++++++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 18 +++- .../ui/preferences/PreferencesContent.kt | 2 +- .../subtitle/SubtitlePreferencesContent.kt | 2 +- .../util/profile/DeviceProfileUtils.kt | 84 +++++++++++++++---- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 11 files changed, 205 insertions(+), 54 deletions(-) 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 f573c4a7..5784d69b 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 @@ -53,7 +53,10 @@ sealed interface AppPreference<Pref, T> { value: T?, ): String? = null - fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid + fun validate( + prefs: Pref, + value: T, + ): PreferenceValidation = PreferenceValidation.Valid companion object { val SkipForward = @@ -391,18 +394,25 @@ sealed interface AppPreference<Pref, T> { defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + ac3Supported = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + val DownMixStereo = AppSwitchPreference<AppPreferences>( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + downmixStereo = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -1311,11 +1321,16 @@ data class AppSwitchPreference<Pref>( override val defaultValue: Boolean, override val getter: (prefs: Pref) -> Boolean, override val setter: (prefs: Pref, value: Boolean) -> Pref, - val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, + val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, ) : AppPreference<Pref, Boolean> { + override fun validate( + prefs: Pref, + value: Boolean, + ): PreferenceValidation = validator.invoke(prefs, value) + override fun summary( context: Context, value: Boolean?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index 18e38f79..3950d626 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -4,6 +4,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation object ExperimentalPreference { val Enable = @@ -35,6 +36,30 @@ object ExperimentalPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val PreferAc3ForSurround = + AppSwitchPreference<AppPreferences>( + title = R.string.prefer_ac3_for_surround, + defaultValue = true, + getter = { it.experimentalPreferences.preferAc3Surround }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { + preferAc3Surround = value + } + }, + summary = R.string.prefer_ac3_for_surround_summary, + validator = { prefs, value -> + prefs.playbackPreferences.overrides.let { + if (value && !it.ac3Supported) { + PreferenceValidation.Invalid("AC3 support is not enabled") + } else if (value && it.downmixStereo) { + PreferenceValidation.Invalid("Always downmixing to stereo") + } else { + PreferenceValidation.Valid + } + } + }, + ) } val experimentalPreferences = @@ -45,6 +70,7 @@ val experimentalPreferences = preferences = listOf( ExperimentalPreference.VideoTunneling, + ExperimentalPreference.PreferAc3ForSurround, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index ce4ea7dc..a7dfe235 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode -import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -34,20 +36,17 @@ class DeviceProfileService private var deviceProfile: DeviceProfile? = null suspend fun getOrCreateDeviceProfile( - prefs: PlaybackPreferences, + appPrefs: AppPreferences, serverVersion: ServerVersion?, ): DeviceProfile = withContext(WholphinDispatchers.Default) { + val prefs = appPrefs.playbackPreferences mutex.withLock { val newConfig = DeviceProfileConfiguration( maxBitrate = prefs.maxBitrate.toInt(), - isAC3Enabled = prefs.overrides.ac3Supported, - downMixAudio = prefs.overrides.downmixStereo, - assPlaybackMode = prefs.overrides.assPlaybackMode, - pgsDirectPlay = prefs.overrides.directPlayPgs, - dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, - decodeAv1 = prefs.overrides.decodeAv1, + overrides = prefs.overrides, + experimental = appPrefs.experimentalPreferences, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -57,12 +56,13 @@ class DeviceProfileService createDeviceProfile( mediaTest = mediaCodecCapabilitiesTest, maxBitrate = newConfig.maxBitrate, - isAC3Enabled = newConfig.isAC3Enabled, - downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, - pgsDirectPlay = newConfig.pgsDirectPlay, - dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + isAC3Enabled = newConfig.overrides.ac3Supported, + downMixAudio = newConfig.overrides.downmixStereo, + assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, + pgsDirectPlay = newConfig.overrides.directPlayPgs, + dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, + preferAc3ForSurround = appPrefs.experimentalPreferences.preferAc3Surround, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -76,11 +76,7 @@ class DeviceProfileService */ data class DeviceProfileConfiguration( val maxBitrate: Int, - val isAC3Enabled: Boolean, - val downMixAudio: Boolean, - val assPlaybackMode: AssPlaybackMode, - val pgsDirectPlay: Boolean, - val dolbyVisionELDirectPlay: Boolean, - val decodeAv1: Boolean, + val overrides: PlaybackOverrides, + val experimental: ExperimentalPreferences, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt index eeae86d9..67ef91e8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -61,10 +61,10 @@ class MediaReportService .getItem(itemId = item.id) .content.mediaSources val sourcesJson = json.encodeToString(sources) - val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences + val appPreferences = userPreferencesService.getCurrent().appPreferences val serverVersion = serverRepository.currentServer?.serverVersion val deviceProfile = - deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) + deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) val deviceProfileJson = json.encodeToString(deviceProfile) val body = """ @@ -76,7 +76,14 @@ class MediaReportService model=${Build.MODEL} apiLevel=${Build.VERSION.SDK_INT} - playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")} + playbackPrefs=${ + appPreferences.playbackPreferences.toString().replace("\n", ", ") + .replace("\t", " ") + } + experimental=${ + appPreferences.experimentalPreferences.toString().replace("\n", ", ") + .replace("\t", " ") + } deviceProfile=$deviceProfileJson diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index c1aeb280..eea66307 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.seekBack @@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -44,6 +46,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage @@ -72,6 +75,7 @@ class MusicService private val playerFactory: PlayerFactory, private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow<MusicServiceState> = _state @@ -92,6 +96,11 @@ class MusicService private var activityTracker: TrackActivityPlaybackListener? = null private var websocketJob: Job? = null + private suspend fun preferAc3Surround() = + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences.preferAc3Surround + /** * Start music playback * @@ -195,10 +204,11 @@ class MusicService shuffled: Boolean, ) { Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) + val preferAc3Surround = preferAc3Surround() val mediaItems = items .filter { it.type == BaseItemKind.AUDIO } - .map(::convert) + .map { convert(it, preferAc3Surround) } withContext(WholphinDispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled @@ -215,7 +225,8 @@ class MusicService index: Int? = null, ) { if (item.type == BaseItemKind.AUDIO) { - val mediaItem = convert(item) + val preferAc3Surround = preferAc3Surround() + val mediaItem = convert(item, preferAc3Surround) withContext(WholphinDispatchers.Main) { if (index != null) { player.addMediaItem(index, mediaItem) @@ -242,6 +253,7 @@ class MusicService list: BlockingList<BaseItem?>, startIndex: Int, ) = loading { + val preferAc3Surround = preferAc3Surround() var remaining = startIndex list.indices .chunked(25) @@ -252,7 +264,7 @@ class MusicService list .getBlocking(it) ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let(::convert) + ?.let { convert(it, preferAc3Surround) } } else { Timber.v("Skipping $remaining") remaining-- @@ -268,12 +280,36 @@ class MusicService /** * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag */ - private fun convert(audio: BaseItem): MediaItem { + private fun convert( + audio: BaseItem, + preferAc3Surround: Boolean, + ): MediaItem { + val needsAc3Transcode = + preferAc3Surround && + audio.data.mediaSources + ?.firstOrNull() + ?.mediaStreams + ?.any { stream -> + stream.type == MediaStreamType.AUDIO && + stream.channels.gt(2) + stream.codec != Codec.Audio.AC3 + } == true val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = audio.id, - container = audioFormats, - ) + if (needsAc3Transcode) { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = listOf("mka"), + transcodingContainer = "mka", + maxAudioChannels = 6, + transcodingAudioChannels = 6, + audioCodec = Codec.Audio.AC3, + ) + } else { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = audioFormats, + ) + } Timber.i("url=%s", url) val imageUrl = audio.data.albumId?.let { albumId -> @@ -354,7 +390,7 @@ class MusicService * Play this item next after the current, ie add the item as the next index in the queue */ suspend fun playNext(song: BaseItem) { - val mediaItem = convert(song) + val mediaItem = convert(song, preferAc3Surround()) onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) if (player.mediaItemCount == 1) { 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 37d3e5ad..94529bd6 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 @@ -54,6 +54,7 @@ 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.formatBitrate +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -646,7 +647,7 @@ class PlaybackViewModel deviceProfile = if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, + preferences.appPreferences, serverRepository.currentServer?.serverVersion, ) } else { @@ -855,9 +856,24 @@ class PlaybackViewModel // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") + // TODO Better way to handle unsupported types in general is needed + // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 + if (preferences.appPreferences.experimentalPreferences.preferAc3Surround && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) { + // User wants to transcode audio into AC3 + return@withContext false + } + } + } + val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) + // TODO there's probably no reason why we can't add external subtitles? if (externalSubtitle == null) { val result = withContext(WholphinDispatchers.Main) { 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 b08e9508..050e7798 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 @@ -621,7 +621,7 @@ fun PreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt index 02f70d11..f130016f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -162,7 +162,7 @@ fun SubtitlePreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index a18d1805..d2efeb30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -71,6 +71,7 @@ fun createDeviceProfile( dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, + preferAc3ForSurround: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -132,31 +133,61 @@ fun createDeviceProfile( // / Transcoding profiles // Video - transcodingProfile { - type = DlnaProfileType.VIDEO - context = EncodingContext.STREAMING + if (preferAc3ForSurround) { + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - if (supportsHevc) videoCodec(Codec.Video.HEVC) - videoCodec(Codec.Video.H264) + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) - audioCodec(*allowedAudioCodecs) + audioCodec(Codec.Audio.AC3) - copyTimestamps = false - enableSubtitlesInManifest = true + copyTimestamps = false + enableSubtitlesInManifest = true + } + } else { + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING + + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) + + audioCodec(*allowedAudioCodecs) + + copyTimestamps = false + enableSubtitlesInManifest = true + } } // Audio - transcodingProfile { - type = DlnaProfileType.AUDIO - context = EncodingContext.STREAMING + if (preferAc3ForSurround) { + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.AAC) + audioCodec(Codec.Audio.AC3) + } + } else { + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING + + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -530,6 +561,27 @@ fun createDeviceProfile( } } + if (preferAc3ForSurround) { + supportedAudioCodecs + .filterNot { it == Codec.Audio.AC3 } + .forEach { audioCodec -> + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + codecProfile { + type = CodecType.AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + } + } + // Audio channel profile codecProfile { type = CodecType.VIDEO_AUDIO diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 2eb47cef..4b1f6c34 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -215,6 +215,7 @@ message MusicPreferences { message ExperimentalPreferences { bool enabled = 1; bool video_tunneling_enabled = 2; + bool prefer_ac3_surround = 3; } message AppPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13686543..54217145 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -551,6 +551,8 @@ <string name="display_presets">Display presets</string> <string name="display_presets_description">Built-in presets to quickly style all rows</string> <string name="customize_home_summary">Choose rows and images on the home page</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcode non-stereo audio to AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Use AC3 for surround sound audio</string> <string name="start_after">Start after</string> <string name="animate">Animate</string> <string name="max_age_rating">Max age rating</string> From 26124a534828bbcac6f4875ee8aeabed7ad3eef6 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 10 Jul 2026 17:49:47 -0400 Subject: [PATCH 154/475] Improve track selection logic (#1654) ## Description Updates the logic for selecting tracks which should fix several edge cases The new logic groups track types together and then applying the user's selection to the actual tracks in the file. For example, if the user choosing the audio track which the server reports as absolute `index=2` (eg the _third_ track of any type in the file) and audio `index=1` (eg the _second audio_ track in the file), then the track selection logic will use the _second audio_ track in the direct played file (after accounting for id sorting). Some other changes: - If direct playing and an unsupported track is selected, the app will now fall back to transcoding - If the correct audio/subtitle tracks are not found, the app will now fall back to transcoding instead of blocking any track change attempts ### TODO Not necessarily in this PR, but some things need further investigation: - Handling external audio tracks - Why does ExoPlayer order the tracks differently sometimes, see [example here](https://github.com/damontecres/Wholphin/issues/791#issuecomment-4836470813) where `0:3` is _after_ `0:4`, but the `MediaSourceInfo` lists them `0:3` then `0:4` ### Related issues Maybe fixes #791 Should fix https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 Related to the parent issue, #1475 Replaces #845 ### Testing Unit tests mostly, some basic tests on emulator Needs more "real world" testing #### Try it out? You can try out this build by following the [instructions here](https://github.com/damontecres/Wholphin/releases/tag/develop-track-selection). If you do, please report your results in this PR! ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackViewModel.kt | 449 +++++++++--------- .../ui/playback/TrackSelectionUtils.kt | 287 +++++------ .../damontecres/wholphin/util/TrackSupport.kt | 3 +- .../test/TestSelectionTrackExamples.kt | 191 +++++++- .../wholphin/test/TestTrackSelection.kt | 286 +++++++++-- .../damontecres/wholphin/test/TestTracks.kt | 269 ++++++++--- .../ui/playback/PlaybackViewModelTests.kt | 3 +- app/src/test/resources/embedded_subs.json | 9 +- app/src/test/resources/external_subs.json | 12 +- app/src/test/resources/no_embedded_subs.json | 3 +- 10 files changed, 1014 insertions(+), 498 deletions(-) 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 94529bd6..677200e2 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 @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService @@ -614,231 +615,257 @@ class PlaybackViewModel userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - ) = withContext(WholphinDispatchers.IO) { - val itemId = item.id + ): Unit = + withContext(WholphinDispatchers.IO) { + val itemId = item.id - val currentPlayback = state.value.currentPlayback - if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - currentItemPlayback = currentItemPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - userInitiated = userInitiated, - ) - if (wasSuccessful) return@withContext - } - - Timber.d( - "changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " + - "enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs", - ) - - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE - val response by - api.mediaInfoApi - .getPostedPlaybackInfo( - itemId, - PlaybackInfoDto( - startTimeTicks = null, - deviceProfile = - if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences, - serverRepository.currentServer?.serverVersion, - ) - } else { - mpvDeviceProfile - }, - maxAudioChannels = null, - audioStreamIndex = audioIndex, - subtitleStreamIndex = subtitleIndex, - mediaSourceId = currentItemPlayback.sourceId?.toServerString(), - alwaysBurnInSubtitleWhenTranscoding = false, - maxStreamingBitrate = maxBitrate.toInt(), - enableDirectPlay = enableDirectPlay, - enableDirectStream = enableDirectStream, - allowVideoStreamCopy = enableDirectStream, - allowAudioStreamCopy = enableDirectStream, - enableTranscoding = true, - autoOpenLiveStream = true, - ), - ) - if (response.errorCode != null) { - _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } - return@withContext - } - val source = response.mediaSources.firstOrNull() - source?.let { source -> - val mediaUrl = - if (source.supportsDirectPlay) { - if (source.isRemote && source.path.isNotNullOrBlank()) { - Timber.i("Playback is remote for source: %s", source.id) - source.path - } else { - api.videosApi.getVideoStreamUrl( - itemId = itemId, - mediaSourceId = source.id, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, - ) - } - } else if (source.supportsDirectStream) { - source.transcodingUrl?.let(api::createUrl) - } else { - source.transcodingUrl?.let(api::createUrl) - } - if (mediaUrl.isNullOrBlank()) { - _state.update { - it.copy( - loading = - LoadingState.Error( - "Unable to get media URL from the server. Do you have permission to view and/or transcode?", - ), + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null && + currentPlayback.item.id == item.id && + currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && + enableDirectPlay + ) { + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + userInitiated = userInitiated, ) - } + if (wasSuccessful) return@withContext + } + + Timber.i( + "changeStreams (%s): userInitiated=%s, audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + itemId, + userInitiated, + audioIndex, + subtitleIndex, + enableDirectPlay, + enableDirectStream, + positionMs, + ) + + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + val response by + api.mediaInfoApi + .getPostedPlaybackInfo( + itemId, + PlaybackInfoDto( + startTimeTicks = null, + deviceProfile = + if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { + deviceProfileService.getOrCreateDeviceProfile( + preferences.appPreferences, + serverRepository.currentServer?.serverVersion, + ) + } else { + mpvDeviceProfile + }, + maxAudioChannels = null, + audioStreamIndex = audioIndex, + subtitleStreamIndex = subtitleIndex, + mediaSourceId = currentItemPlayback.sourceId?.toServerString(), + alwaysBurnInSubtitleWhenTranscoding = false, + maxStreamingBitrate = maxBitrate.toInt(), + enableDirectPlay = enableDirectPlay, + enableDirectStream = enableDirectStream, + allowVideoStreamCopy = enableDirectStream, + allowAudioStreamCopy = enableDirectStream, + enableTranscoding = true, + autoOpenLiveStream = true, + ), + ) + if (response.errorCode != null) { + _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } return@withContext } - val transcodeType = - when { -// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY - source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - - source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - - source.supportsTranscoding -> PlayMethod.TRANSCODE - - else -> throw Exception("No supported playback method") - } - Timber.i("Playback decision for $itemId: $transcodeType") - - val externalSubtitleCount = source.externalSubtitlesCount - - val externalSubtitle = - source.findExternalSubtitle(subtitleIndex)?.let { - it.deliveryUrl?.let { deliveryUrl -> - var flags = 0 - if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) - if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) - MediaItem.SubtitleConfiguration - .Builder( - api.createUrl(deliveryUrl).toUri(), - ).setId("e:${it.index}") - .setMimeType(subtitleMimeTypes[it.codec]) - .setLanguage(it.language) - .setLabel(it.title) - .setSelectionFlags(flags) - .build() - } - } - - Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle") - - val mediaItem = - MediaItem - .Builder() - .setMediaId(itemId.toString()) - .setMediaMetadata( - item.toMediaMetadata( - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - useSeriesForPrimary = true, - ), - ), - ).setUri(mediaUrl.toUri()) - .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) - .apply { - when (source.container) { - Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) - Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + val source = response.mediaSources.firstOrNull() + source?.let { source -> + val mediaUrl = + if (source.supportsDirectPlay) { + if (source.isRemote && source.path.isNotNullOrBlank()) { + Timber.i("Playback is remote for source: %s", source.id) + source.path + } else { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) } - }.build() - - val playback = - CurrentPlayback( - item = item, - tracks = listOf(), - backend = currentPlayer.value!!.backend, - playMethod = transcodeType, - playSessionId = response.playSessionId, - liveStreamId = source.liveStreamId, - mediaSourceInfo = source, - ) - - preferences.appPreferences.playbackPreferences.let { prefs -> - source.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } - ?.let { stream -> - refreshRateService.changeRefreshRate( - stream = stream, - switchRefreshRate = prefs.refreshRateSwitching, - switchResolution = prefs.resolutionSwitching, + } else if (source.supportsDirectStream) { + source.transcodingUrl?.let(api::createUrl) + } else { + source.transcodingUrl?.let(api::createUrl) + } + if (mediaUrl.isNullOrBlank()) { + _state.update { + it.copy( + loading = + LoadingState.Error( + "Unable to get media URL from the server. Do you have permission to view and/or transcode?", + ), ) } - } - withContext(WholphinDispatchers.Main) { - // TODO, don't need to release & recreate when switching streams - this@PlaybackViewModel.activityListener?.let { - it.release() - player.removeListener(it) + return@withContext } + val transcodeType = + when { +// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY + source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - val playbackItemState = PlaybackItemState(playback, currentItemPlayback) - val activityListener = - TrackActivityPlaybackListener( - api = api, - player = player, - getState = { playbackItemState }, - ) - player.addListener(activityListener) - this@PlaybackViewModel.activityListener = activityListener + source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - _state.update { - it.copy( - loading = LoadingState.Success, - currentPlayback = playback, - ) - } - player.setMediaItem( - mediaItem, - positionMs, + source.supportsTranscoding -> PlayMethod.TRANSCODE + + else -> throw Exception("No supported playback method") + } + Timber.i("Playback decision for $itemId: $transcodeType") + + val externalSubtitleCount = source.externalSubtitlesCount + + val externalSubtitle = + source.findExternalSubtitle(subtitleIndex)?.let { + it.deliveryUrl?.let { deliveryUrl -> + var flags = 0 + if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) + if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) + MediaItem.SubtitleConfiguration + .Builder( + api.createUrl(deliveryUrl).toUri(), + ).setId("e:${it.index}") + .setMimeType(subtitleMimeTypes[it.codec]) + .setLanguage(it.language) + .setLabel(it.title) + .setSelectionFlags(flags) + .build() + } + } + + Timber.v( + "subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle", ) - if (audioIndex != null || subtitleIndex != null) { - val onTracksChangedListener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - Timber.v("onTracksChanged: $tracks") - if (tracks.groups.isNotEmpty()) { - val result = - TrackSelectionUtils.createTrackSelections( - player.trackSelectionParameters, - player.currentTracks, - currentPlayer.value!!.backend, - source.supportsDirectPlay, - audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, - subtitleIndex, - source, - ) - Timber.v("onTracksChanged: %s", result) - if (result.bothSelected) { - player.trackSelectionParameters = - result.trackSelectionParameters + + val mediaItem = + MediaItem + .Builder() + .setMediaId(itemId.toString()) + .setMediaMetadata( + item.toMediaMetadata( + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + useSeriesForPrimary = true, + ), + ), + ).setUri(mediaUrl.toUri()) + .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) + .apply { + when (source.container) { + Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) + Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + } + }.build() + + val playback = + CurrentPlayback( + item = item, + tracks = listOf(), + backend = currentPlayer.value!!.backend, + playMethod = transcodeType, + playSessionId = response.playSessionId, + liveStreamId = source.liveStreamId, + mediaSourceInfo = source, + ) + + preferences.appPreferences.playbackPreferences.let { prefs -> + source.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + refreshRateService.changeRefreshRate( + stream = stream, + switchRefreshRate = prefs.refreshRateSwitching, + switchResolution = prefs.resolutionSwitching, + ) + } + } + withContext(WholphinDispatchers.Main) { + // TODO, don't need to release & recreate when switching streams + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + + val playbackItemState = PlaybackItemState(playback, currentItemPlayback) + val activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + getState = { playbackItemState }, + ) + player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + + _state.update { + it.copy( + loading = LoadingState.Success, + currentPlayback = playback, + ) + } + player.setMediaItem( + mediaItem, + positionMs, + ) + if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) { + val onTracksChangedListener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + Timber.v("onTracksChanged: $tracks") + if (tracks.groups.isNotEmpty()) { + val result = + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, + audioIndex, + subtitleIndex, + source, + ) + Timber.v("onTracksChanged: %s", result) player.removeListener(this) + if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters + } else { + // Fall back to transcoding + Timber.w("Failed to select tracks, falling back to transcoding") + viewModelScope.launchIO { + changeStreams( + item = item, + currentItemPlayback = currentItemPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = positionMs, + userInitiated = userInitiated, + enableDirectPlay = false, + enableDirectStream = true, + ) + } + } + viewModelScope.launchIO { loadSubtitleDelay() } } - viewModelScope.launchIO { loadSubtitleDelay() } } } - } - player.addListener(onTracksChangedListener) + player.addListener(onTracksChangedListener) + } } } } - } /** * If direct playing, can try to switch tracks without playback restarting @@ -853,12 +880,11 @@ class PlaybackViewModel userInitiated: Boolean, ): Boolean = withContext(WholphinDispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") // TODO Better way to handle unsupported types in general is needed // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 - if (preferences.appPreferences.experimentalPreferences.preferAc3Surround && audioIndex != null) { + if (preferences.appPreferences.experimentalPreferences.enabled { preferAc3Surround } && audioIndex != null) { currentPlayback.mediaSourceInfo.mediaStreams .orEmpty() .firstOrNull { it.index == audioIndex } @@ -873,15 +899,12 @@ class PlaybackViewModel val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) - // TODO there's probably no reason why we can't add external subtitles? if (externalSubtitle == null) { val result = withContext(WholphinDispatchers.Main) { TrackSelectionUtils.createTrackSelections( onMain { player.trackSelectionParameters }, onMain { player.currentTracks }, - currentPlayer.value!!.backend, - true, audioIndex, subtitleIndex, source, @@ -916,7 +939,7 @@ class PlaybackViewModel it.copy( currentPlayback = (it.currentPlayback ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), + tracks = checkForSupport(onMain { player.currentTracks }), ), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index cfbbe6b7..c9a16e79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -7,13 +7,11 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import timber.log.Timber -import kotlin.math.max /** * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] @@ -23,201 +21,152 @@ object TrackSelectionUtils { fun createTrackSelections( trackSelectionParams: TrackSelectionParameters, tracks: Tracks, - playerBackend: PlayerBackend, - supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val embeddedSubtitleCount = source.embeddedSubtitleCount - val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = trackSelectionParams.buildUpon() - val groups = tracks.groups - val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null - if (subtitleIsExternal || supportsDirectPlay) { - val chosenTrack = - if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.any { it.endsWith("e:$subtitleIndex") } - } - } else { - val actualEmbeddedCount = - groups - .filter { group -> - group.type == C.TRACK_TYPE_TEXT && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.none { it.contains("e:") } - }.size - val indexToFind = - calculateIndexToFind( - subtitleIndex, - MediaStreamType.SUBTITLE, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - subtitleIsExternal, - actualEmbeddedCount, - source, - ) - Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track") - // subtitleIndex - externalSubtitleCount + 1 - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .filter { - if (subtitleIsExternal) { - group.getTrackFormat(0).id?.contains("e:") == true - } else { - group.getTrackFormat(0).id?.contains("e:") == false - } - }.map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) - } + val chosenTrack = + if (subtitleIsExternal) { + tracks.groups.firstOrNull { group -> + group.type == C.TRACK_TYPE_TEXT && group.isExternal } - - Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") - chosenTrack?.let { + } else { + val playerIndex = + getPlayerIndex(subtitleIndex, source, MediaStreamType.SUBTITLE) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_TEXT && + group.length >= 1 && + !group.isExternal + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + } + when { + chosenTrack != null && chosenTrack.isSupported -> { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - chosenTrack != null - } else { - false } } else { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - - true + TrackSelected.SELECTED } + val audioSelected = - if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { - val indexToFind = - calculateIndexToFind( - audioIndex, - MediaStreamType.AUDIO, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - false, - null, - source, - ) + if (audioIndex != null && audioIndex >= 0) { + val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO) val chosenTrack = - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_AUDIO && group.isSupported && - (0..<group.mediaTrackGroup.length) - .map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_AUDIO && group.length >= 1 + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + when { + chosenTrack != null && chosenTrack.isSupported -> { + paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .setOverrideForType( + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), + ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack") - chosenTrack?.let { - paramsBuilder - .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) - .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), - ) } - chosenTrack != null } else { - true + TrackSelected.SELECTED } return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) } - /** - * Maps the server provided index to the track index based on the [PlayerBackend] and other stream information - */ - private fun calculateIndexToFind( + private fun getPlayerIndex( serverIndex: Int, - type: MediaStreamType, - playerBackend: PlayerBackend, - embeddedSubtitleCount: Int, - externalSubtitleCount: Int, - subtitleIsExternal: Boolean, - actualEmbeddedCount: Int?, source: MediaSourceInfo, - ): Int = - when (playerBackend) { - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - serverIndex - externalSubtitleCount + 1 - } - - // TODO MPV could use literal indexes because they are stored in the track format ID - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - when (type) { - MediaStreamType.VIDEO -> { - serverIndex - externalSubtitleCount + 1 + type: MediaStreamType, + ): Int? { + val playerIndex = + source.mediaStreams + .orEmpty() + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE) { + it.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS } + } else { + it } + }.indexOfFirstOrNull { it.index == serverIndex } + return playerIndex + } - MediaStreamType.AUDIO -> { - val videoStreamsBeforeAudioCount = - source.mediaStreams - .orEmpty() - .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount - serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 - } - - MediaStreamType.SUBTITLE -> { - if (subtitleIsExternal) { - // Need to account for the actual embedded count because if the library - // disables embedded subtitles, they still exist in the direct played file, - // but not included in the MediaStreams list - serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 - } else { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount - serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 - } - } - - else -> { - throw UnsupportedOperationException("Cannot calculate index for $type") - } + val Tracks.Group.trackFormats: List<Format> + @OptIn(UnstableApi::class) + get() = + (0..<mediaTrackGroup.length) + .mapNotNull { + getTrackFormat(it) } + + private val Tracks.Group.isExternal: Boolean + get() = + trackFormats.any { + it.id?.contains(":e:") == true } - PlayerBackend.EXTERNAL_PLAYER -> { - throw IllegalStateException("Cannot calculate tracks external playback") - } - } -} - -val Format.idAsInt: Int? - @OptIn(UnstableApi::class) - get() = - id?.let { - if (it.contains(":")) { - it.split(":").last().toIntOrNull() + private fun Iterable<Tracks.Group>.sortedById(): List<Tracks.Group> = + mapNotNull { track -> + if (track.isExternal) { + // Should be filtered out before calling this though + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) } else { - it.toIntOrNull() + track.trackFormats[0] + .id + ?.split(":") + ?.map { it.toInt() } + ?.let { + track to it + } } - } + }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] }) + .map { it.first } +} /** * Returns the number of external subtitle streams there are @@ -270,8 +219,18 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = */ data class TrackSelectionResult( val trackSelectionParameters: TrackSelectionParameters, - val audioSelected: Boolean, - val subtitleSelected: Boolean, + val audio: TrackSelected, + val subtitle: TrackSelected, ) { - val bothSelected: Boolean = audioSelected && subtitleSelected + val audioSelected: Boolean get() = audio == TrackSelected.SELECTED + + val subtitleSelected: Boolean get() = subtitle == TrackSelected.SELECTED + + val bothSelected: Boolean get() = audioSelected && subtitleSelected +} + +enum class TrackSelected { + SELECTED, + NOT_FOUND, + UNSUPPORTED, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt index 86b0f47e..f3ac966d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt @@ -7,7 +7,6 @@ import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.ui.playback.idAsInt import timber.log.Timber import java.util.Locale @@ -159,7 +158,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> = val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id + " (${format.idAsInt})", + format.id, type, reason, it.isSelected, diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt index e1bf9fc8..564ab7a9 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import androidx.media3.common.C import androidx.media3.common.TrackSelectionParameters import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelected import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils import org.jellyfin.sdk.model.api.MediaStreamType @@ -36,11 +38,9 @@ class TestSelectionTrackExamples { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = testTacks.toTracks(), - playerBackend = playerBackend, - supportsDirectPlay = true, audioIndex = audioIndex, subtitleIndex = subtitleIndex, - source = testTacks.toMediaSourceInfo(), + source = builder.buildMediaSourceInfo(), ).also(onResult) } @@ -111,4 +111,189 @@ class TestSelectionTrackExamples { Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) } } + + @Test + fun `test builderVASASS`() { + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("2:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(2) + .addExternalSubtitle(1) + + runTest( + builder, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 5, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + runTest( + builder, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test unsupported tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_UNSUPPORTED_TYPE) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } + + @Test + fun `test exceeds capabilities tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_EXCEEDS_CAPABILITIES) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index 956ddcd4..41db76aa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -235,6 +235,155 @@ class TestTrackSelection { return Tracks(groups) } + private fun buildMissingIdTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (3..5).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (6..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) + } + + private fun buildDifferentOrderedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:2") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:4") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:3") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + (5..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:8") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) + } + @Test fun `test MPV embedded`() { val resource = javaClass.classLoader?.getResource("embedded_subs.json") @@ -250,8 +399,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -265,8 +412,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -280,8 +425,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -307,8 +450,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -322,8 +463,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -337,8 +476,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 6, source = source, @@ -352,8 +489,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -379,8 +514,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -394,8 +527,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -421,8 +552,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -448,8 +577,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -465,8 +592,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -500,8 +625,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -523,8 +646,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -551,8 +672,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 2, source = source, @@ -570,6 +689,113 @@ class TestTrackSelection { } } } + + @Test + fun `test ExoPlayer missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.EXO_PLAYER) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + Assert.assertTrue("0:1" in ids) + Assert.assertTrue("0:2" !in ids) + Assert.assertTrue("0:3" in ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.MPV) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + println(ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer different order`() { + val source = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle(1) + .buildMediaSourceInfo() + val tracks = buildDifferentOrderedTracks(PlayerBackend.EXO_PLAYER) + + Assert.assertEquals(9, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 1, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } } internal fun TrackSelectionParameters.getAudioOverride(): Format? { diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt index ba8d0d53..f9b1eab2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -10,6 +10,7 @@ import org.jellyfin.sdk.model.api.MediaSourceType import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.junit.Assert import org.junit.Test @@ -18,7 +19,7 @@ import org.junit.Test * * @see Builder */ -class TestTracks private constructor( +class TestTracks( val tracks: List<TestTrack> = emptyList(), ) { /** @@ -27,6 +28,7 @@ class TestTracks private constructor( fun toTracks(): Tracks { val groups = tracks + .sortedBy { it.external } .map { val mimeType = when (it.type) { @@ -35,55 +37,21 @@ class TestTracks private constructor( MediaStreamType.SUBTITLE -> "text/sample" else -> throw UnsupportedOperationException("${it.type}") } - Format - .Builder() - .setId(it.id) - .setSampleMimeType(mimeType) - .build() - }.map { TrackGroup(it) } - .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } - return Tracks(groups) - } + val format = + Format + .Builder() + .setId(it.id) + .setSampleMimeType(mimeType) + .build() + Tracks.Group( + TrackGroup(format), + false, + intArrayOf(it.support), + booleanArrayOf(false), + ) + } - fun toMediaSourceInfo(): MediaSourceInfo { - val streams = - tracks.mapIndexed { index, track -> - MediaStream( - index = index, - type = track.type, - isExternal = track.external, - codec = null, - language = null, - isInterlaced = false, - isDefault = false, - isForced = false, - isHearingImpaired = false, - isTextSubtitleStream = false, - supportsExternalStream = false, - ) - } - val source = - MediaSourceInfo( - mediaStreams = streams, - protocol = MediaProtocol.HTTP, - type = MediaSourceType.DEFAULT, - isRemote = false, - readAtNativeFramerate = false, - ignoreDts = false, - ignoreIndex = false, - genPtsInput = false, - supportsTranscoding = true, - supportsDirectStream = true, - supportsDirectPlay = true, - isInfiniteStream = false, - requiresOpening = false, - requiresClosing = false, - requiresLooping = false, - supportsProbing = false, - transcodingSubProtocol = MediaStreamProtocol.HLS, - hasSegments = false, - ) - return source + return Tracks(groups) } /** @@ -92,28 +60,24 @@ class TestTracks private constructor( class Builder { private val tracks = mutableListOf<TestTrackBuilder>() - fun addVideo(): Builder { - tracks.add(TestTrackBuilder(MediaStreamType.VIDEO)) - return this - } + fun addVideo(): Builder = addTrack(1, MediaStreamType.VIDEO, false, C.FORMAT_HANDLED) - fun addAudio(count: Int = 1): Builder { - repeat(count) { - tracks.add(TestTrackBuilder(MediaStreamType.AUDIO)) - } - return this - } + fun addAudio(count: Int = 1): Builder = addTrack(count, MediaStreamType.AUDIO, false, C.FORMAT_HANDLED) - fun addSubtitle(count: Int = 1): Builder { - repeat(count) { - tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, false)) - } - return this - } + fun addSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, false, C.FORMAT_HANDLED) - fun addExternalSubtitle(count: Int = 1): Builder { + fun addExternalSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, true, C.FORMAT_HANDLED) + + fun addEmpty(count: Int = 1): Builder = addTrack(count, null, false, C.FORMAT_UNSUPPORTED_TYPE) + + fun addTrack( + count: Int, + type: MediaStreamType?, + external: Boolean, + @C.FormatSupport support: Int, + ): Builder { repeat(count) { - tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, true)) + tracks.add(TestTrackBuilder(type, external, support)) } return this } @@ -129,7 +93,10 @@ class TestTracks private constructor( var externalSubCount = 0 val t = tracks - .mapIndexed { index, track -> + .mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } if (track.external) { externalSubCount++ TestTrack( @@ -137,14 +104,27 @@ class TestTracks private constructor( index, track.type, track.external, + track.support, ) } else if (hasExternal) { val idx = index + 1 - externalSubCount // If there's a sidecar file, the actual file - TestTrack("0:$idx", index, track.type, track.external) + TestTrack( + "0:$idx", + index, + track.type, + track.external, + track.support, + ) } else { val idx = index + 1 - TestTrack("$idx", index, track.type, track.external) + TestTrack( + "$idx", + index, + track.type, + track.external, + track.support, + ) } } addAll(t) @@ -161,20 +141,29 @@ class TestTracks private constructor( tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } val externalSubCount = tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } +// val firstNonExternal = tracks.indexOfFirst { it.type != MediaStreamType.SUBTITLE } + val firstExternal = + tracks.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.external } + val externalSubtitleAreFirst = externalSubCount > 0 && firstExternal == 0 var videoCount = 0 var audioCount = 0 var subtitleCount = 0 val testTracks = - tracks.mapIndexed { index, track -> + tracks.mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } val id = if (track.external) { // MPV places external subtitles last - val idx = embeddedCount + index + val idx = index + if (externalSubtitleAreFirst) embeddedCount else 0 subtitleCount++ - val count = embeddedSubtitleCount + subtitleCount + val count = + subtitleCount + if (externalSubtitleAreFirst) embeddedSubtitleCount else 0 "$idx:e:$count" } else { - val idx = index - externalSubCount + val idx = + if (externalSubtitleAreFirst) index - externalSubCount else index when (track.type) { MediaStreamType.AUDIO -> { audioCount++ @@ -188,7 +177,8 @@ class TestTracks private constructor( MediaStreamType.SUBTITLE -> { subtitleCount++ - val count = subtitleCount - externalSubCount + val count = + subtitleCount - (if (externalSubtitleAreFirst) externalSubCount else 0) "$idx:$count" } @@ -201,6 +191,55 @@ class TestTracks private constructor( } return TestTracks(testTracks) } + + fun buildMediaSourceInfo(): MediaSourceInfo { + val streams = + tracks + .filter { it.type != null } + .mapIndexed { index, track -> + MediaStream( + index = index, + type = track.type!!, + isExternal = track.external, + deliveryMethod = + if (track.type == MediaStreamType.SUBTITLE && track.external) { + SubtitleDeliveryMethod.EXTERNAL + } else { + SubtitleDeliveryMethod.EMBED + }, + codec = null, + language = null, + isInterlaced = false, + isDefault = false, + isForced = false, + isHearingImpaired = false, + isTextSubtitleStream = false, + supportsExternalStream = false, + ) + } + val source = + MediaSourceInfo( + mediaStreams = streams, + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = false, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = false, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) + return source + } } companion object { @@ -223,11 +262,13 @@ data class TestTrack( val index: Int, val type: MediaStreamType, val external: Boolean, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, ) class TestTrackBuilder( - val type: MediaStreamType, + val type: MediaStreamType?, val external: Boolean = false, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, ) fun assertIdType( @@ -473,4 +514,80 @@ class TestTracksTests { assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) } } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle() + + builder.buildForExoPlayer().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("0:2", tracks[1].id) + Assert.assertEquals("0:3", tracks[2].id) + Assert.assertEquals("0:4", tracks[3].id) + Assert.assertEquals("0:5", tracks[4].id) + Assert.assertEquals("0:6", tracks[5].id) + Assert.assertEquals("0:7", tracks[6].id) + Assert.assertEquals("0:8", tracks[7].id) + Assert.assertEquals("1:e:8", tracks[8].id) + } + + builder.buildForMpv().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("1:1", tracks[1].id) + Assert.assertEquals("2:2", tracks[2].id) + Assert.assertEquals("3:3", tracks[3].id) + Assert.assertEquals("4:1", tracks[4].id) + Assert.assertEquals("5:2", tracks[5].id) + Assert.assertEquals("6:3", tracks[6].id) + Assert.assertEquals("7:4", tracks[7].id) + Assert.assertEquals("8:e:5", tracks[8].id) + } + + builder.buildMediaSourceInfo().let { source -> + val index = + source.mediaStreams?.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.isExternal } + Assert.assertEquals(8, index) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + + builder.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(46, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + builder.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(46, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:3", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:1", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:2", MediaStreamType.SUBTITLE, mpv[5]) + assertIdType("6:3", MediaStreamType.SUBTITLE, mpv[6]) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index d2d824db..33e7973d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -165,8 +165,7 @@ class PlaybackViewModelTests { .addVideo() .addAudio() .addSubtitle() - .buildForExoPlayer() - .toMediaSourceInfo() + .buildMediaSourceInfo() private val playSessionId = "playsessionid12345" private val videoStreamUrl = "http://localhost:8096/video/stream" diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json index f24aeefc..4a3c7666 100644 --- a/app/src/test/resources/embedded_subs.json +++ b/app/src/test/resources/embedded_subs.json @@ -165,7 +165,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -193,7 +194,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -221,7 +223,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json index 9f6a4b05..1ef202d4 100644 --- a/app/src/test/resources/external_subs.json +++ b/app/src/test/resources/external_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -79,7 +80,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -107,7 +109,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "eac3", @@ -194,7 +197,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json index be4e3051..d746913e 100644 --- a/app/src/test/resources/no_embedded_subs.json +++ b/app/src/test/resources/no_embedded_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "hevc", From f74306a12ce2bccf83b556942bc6b46c0d1bf2fd Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 11 Jul 2026 13:21:41 -0400 Subject: [PATCH 155/475] Fix subtitle outline width not applying in release builds (#1687) ## Description Fix applying the subtitle outline width in ExoPlayer for release builds The outline width field isn't public, so reflection is used to bypass and modify it. Since proguard/r8 renames all of the classes and fields, this meant the field was no longer found. The fix is to have proguard preserve the necessary field names. ### Related issues Fixes #1681 ### Testing Emulator w/ release build ## Screenshots N/a ## AI or LLM usage None --- app/proguard-rules.pro | 11 +++++++++++ .../wholphin/util/Media3SubtitleOverride.kt | 3 +++ 2 files changed, 14 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d45ace7b..cddfb043 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,3 +11,14 @@ # TODO investigate using smaller scope -keep class com.google.common.cache.** { *; } + +# Media3SubtitleOverride uses reflection to access/modify these fields +-keep class androidx.media3.ui.SubtitleView { + private androidx.media3.ui.SubtitleView$Output output; +} +-keep class androidx.media3.ui.CanvasSubtitleOutput { + private final java.util.List painters; +} +-keep class androidx.media3.ui.SubtitlePainter { + private final float outlineWidth; +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt index c618f6e5..e02a525e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.util +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import timber.log.Timber import java.lang.reflect.Field @@ -7,6 +9,7 @@ import java.lang.reflect.Field /** * Utility class to override private fields in media3's [SubtitleView] */ +@OptIn(UnstableApi::class) class Media3SubtitleOverride( val outlineThicknessPx: Float, ) { From 077b7e45703a2b2e2f04d8e09d9d39ad8fd56d25 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 13 Jul 2026 01:30:54 -0400 Subject: [PATCH 156/475] Show duration & ends at time on next up card (#1690) ## Description Adds the duration & ends at time to the next up card ### Related issues Closes #1664 ### Testing <!-- Describe how this change was tested and on what device(s) --> ## Screenshots <img width="660" height="241" alt="image" src="https://github.com/user-attachments/assets/7551843e-4f53-43fc-af2a-3e503ba6d03c" /> ## AI or LLM usage None --- .../wholphin/ui/playback/NextUpEpisode.kt | 98 ++++++++++++++----- .../wholphin/ui/playback/PlaybackPage.kt | 2 + 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index f032c8b7..d93ffda6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,6 +19,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,7 +28,10 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -47,10 +49,17 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.Clock +import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration /** * Layout for showing the next up episode during playback @@ -64,6 +73,7 @@ fun NextUpEpisode( timeLeft: Duration?, modifier: Modifier = Modifier, aspectRatio: Float = AspectRatios.WIDE, + runtime: Duration? = null, ) { val interactionSource = remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } @@ -122,9 +132,36 @@ fun NextUpEpisode( text = description ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + runtime?.let { + val context = LocalContext.current + val resources = LocalResources.current + val now by LocalClock.current.now + val text = + remember(now, runtime) { + buildAnnotatedString { + append(resources.formatDuration(runtime)) + dot() + val endsAt = now.plus(runtime.toJavaDuration()) + val endTimeStr = formatTime(context, endsAt) + append(resources.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(top = 4.dp) + .align(Alignment.Start), + ) + } } } } @@ -189,34 +226,41 @@ fun NextUpCard( @Composable private fun NextUpEpisodePreview() { WholphinTheme(true) { + val theme = MaterialTheme.colorScheme val previewHandler = AsyncImagePreviewHandler { - ColorImage(Color.Blue.toArgb()) + ColorImage(theme.surfaceVariant.toArgb()) + } + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + ) { + CompositionLocalProvider( + LocalAsyncImagePreviewHandler provides previewHandler, + LocalClock provides Clock(), + ) { + NextUpEpisode( + title = "Episode Title", + description = + "This is the description of the episode. It might be long. " + + "It might be very, very long and overflow the available space. " + + "But it just keeps going and going.", + imageUrl = "", + onClick = {}, + aspectRatio = AspectRatios.WIDE, + timeLeft = 30.seconds, + runtime = 32.minutes + 20.seconds, + modifier = + Modifier + .padding(8.dp) + .fillMaxHeight(.4f) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) } - - CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) { - NextUpEpisode( - title = "Episode Title", - description = - "This is the description of the episode. It might be long. " + - "It might be very, very long and overflow the available space. " + - "But it just keeps going and going.", - imageUrl = "", - onClick = {}, - aspectRatio = AspectRatios.FOUR_THREE, - timeLeft = 30.seconds, - modifier = - Modifier - .padding(16.dp) - .height(200.dp) - .width(400.dp) -// .fillMaxWidth(.4f) -// .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), - ) } } } 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 6e64be5b..6d22cd10 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 @@ -94,6 +94,7 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -600,6 +601,7 @@ fun PlaybackPageContent( viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + runtime = it.data.runTimeTicks?.ticks, modifier = Modifier .padding(8.dp) From 3f60fa75505a4703c4b407239285ac6e45d16784 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 14 Jul 2026 14:17:31 -0400 Subject: [PATCH 157/475] Add option to override user profile settings for audio & subtitle languages (#1680) ## Description Adds settings under the "User profile settings" section which allows for specifying preferred languages for audio and subtitles and the preferred subtitle mode. By default (matching existing Wholphin behavior), the preferred languages and subtitle mode are derived from the user's profile settings provided by the server which can be set on the WebUI: User->Settings->Playback or Subtitles. However, this isn't well understood and Wholphin doesn't display these values anywhere, so many users were confused. The preferred languages and subtitle mode can now be configured from within Wholphin. Setting these will **not** update the server. The settings are **only** used locally. ### Related issues Related to #319 Closes #790 ### Testing Emulator manually changing some settings, added additional unit tests to ensure the overrides are respected when choosing tracks ## Screenshots ### Settings page <img width="1280" height="720" alt="settings_01 Large" src="https://github.com/user-attachments/assets/28cc2f6b-3a60-42a4-b268-48695086d566" /> ### Clicking into "More user profile settings" <img width="1280" height="720" alt="settings_02 Large" src="https://github.com/user-attachments/assets/6c3be3aa-c177-4d44-8060-b76d15fb10d7" /> ### Language choice dialog Can filter languages by typing in the search box at the top <img width="1280" height="720" alt="settings_03 Large" src="https://github.com/user-attachments/assets/c014d0ba-8d5b-4201-a6ee-4876b478148c" /> ## AI or LLM usage None --- .../35.json | 741 ++++++++++++++++++ .../damontecres/wholphin/MainActivity.kt | 13 +- .../damontecres/wholphin/MainContent.kt | 11 +- .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/JellyfinServerDao.kt | 7 + .../wholphin/data/ServerRepository.kt | 16 +- .../wholphin/data/model/JellyfinServer.kt | 18 +- .../wholphin/preferences/AppPreference.kt | 8 + .../wholphin/preferences/UserPreferences.kt | 2 + .../preferences/UserProfileSettings.kt | 70 ++ .../wholphin/services/StreamChoiceService.kt | 53 +- .../services/UserPreferencesService.kt | 18 +- .../damontecres/wholphin/ui/Extensions.kt | 12 + .../ui/detail/music/NowPlayingPage.kt | 1 + .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 5 + .../ui/preferences/PreferencesContent.kt | 1 - .../ui/preferences/PreferencesViewModel.kt | 19 +- .../user/FilterableLanguagePreference.kt | 145 ++++ .../ui/preferences/user/PreferredLanguage.kt | 55 ++ .../user/UserPreferencesViewModel.kt | 151 ++++ .../user/UserProfilePreferencesPage.kt | 321 ++++++++ .../wholphin/ui/util/StringProvider.kt | 10 + app/src/main/res/values/strings.xml | 24 + .../services/StreamChoiceServiceTest.kt | 90 ++- .../ui/playback/PlaybackViewModelTests.kt | 2 +- 26 files changed, 1751 insertions(+), 48 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json new file mode 100644 index 00000000..f82e6bdf --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json @@ -0,0 +1,741 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "ff9ade58d4ac39e36335ac0df1405a5f", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')" + ] + } +} 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 a5b6e100..229fd378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService @@ -93,6 +94,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var userPreferencesDataStore: DataStore<AppPreferences> + @Inject + lateinit var userPreferencesService: UserPreferencesService + @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -226,8 +230,8 @@ class MainActivity : AppCompatActivity() { .fillMaxSize() .background(Color.Black), ) { - val appPreferences by userPreferencesDataStore.data.collectAsState(null) - if (appPreferences == null) { + val userPreferences by userPreferencesService.flow.collectAsState(null) + if (userPreferences == null) { // Show loading page if it is taking a while to get app preferences var showLoading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -246,7 +250,8 @@ class MainActivity : AppCompatActivity() { } } } else { - appPreferences?.let { appPreferences -> + userPreferences?.let { userPreferences -> + val appPreferences = userPreferences.appPreferences CoilConfig( prefs = appPreferences, okHttpClient = okHttpClient, @@ -265,7 +270,7 @@ class MainActivity : AppCompatActivity() { MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, - appPreferences = appPreferences, + userPreferences = userPreferences, backdropService = backdropService, screensaverService = screensaverService, 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 714d80dc..78545930 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -27,7 +27,6 @@ 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 @@ -44,12 +43,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization fun MainContent( backStack: MutableList<SetupDestination>, navigationManager: NavigationManager, - appPreferences: AppPreferences, + userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, modifier: Modifier = Modifier, ) { - val preferences by rememberUpdatedState(UserPreferences(appPreferences)) + val preferences by rememberUpdatedState(userPreferences) Surface( modifier = modifier @@ -59,7 +58,7 @@ fun MainContent( // val backStack = rememberNavBackStack(SetupDestination.Loading) // setupNavigationManager.backStack = backStack val interfaceCustomization = - remember(appPreferences) { InterfaceCustomization(appPreferences) } + remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { NavDisplay( backStack = backStack, @@ -105,7 +104,7 @@ fun MainContent( mutableStateOf(true) } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!appPreferences.signInAutomatically) { + if (!userPreferences.appPreferences.signInAutomatically) { showContent = false } } @@ -140,7 +139,7 @@ fun MainContent( screenSaverState.show, Modifier.fillMaxSize(), ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) + AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 43676509..6a691723 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -49,7 +49,7 @@ import java.util.UUID SeerrUser::class, RememberedTab::class, ], - version = 34, + version = 35, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -67,6 +67,7 @@ import java.util.UUID AutoMigration(31, 32), AutoMigration(32, 33), AutoMigration(33, 34), + AutoMigration(34, 35), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 3c2250fb..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.data.model.JellyfinUser +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -56,6 +57,12 @@ interface JellyfinServerDao { userId: UUID, ): JellyfinUser? + @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") + fun getUserFlow( + serverId: UUID, + userId: UUID, + ): Flow<JellyfinUser?> + @Query("DELETE FROM servers WHERE id = :serverId") fun deleteServer(serverId: UUID) 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 5d94b008..e6d9c468 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 @@ -12,9 +12,12 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -57,7 +60,18 @@ class ServerRepository val currentServer: JellyfinServer? get() = _current.value?.server val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } val currentUser: JellyfinUser? get() = _current.value?.user - val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user } + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserFlow: Flow<JellyfinUser?> + get() = + _current + .flatMapLatest { user -> + if (user?.user != null) { + serverDao.getUserFlow(user.user.serverId, user.user.id) + } else { + flow { emit(null) } + } + } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index a357de4a..44c19b4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -11,6 +11,8 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -65,11 +67,15 @@ data class JellyfinUser( val requireLogin: Boolean = false, val lastUsed: ZonedDateTime? = null, val uiLanguage: String? = null, + @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + + "appPreferences=$appPreferences" } /** @@ -83,3 +89,13 @@ data class JellyfinServerUsers( ) val users: List<JellyfinUser>, ) + +@Serializable +data class JellyfinUserPreferences( + @ColumnInfo(defaultValue = "") + val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "") + val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "USE_USER_PROFILE") + val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, +) 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 5784d69b..12e39399 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 @@ -582,6 +582,13 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) + val UserProfileSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.override_user_profile_settings, + destination = Destination.UserAppPreferences, + summary = R.string.override_user_profile_settings_summary, + ) + val SkipIntros = AppChoicePreference<AppPreferences, SkipSegmentBehavior>( title = R.string.skip_intro_behavior, @@ -1116,6 +1123,7 @@ val basicPreferences = AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, AppPreference.UserInterfaceLanguage, + AppPreference.UserProfileSettings, ), ), PreferenceGroup( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 35da376a..c90cd00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.preferences +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.UserConfiguration @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, + val userPreferences: JellyfinUserPreferences?, ) val DefaultUserConfiguration = diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt new file mode 100644 index 00000000..31d0e98c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup + +/** + * Represents settings from the user's profile on the server that may be overridden within the app + */ +object UserProfileSettings { + /** + * Special value that means the preferred language should be taken from the user's profile on the server + */ + const val USE_USER_PROFILE = "" + + /** + * Special value that means the user has no preferred language + */ + const val PREFER_ANY_LANGUAGE = "_any-language" + + val PreferredAudioLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_audio_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val PreferredSubtitleLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_subtitle_language, + summary = null, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val SubtitleModePref = + AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>( + title = R.string.subtitle_mode, + defaultValue = SubtitleModePreference.USE_USER_PROFILE, + getter = { it.subtitleMode }, + setter = { prefs, value -> + prefs.copy(subtitleMode = value) + }, + displayValues = R.array.subtitle_mode_options, + indexToValue = { SubtitleModePreference.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val Preferences = + listOf( + PreferenceGroup( + title = R.string.profile_specific_settings_from_server, + preferences = + listOf( + PreferredAudioLang, + PreferredSubtitleLang, + SubtitleModePref, + ), + ), + ) +} + +enum class SubtitleModePreference { + USE_USER_PROFILE, + DEFAULT, + SMART, + ONLY_FORCED, + ALWAYS, + NONE, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index c0a9465e..1bd2986c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import org.jellyfin.sdk.model.api.BaseItemDto @@ -135,7 +137,17 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference + val audioLanguage = + if (seriesLang != null) { + seriesLang + } else { + val pref = prefs.userPreferences?.preferredAudioLanguage + when (pref) { + UserProfileSettings.USE_USER_PROFILE -> userConfig?.audioLanguagePreference + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + } + } if (audioLanguage.isNotNullOrBlank()) { val sorted = @@ -226,23 +238,26 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null - } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + } + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + if (seriesLang != null) { + seriesLang + } else { + val pref = prefs.userPreferences?.preferredSubtitleLanguage + when (pref) { + UserProfileSettings.USE_USER_PROFILE -> userConfig?.subtitleLanguagePreference + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + }?.takeIf { it.isNotNullOrBlank() } + } + if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { // Client-side manual override: User selected "Only Forced" in player menu - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } - val subtitleMode = when { playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { @@ -258,9 +273,17 @@ class StreamChoiceService else -> { // Fallback to the user's preference - userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT + } } - } + } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates .sortedWith( 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 0fe6be1e..03b8846f 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 @@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore 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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +20,10 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { - val flow = preferencesDataStore.data.map { UserPreferences(it) } - - suspend fun getCurrent(): UserPreferences = - serverRepository.currentUserDto!!.configuration.let { userConfig -> - val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - UserPreferences( - appPrefs, - ) + val flow: Flow<UserPreferences> = + preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user -> + UserPreferences(appPrefs, user?.appPreferences) } + + suspend fun getCurrent(): UserPreferences = flow.first() } 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 c3037d75..2097dd9e 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 @@ -448,3 +448,15 @@ fun <T> Flow<T>.collectLatestIn( * Easy way to combine two flows into a [Pair] */ fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) } + +/** + * Easy way to combine three flows into a [Triple] + */ +fun <T1, T2, T3> Flow<T1>.combineTriple( + flow: Flow<T2>, + flow2: Flow<T3>, +): Flow<Triple<T1, T2, T3>> = + combine(flow) { t1, t2 -> Pair(t1, t2) } + .combine(flow2) { (t1, t2), t3 -> + Triple(t1, t2, t3) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 5b571e4a..b61d6b89 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -104,6 +104,7 @@ fun NowPlayingPage( .collectAsState( UserPreferences( AppPreferences.getDefaultInstance(), + null, ), ).value.appPreferences val musicPrefs = preferences.musicPreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 96863e05..accfd693 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -53,6 +53,9 @@ sealed class Destination( val hdr: Boolean, ) : Destination(true) + @Serializable + data object UserAppPreferences : Destination(true) + @Serializable data object Search : Destination() 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 12f4f4d9..e0f89304 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 @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage +import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind @@ -128,6 +129,10 @@ fun DestinationContent( ) } + Destination.UserAppPreferences -> { + UserProfilePreferencesPage(modifier) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = 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 050e7798..d57de598 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 @@ -103,7 +103,6 @@ fun PreferencesContent( val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } val currentUser by viewModel.currentUser.collectAsState() - val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() 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 f0e396cb..b72849a0 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 @@ -57,13 +57,18 @@ class PreferencesViewModel private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, ) : ViewModel() { - val currentUser - get() = - serverRepository.currentUserFlow.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null, - ) + val currentUser = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) val seerrConnection = seerrServerRepository.connection diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt new file mode 100644 index 00000000..c68239b0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * A filterable list of language choices ([PreferredLanguageType]) + */ +@Composable +fun FilterableLanguagePreference( + @StringRes title: Int, + selectedOption: PreferredLanguageType, + options: List<PreferredLanguageType>, + onClickOption: (PreferredLanguageType) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + + var filteredOptions by remember { mutableStateOf(options) } + LaunchedEffect(query) { + delay(500.milliseconds) + if (query.isNotNullOrBlank()) { + withContext(WholphinDispatchers.Default) { + val q = query.lowercase() + filteredOptions = + options + .filter { + // Only show languages, not "built-in" types + it is PreferredLanguageType.Language && + (it.name.lowercase().contains(q) || it.iso.contains(q)) + }.sortedByDescending { + // User is likely typing the first letters of a language, so show those first + // IE, for q="en", show English before Armenian and Bengali + (it as PreferredLanguageType.Language).name.lowercase().startsWith(q) + } + } + } else { + filteredOptions = options + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val listState = rememberLazyListState() + val focusRequesters = remember { List(filteredOptions.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } + LazyColumn( + state = listState, + modifier = Modifier, + ) { + item { + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { focusRequesters.firstOrNull()?.tryRequestFocus() }, + modifier = + Modifier + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) + } + itemsIndexed(filteredOptions) { index, option -> + if (option is PreferredLanguageType.Divider) { + HorizontalDivider() + } else { + ListItem( + selected = false, + onClick = { + onClickOption.invoke(option) + }, + leadingContent = { + SelectedLeadingContent(option == selectedOption) + }, + headlineContent = { + val text = + when (option) { + PreferredLanguageType.AnyLanguage -> option.displayString.getString() + is PreferredLanguageType.Language -> option.displayString.getString() + is PreferredLanguageType.ServerProfile -> stringResource(R.string.use_user_profile) + } + Text(text) + }, + supportingContent = + when (option) { + PreferredLanguageType.AnyLanguage, + is PreferredLanguageType.Language, + -> { + null + } + + is PreferredLanguageType.ServerProfile -> { + option.name?.let { + { + Text(it) + } + } + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt new file mode 100644 index 00000000..dc8da479 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt @@ -0,0 +1,55 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.ConcatStringProvider +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +/** + * UI information for displaying a preferred language + */ +sealed interface PreferredLanguageType { + val displayString: StringProvider + + data class ServerProfile( + val name: String?, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = + ConcatStringProvider( + " - ", + buildList { + add(ResStringProvider(R.string.use_user_profile)) + if (name.isNotNullOrBlank()) { + add(StringStringProvider(name)) + } + }, + ) + } + + data object AnyLanguage : PreferredLanguageType { + override val displayString: StringProvider + get() = ResStringProvider(R.string.any_language) + } + + data class Language( + val iso: String, + val name: String, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = StringStringProvider(name) + } + + data object Divider : PreferredLanguageType { + override val displayString: StringProvider + get() = EmptyStringProvider + } +} + +data class PreferredLanguage( + val selected: PreferredLanguageType = PreferredLanguageType.ServerProfile(null), + val options: List<PreferredLanguageType> = emptyList(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt new file mode 100644 index 00000000..8b4dd64f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings +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.ui.combineTriple +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.model.api.CultureDto +import javax.inject.Inject + +@HiltViewModel +class UserPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore<AppPreferences>, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + val screensaverService: ScreensaverService, + private val serverRepository: ServerRepository, + private val serverDao: JellyfinServerDao, + ) : ViewModel() { + private val mutex = Mutex() + + val currentUser = serverRepository.currentUserFlow + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + private val serverLanguages = MutableStateFlow<List<CultureDto>>(emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val userAppPreferences: StateFlow<JellyfinUserPreferences> = + currentUser.mapNotNull { it?.appPreferences }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + JellyfinUserPreferences(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createLanguageFlow(isAudio: Boolean): StateFlow<PreferredLanguage> = + currentUser + .combineTriple(serverRepository.currentUserDtoFlow, serverLanguages) + .mapLatest { (user, userDto, serverLanguages) -> + if (user == null || userDto == null || serverLanguages.isEmpty() || user.id != userDto.id) { + return@mapLatest PreferredLanguage() + } + val languages = + serverLanguages.filter { it.threeLetterIsoLanguageName.isNotNullOrBlank() } + + // Language user has chosen from local app + val prefLang = user.appPreferences.let { if (isAudio) it.preferredAudioLanguage else it.preferredSubtitleLanguage } + + // Language user has chosen from server user profile + val userDisplayLang = + userDto.configuration + ?.let { if (isAudio) it.audioLanguagePreference else it.subtitleLanguagePreference } + .let { userLang -> + languages.firstOrNull { it.threeLetterIsoLanguageName == userLang }?.displayName + ?: userLang + } + val selected = + when (prefLang) { + UserProfileSettings.USE_USER_PROFILE -> { + PreferredLanguageType.ServerProfile(userDisplayLang) + } + + UserProfileSettings.PREFER_ANY_LANGUAGE -> { + PreferredLanguageType.AnyLanguage + } + + else -> { + languages + .firstOrNull { it.threeLetterIsoLanguageName == prefLang } + ?.let { + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ) + } + } + } ?: PreferredLanguageType.ServerProfile(userDisplayLang) + val options = + buildList { + add(PreferredLanguageType.ServerProfile(userDisplayLang)) + add(PreferredLanguageType.AnyLanguage) + add(PreferredLanguageType.Divider) + languages.forEach { + if (it.threeLetterIsoLanguageName.isNotNullOrBlank()) { + add( + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ), + ) + } + } + } + PreferredLanguage(selected, options) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + PreferredLanguage(), + ) + + val audioLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(true) + val subtitleLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(false) + + init { + viewModelScope.launchIO { + serverLanguages.value = api.localizationApi.getCultures().content + } + } + + fun updatePreferences(block: (JellyfinUserPreferences) -> JellyfinUserPreferences) { + viewModelScope.launchIO { + mutex.withLock { + val user = currentUser.first { it != null }!! + val newAppPrefs = block.invoke(user.appPreferences) + serverDao.updateUser(user.copy(appPreferences = newAppPrefs)) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt new file mode 100644 index 00000000..2cd74f1a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt @@ -0,0 +1,321 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +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 +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +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.saveable.rememberSaveable +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.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ChoicePreference +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import timber.log.Timber + +/** + * Page for showing settings derived from the user's profile on the server + * + * @see UserProfileSettings + */ +@Composable +fun UserProfilePreferencesPage(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + UserProfilePreferencesContent( + Modifier + .fillMaxWidth(.4f) + .fillMaxHeight() + .align(Alignment.TopEnd), + ) + } +} + +@Composable +fun UserProfilePreferencesContent( + modifier: Modifier = Modifier, + viewModel: UserPreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + val userDto by viewModel.currentUserDto.collectAsState() + val preferences by viewModel.userAppPreferences.collectAsState() + val audioLanguagePref by viewModel.audioLanguage.collectAsState() + val subtitleLanguagePref by viewModel.subtitleLanguage.collectAsState() + var showPreferredLanguageDialog by remember { mutableStateOf<Boolean?>(null) } + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + Text( + text = stringResource(R.string.profile_specific_settings), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier, + ) { + UserProfileSettings.Preferences.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference<JellyfinUserPreferences, Any> + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + + val modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) focusedIndex = Pair(groupIndex, prefIndex) + } + + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + UserProfileSettings.PreferredAudioLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = true }, + summary = audioLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.PreferredSubtitleLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = false }, + summary = subtitleLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.SubtitleModePref -> { + pref as AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference> + val value = preferences.subtitleMode + val values = stringArrayResource(pref.displayValues).toList() + val summary = + when (value) { + SubtitleModePreference.USE_USER_PROFILE -> { + val userMode = userDto?.configuration?.subtitleMode + if (userMode != null) { + "${values[0]} - ${stringResource(userMode.stringRes)}" + } else { + values[0] + } + } + + else -> { + values[value.ordinal] + } + } + val selectedIndex = + remember(value) { pref.valueToIndex.invoke(value) } + ChoicePreference( + title = stringResource(pref.title), + summary = summary, + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + viewModel.updatePreferences { + it.copy(subtitleMode = SubtitleModePreference.entries[index]) + } + }, + modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + val userMode = userDto?.configuration?.subtitleMode + if (index == 0 && userMode != null) { + { + Text(stringResource(userMode.stringRes)) + } + } else { + null + } + }, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + viewModel.updatePreferences { + pref.setter(preferences, newValue) + } + } + } + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + } + } + } + } + } + } + } + showPreferredLanguageDialog?.let { isAudio -> + BasicDialog( + onDismissRequest = { showPreferredLanguageDialog = null }, + elevation = 3.dp, + ) { + val lang = if (isAudio) audioLanguagePref else subtitleLanguagePref + FilterableLanguagePreference( + title = if (isAudio) R.string.preferred_audio_language else R.string.preferred_subtitle_language, + selectedOption = lang.selected, + options = lang.options, + onClickOption = { option -> + val value = + when (option) { + PreferredLanguageType.AnyLanguage -> UserProfileSettings.PREFER_ANY_LANGUAGE + is PreferredLanguageType.Language -> option.iso + is PreferredLanguageType.ServerProfile -> UserProfileSettings.USE_USER_PROFILE + PreferredLanguageType.Divider -> throw IllegalStateException("Cannot click on a divider") + } + Timber.v("Updating language pref to %s", option) + viewModel.updatePreferences { + if (isAudio) { + it.copy(preferredAudioLanguage = value) + } else { + it.copy(preferredSubtitleLanguage = value) + } + } + showPreferredLanguageDialog = null + }, + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) + } + } +} + +val SubtitlePlaybackMode.stringRes: Int + @StringRes get() = + when (this) { + SubtitlePlaybackMode.DEFAULT -> R.string.subtitle_mode_default + SubtitlePlaybackMode.ALWAYS -> R.string.subtitle_mode_always + SubtitlePlaybackMode.ONLY_FORCED -> R.string.subtitle_mode_only_forced + SubtitlePlaybackMode.NONE -> R.string.subtitle_mode_none + SubtitlePlaybackMode.SMART -> R.string.subtitle_mode_smart + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt index 6c3e7bca..c63c4067 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt @@ -93,3 +93,13 @@ data class ResProviderStringProvider( @NonRestartableComposable override fun getString(): String = stringResource(stringResId, argProvider.getString()) } + +@Serializable +data class ConcatStringProvider( + val separator: String, + val providers: List<StringProvider>, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = providers.map { it.getString() }.joinToString(separator) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54217145..89888b8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,6 +95,7 @@ <string name="playlists">Playlists</string> <string name="preview">Preview</string> <string name="profile_specific_settings">User Profile Settings</string> + <string name="profile_specific_settings_from_server">User profile settings from server</string> <string name="queue">Queue</string> <string name="recap">Recap</string> <string name="recently_added_in">Recently added in %1$s</string> @@ -844,4 +845,27 @@ <string name="video_tunneling">Video tunneling</string> <string name="blocklisted">Blocklisted</string> <string name="unavailable">Unavailable</string> + <string name="subtitle_mode">Subtitle mode</string> + + <string name="use_user_profile">Use your profile setting</string> + + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Only Forced</string> + <string name="subtitle_mode_always">Always</string> + <string name="subtitle_mode_none">None</string> + <string-array name="subtitle_mode_options"> + <item>@string/use_user_profile</item> + <item>@string/subtitle_mode_default</item> + <item>@string/subtitle_mode_smart</item> + <item>@string/subtitle_mode_only_forced</item> + <item>@string/subtitle_mode_always</item> + <item>@string/subtitle_mode_none</item> + </string-array> + + <string name="preferred_subtitle_language">Preferred subtitle language</string> + <string name="preferred_audio_language">Preferred audio language</string> + <string name="override_user_profile_settings">More user profile settings</string> + <string name="override_user_profile_settings_summary">Override your user profile settings from the server</string> + <string name="any_language">Any language</string> </resources> diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index 61f0f136..37671f8d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -3,11 +3,14 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID @@ -800,6 +803,80 @@ class TestStreamChoiceServiceOnlyForcedClientOverride( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceAppPreferences( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection<TestInput> = + listOf( + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = UserProfileSettings.USE_USER_PROFILE, + ), + TestInput( + expectedIndex = 0, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "eng", + ), + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "spa", + ), + TestInput( + expectedIndex = 1, // ItemPlayback + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + appSubtitleLang = "spa", + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -809,6 +886,9 @@ data class TestInput( val subtitles: List<MediaStream>, val itemPlayback: ItemPlayback? = null, val plc: PlaybackLanguageChoice? = null, + val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, ) { override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" } @@ -850,7 +930,15 @@ private fun runTest(input: TestInput) { candidates = input.subtitles, itemPlayback = input.itemPlayback, playbackLanguageChoice = input.plc, - prefs = UserPreferences(AppPreferences.getDefaultInstance()), + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences( + preferredAudioLanguage = input.appAudioLang, + preferredSubtitleLanguage = input.appSubtitleLang, + subtitleMode = input.appSubtitleMode, + ), + ), ) Assert.assertEquals(input.expectedIndex, result?.index) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 33e7973d..adb29999 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -132,7 +132,7 @@ class PlaybackViewModelTests { private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) - val prefs = UserPreferences(appPrefs) + val prefs = UserPreferences(appPrefs, null) coEvery { mockUserPreferencesService.getCurrent() } returns prefs } From 46b2f2839fe618d3b27f4cd8917828fee1d16f4d Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 14 Jul 2026 15:35:49 -0400 Subject: [PATCH 158/475] Fix compile error from merging #947 & #1680 (#1700) ## Description Fixes [compile error](https://github.com/damontecres/Wholphin/actions/runs/29357259594/job/87167912698) ### Related issues Resulted from merging #947 & #1680 together into main ### Testing Compiled & unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/preferences/user/UserProfilePreferencesPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt index 2cd74f1a..1308e08e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt @@ -243,7 +243,7 @@ fun UserProfilePreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? From 84f60351032eb1c305affdde05ab9099613a4151 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 15 Jul 2026 18:45:32 -0400 Subject: [PATCH 159/475] Fix bug finding the episode to scroll to on Series Overview (#1708) ## Description Fixes an off-by-one error when searching for the right episode (or season) to scroll to on the series overview page. While the code works most of the time, on cases where a special appears _after_ a season is included, the final episode in the season not be scrolled to. For example, if Special S00E04 comes after Season 2 (which has 10 episodes), clicking on S02E10 on the home page won't scroll to that episode on the series overview, instead S02E01 will be focused. And S00E04 will appear at the end of the list. This should also be a very, very tiny performance boost since the "search the whole list" fallback won't be triggered as often. I also added some comments to explain the function better. ### Related issues N/A, just noticed it yesterday ### Testing Emulator in the situation described above ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/series/SeriesViewModel.kt | 71 ++++++---- .../test/TestFindIndexByNumberOrId.kt | 129 ++++++++++++++++++ 2 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt 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 499dc44f..033ae5f6 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 @@ -37,6 +37,7 @@ import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler @@ -165,7 +166,7 @@ class SeriesViewModel viewModelScope.launchIO { val index = (seasons as? ApiRequestPager<*>)?.let { - findIndexOf( + findIndexByNumberOrId( seasonEpisodeIds.seasonNumber, seasonEpisodeIds.seasonId, it, @@ -361,7 +362,7 @@ class SeriesViewModel pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexOf(episodeNumber, episodeId, pager) + findIndexByNumberOrId(episodeNumber, episodeId, pager) .coerceAtLeast(0) } else { // Force the first page to to be fetched @@ -704,43 +705,67 @@ enum class SeriesPageType { OVERVIEW, } -private suspend fun findIndexOf( +private fun checkNumberOrId( targetNum: Int?, targetId: UUID?, - pager: ApiRequestPager<*>, + indexNumber: Int?, + id: UUID?, +): Boolean = + if (targetId != null) { + equalsNotNull(targetId, id) + } else { + equalsNotNull(indexNumber, targetNum) + } + +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * This is necessary in cases where items are missing. E.g. if looking for episode 4 but + * episodes 2 & 3 is missing, then the index in the list for episode 4 will be `1`. + * + * @param targetNum the 1-index season or episode number + * @param targetId the season or episode ID + * + * @return the index within [list] that matches, or zero if no match is found + */ +suspend fun findIndexByNumberOrId( + targetNum: Int?, + targetId: UUID?, + list: BlockingList<BaseItem?>, ): Int { + // Adjust for 1-based numbers + val listIndex = targetNum?.minus(1)?.coerceAtLeast(0) val index = - if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { + if (targetId != null && (targetNum == null || listIndex !in list.indices)) { // No hint info, so have to check everything - pager.indexOfBlocking { - equalsNotNull(it?.indexNumber, targetNum) || - equalsNotNull(it?.id, targetId) - } - } else if (targetNum != null && targetNum in pager.indices) { - // Start searching from the season number and choose direction from there - val num = pager.getBlocking(targetNum)?.indexNumber + list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + }.coerceAtLeast(0) + } else if (listIndex != null && listIndex in list.indices) { + // Start searching from the target number and choose direction from there + val num = list.getBlocking(listIndex)?.indexNumber if (num.lt(targetNum)) { - for (i in targetNum + 1 until pager.lastIndex) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { + for (i in listIndex + 1 until list.lastIndex) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { return i } } return 0 } else if (num.gt(targetNum)) { - for (i in targetNum - 1 downTo 0) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { + for (i in listIndex - 1 downTo 0) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { return i } } return 0 } else { - targetNum + list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + }.coerceAtLeast(0) } } else { 0 diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt new file mode 100644 index 00000000..f50ed223 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -0,0 +1,129 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.findIndexByNumberOrId +import com.github.damontecres.wholphin.util.BlockingList +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert +import org.junit.Test + +class TestFindIndexByNumberOrId { + fun create( + indexNumber: Int, + parentIndexNumber: Int, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + indexNumber = indexNumber, + parentIndexNumber = parentIndexNumber, + ), + ) + + val epS02E01 = create(1, 2) + val epS02E02 = create(2, 2) + val epS02E03 = create(3, 2) + val epS02E04 = create(4, 2) + val epS02E05 = create(5, 2) + val epS00E08 = create(8, 0) + val epS00E02 = create(2, 0) + + private val episodes = + listOf( + epS02E01, + epS02E02, + epS02E03, + ) + + private val missingEpisodes = + listOf( + epS02E01, + epS02E04, + epS02E05, + ) + + private val withSpecials = + listOf( + epS02E01, + epS02E02, + epS02E03, + epS00E08, + ) + + @Test + fun `Basic tests`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(2, index) + } + findIndexByNumberOrId(targetNum = 100, targetId = UUID.randomUUID(), list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test missing episodes`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = epS02E04.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = epS02E05.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test missing episodes without ID`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test with special`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test with special matching number`() = + runTest { + val list = listOf(epS02E01, epS00E02, epS02E02) + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(list)).let { index -> + Assert.assertEquals(2, index) + } + } +} From c2aab06c0b397decf2fe5bd024c7c6095d5d4a8e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 15 Jul 2026 20:25:43 -0400 Subject: [PATCH 160/475] Save sort & filter within a genre or studio (#1711) ## Description Saves the sort and filter of items within a genre or studio (Movies->Genres tab->Click genre) across genres or studios. So if you open "Action" movies and change the sort, the sort will be used for "Animation" or "Horror" movies. This is saved separate from the sort/filter on the Library tab. ### Related issues Closes https://github.com/damontecres/Wholphin/issues/1253 ### Testing Emulator by switching sorts ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/data/model/BaseItem.kt | 7 +++++-- .../damontecres/wholphin/data/model/GetItemsFilter.kt | 6 +++++- .../wholphin/ui/components/CollectionFolderView.kt | 5 +++-- 3 files changed, 13 insertions(+), 5 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 5d5c8923..336c8d9a 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 @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.timeRemaining +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient @@ -333,7 +334,8 @@ fun createGenreDestination( genres = listOf(genreId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres", ), recursive = true, collectionType = collectionType, @@ -360,7 +362,8 @@ fun createStudioDestination( studios = listOf(studioId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios", ), recursive = true, collectionType = CollectionType.UNKNOWN, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index d0d32988..792ec6ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -22,9 +22,13 @@ data class CollectionFolderFilter( val nameOverride: String? = null, val filter: GetItemsFilter = GetItemsFilter(), /** - * Whether to use the libray's saved sort & filter + * Whether to use the library's saved sort & filter */ val useSavedLibraryDisplayInfo: Boolean = true, + /** + * Use a different ID for the library's saved sort & filter, such as for library's genres + */ + val libraryDisplayInfoIdOverride: String? = null, ) /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index dd6ed7ce..939be43b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -160,7 +160,8 @@ class CollectionFolderViewModel val libraryDisplayInfo = serverRepository.currentUser?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) + val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId + libraryDisplayInfoDao.getItem(user, id) } _state.update { it.copy( @@ -237,7 +238,7 @@ class CollectionFolderViewModel val libraryDisplayInfo = LibraryDisplayInfo( userId = user.rowId, - itemId = itemId, + itemId = collectionFilter.libraryDisplayInfoIdOverride ?: itemId, sort = newSort.sort, direction = newSort.direction, filter = newFilter, From 39c030c1d51b18f81c823773ee0e292d3fa4c847 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 16 Jul 2026 11:35:32 -0400 Subject: [PATCH 161/475] Fix loading a canceled discover tv show (#1709) ## Description #1614 added filtering by a TV production status. I pulled the enum values from a comment in Seerr's code which spelled it as `Cancelled`, but the actual API uses `Canceled`. This PR corrects the spelling. Also, since this value isn't actually used yet, I've removed it from `TvDetails` for now. ### Related issues Fixes #1703 ### Testing Emulator, filter by production status as canceled and open any result ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/filter/DiscoverFilter.kt | 10 +++++----- .../damontecres/wholphin/services/FilterOptionCache.kt | 4 ++-- .../wholphin/ui/components/FilterByButton.kt | 7 ++++--- app/src/main/seerr/seerr-api.yml | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt index 08863fff..bef5c87a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -6,7 +6,7 @@ import com.github.damontecres.wholphin.api.seerr.SearchApi import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response -import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus import com.github.damontecres.wholphin.ui.data.flip import kotlinx.serialization.Serializable import org.jellyfin.sdk.model.api.SortOrder @@ -63,7 +63,7 @@ data class DiscoverFilter( val watchRegion: String? = null, // US // networkIds? // val watchProviders: String? = null, - val status: List<TvDetails.Status>? = null, + val status: List<TvShowStatus>? = null, val certification: List<String>? = null, // val certificationGte: String? = null, // val certificationLte: String? = null, @@ -233,15 +233,15 @@ data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> { ): DiscoverFilter = filter.copy(networkIds = value) } -data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvDetails.Status>> { +data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> { override val stringRes: Int = R.string.production_status override val supportMultiple: Boolean = true - override fun get(filter: DiscoverFilter): List<TvDetails.Status>? = filter.status + override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status override fun set( - value: List<TvDetails.Status>?, + value: List<TvShowStatus>?, filter: DiscoverFilter, ): DiscoverFilter = filter.copy(status = value) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt index dc055bd3..52734f20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -1,7 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context -import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter @@ -239,7 +239,7 @@ class FilterOptionCache } DiscoverTvStatusFilter -> { - TvDetails.Status.entries.map { + TvShowStatus.entries.map { FilterValueOption(it.value, it) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index 9789d48f..af64ef15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -38,6 +38,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DiscoverFilter @@ -598,7 +599,7 @@ fun DiscoverFilterByButton( } DiscoverTvStatusFilter -> { - (currentValue as? List<TvDetails.Status>) + (currentValue as? List<TvShowStatus>) .orEmpty() .contains(value.value) } @@ -665,7 +666,7 @@ fun DiscoverFilterByButton( DiscoverTvStatusFilter -> { val list = - (currentValue as? List<TvDetails.Status>).orEmpty() + (currentValue as? List<TvShowStatus>).orEmpty() val newValue = list .toMutableList() @@ -673,7 +674,7 @@ fun DiscoverFilterByButton( if (isSelected) { remove(value.value!!) } else { - add(value.value!! as TvDetails.Status) + add(value.value!! as TvShowStatus) } }.takeIf { it.isNotEmpty() } filterOption.set(newValue, current) diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 7c128bbd..3581c599 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1092,6 +1092,9 @@ components: example: 0 description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED readOnly: true + TvShowStatus: + type: string + enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] TvDetails: type: object properties: @@ -1197,9 +1200,6 @@ components: type: array items: $ref: '#/components/schemas/Season' - status: - type: string - enum: ["Returning Series", "Planned", "In Production", "Ended", "Cancelled", "Pilot"] tagline: type: string type: From a350fa20176c4ff024d086fab86301293f16e5f7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 16 Jul 2026 11:35:48 -0400 Subject: [PATCH 162/475] Improvements to "Add to playlist" dialog (#1706) ## Description Fixes & improves the "Add to playlist" dialog: - Added searching by name - Playlists are sorted by recently updated - No longer filters by media type - Filters based on user's permission for the playlists The behavior is now consistent with the web UI's list except for the sort ### Related issues Should fix #1124 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/PlaylistCreator.kt | 51 ++++-- .../ui/components/ContextMenuUtils.kt | 4 +- .../ui/components/RecommendedContent.kt | 9 +- .../wholphin/ui/data/AddPlaylistViewModel.kt | 7 +- .../wholphin/ui/detail/PlaylistDetails.kt | 5 +- .../wholphin/ui/detail/PlaylistList.kt | 165 +++++++++++++++--- .../ui/detail/collection/CollectionDetails.kt | 4 +- .../ui/detail/episode/EpisodeDetails.kt | 4 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 4 +- .../ui/detail/music/AlbumDetailsPage.kt | 4 +- .../ui/detail/music/ArtistDetailsPage.kt | 4 +- .../ui/detail/series/SeriesDetails.kt | 4 +- .../ui/detail/series/SeriesOverview.kt | 4 +- .../damontecres/wholphin/ui/main/HomePage.kt | 4 +- .../wholphin/ui/main/SearchPage.kt | 2 +- .../wholphin/util/SearchRelevance.kt | 12 +- 16 files changed, 211 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 0b2e85e3..22ecb1ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -15,13 +15,11 @@ import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.videosApi @@ -344,26 +342,49 @@ class PlaylistCreator * Get the playlists on the server for a given media type */ suspend fun getServerPlaylists( + query: String, mediaType: MediaType?, - scope: CoroutineScope, - ): List<PlaylistInfo?> { + ): List<PlaylistInfo> { + val userId = + serverRepository.currentUser?.id ?: throw IllegalStateException("No user found") val request = GetItemsRequest( + searchTerm = query.takeIf { it.isNotBlank() }, includeItemTypes = listOf(BaseItemKind.PLAYLIST), mediaTypes = mediaType?.let { listOf(mediaType) }, recursive = true, + limit = 25, + sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED), + sortOrder = listOf(SortOrder.DESCENDING), ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() - return TransformList(pager) { - it?.let { - PlaylistInfo( - id = it.id, - name = it.name ?: context.getString(R.string.unknown), - count = it.data.childCount ?: 0, - mediaType = it.data.mediaType, - ) + val playlists = GetItemsRequestHandler.execute(api, request).content.items + return playlists + .mapNotNull { playlist -> + try { + val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content + if (response.canEdit) { + PlaylistInfo( + id = playlist.id, + name = playlist.name ?: context.getString(R.string.unknown), + count = playlist.childCount ?: 0, + mediaType = playlist.mediaType, + ) + } else { + null + } + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + null + } else { + throw ex + } + } } - } +// .sortedWith( +// compareBy<PlaylistInfo> { +// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query) +// }.thenBy { it.name }, +// ) } suspend fun createServerPlaylist( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt index 80e989ea..2261e0ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -18,7 +18,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.nav.Destination -import org.jellyfin.sdk.model.api.MediaType import java.util.UUID /** @@ -76,7 +75,7 @@ class ContextMenuUtils( provider.setFavorite(position, itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = provider::sendReportFor, @@ -129,6 +128,7 @@ class ContextMenuUtils( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 7dc5d11a..7159fc58 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 @@ -62,7 +62,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.util.UUID @@ -388,7 +387,7 @@ fun RecommendedContent( viewModel.setFavorite(position, itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -485,12 +484,8 @@ fun RecommendedContent( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } - -data class RowColumnItem( - val position: RowColumn, - val item: BaseItem, -) 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 a41487c7..4dc1dd16 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 @@ -18,7 +18,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -38,13 +37,13 @@ class AddPlaylistViewModel ) : ViewModel() { val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending) - fun loadPlaylists(mediaType: MediaType?) { + fun loadPlaylists(query: String = "") { viewModelScope.launchIO { this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading try { - val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) + val playlists = playlistCreator.getServerPlaylists(query, null) this@AddPlaylistViewModel.playlistState.value = - PlaylistLoadingState.Success(playlists) + PlaylistLoadingState.Success(playlists, query) } catch (ex: Exception) { playlistState.value = PlaylistLoadingState.Error(ex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7d66c08b..5859df56 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -412,7 +412,7 @@ fun PlaylistDetails( onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) + addToPlaylistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -425,7 +425,7 @@ fun PlaylistDetails( onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) + addToPlaylistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel::sendMediaReport, @@ -511,6 +511,7 @@ fun PlaylistDetails( addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = addToPlaylistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt index df4408fb..e38ff099 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -1,11 +1,16 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.Gravity +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,9 +30,13 @@ 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.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -38,6 +47,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -49,12 +59,16 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.MediaType +import kotlin.time.Duration.Companion.milliseconds @Composable fun PlaylistList( - playlists: List<PlaylistInfo?>, + playlists: List<PlaylistInfo>, onClick: (PlaylistInfo) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, @@ -68,33 +82,37 @@ fun PlaylistList( items(playlists) { playlist -> ListItem( selected = false, - enabled = playlist != null, + enabled = true, headlineContent = { Text( - text = playlist?.name ?: stringResource(R.string.loading), + text = playlist.name, ) }, supportingContent = { - if (playlist != null) { - Text( - text = playlist.mediaType.serialName, - ) - } + val id = + when (playlist.mediaType) { + MediaType.UNKNOWN -> R.string.unknown + MediaType.VIDEO -> R.string.video + MediaType.AUDIO -> R.string.audio + MediaType.PHOTO -> R.string.photos + MediaType.BOOK -> R.string.unknown + } + Text( + text = stringResource(id), + ) }, trailingContent = { - if (playlist != null) { - Text( - text = - pluralStringResource( - R.plurals.items, - playlist.count, - playlist.count, - ), - ) - } + Text( + text = + pluralStringResource( + R.plurals.items, + playlist.count, + playlist.count, + ), + ) }, onClick = { - if (playlist != null) onClick.invoke(playlist) + onClick.invoke(playlist) }, modifier = Modifier, ) @@ -193,16 +211,38 @@ fun PlaylistDialog( state: PlaylistLoadingState, onDismissRequest: () -> Unit, onClick: (PlaylistInfo) -> Unit, + onSearch: (String) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, elevation: Dp = 3.dp, ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) + + val placeholderFocusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() } + val searchFocusRequester = remember { FocusRequester() } + var query by remember { mutableStateOf("") } + var searchClicked by remember { mutableStateOf(false) } + var searchHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(query) { + val previousQuery = (state as? PlaylistLoadingState.Success)?.query ?: "" + if (previousQuery != query) { + delay(750.milliseconds) + if (searchClicked) { + searchClicked = false + return@LaunchedEffect + } + onSearch.invoke(query) + } + } + Dialog( onDismissRequest = onDismissRequest, ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.TOP) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -220,6 +260,74 @@ fun PlaylistDialog( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { + if (query.isNotBlank()) { + searchClicked = true + onSearch.invoke(query) + } + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester) + .onFocusChanged { searchHasFocus = it.hasFocus } + .focusProperties { + // Block focus to except for down/next to the results + // Don't want to focus on the placeholder + up = FocusRequester.Cancel + left = FocusRequester.Cancel + right = FocusRequester.Cancel + previous = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = focusRequester + next = focusRequester + }, + ) + LaunchedEffect(Unit) { + if (state !is PlaylistLoadingState.Success) { + placeholderFocusRequester.tryRequestFocus() + } + } + // Placeholder for initial focus + // After results load, focus will move to the first one + Box( + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(0.dp) + .height(0.dp) + .focusRequester(placeholderFocusRequester) + .focusable(), + ) + val resultsModifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .focusProperties { + // Only focus up to search bar and ignore the placeholder + left = FocusRequester.Cancel + right = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = FocusRequester.Cancel + next = FocusRequester.Cancel + up = searchFocusRequester + previous = searchFocusRequester + onExit = { + if (requestedFocusDirection == FocusDirection.Next || + requestedFocusDirection == FocusDirection.Left || + requestedFocusDirection == FocusDirection.Right + ) { + cancelFocusChange() + } else { + searchFocusRequester.tryRequestFocus() + } + } + } when (val s = state) { PlaylistLoadingState.Pending, PlaylistLoadingState.Loading, @@ -234,20 +342,24 @@ fun PlaylistDialog( } is PlaylistLoadingState.Error -> { - ErrorMessage(s.message, s.exception) + ErrorMessage( + message = s.message, + exception = s.exception, + modifier = resultsModifier, + ) } is PlaylistLoadingState.Success -> { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + // Once results load, focus unless the user is still focused on the search box + if (!searchHasFocus) focusRequester.tryRequestFocus() + } PlaylistList( playlists = s.items, onClick = onClick, createEnabled = createEnabled, onCreatePlaylist = onCreatePlaylist, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), + modifier = resultsModifier, ) } } @@ -261,7 +373,8 @@ sealed interface PlaylistLoadingState { data object Loading : PlaylistLoadingState data class Success( - val items: List<PlaylistInfo?>, + val items: List<PlaylistInfo>, + val query: String, ) : PlaylistLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 3077727c..f22c7f33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -56,7 +56,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -91,7 +90,7 @@ fun CollectionDetails( viewModel.setFavorite(itemId, favorite, position) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -273,6 +272,7 @@ fun CollectionDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 dd91e00b..ff43eb10 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 @@ -49,7 +49,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -88,7 +87,7 @@ fun EpisodeDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -208,6 +207,7 @@ fun EpisodeDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 42742c85..bc4a3461 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 @@ -70,7 +70,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -109,7 +108,7 @@ fun MovieDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -275,6 +274,7 @@ fun MovieDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 55dc6b4c..f7f50bdb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -104,7 +104,6 @@ import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import java.util.UUID @@ -337,7 +336,7 @@ fun AlbumDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -635,6 +634,7 @@ fun AlbumDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index d8806b59..eacc0e5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -99,7 +99,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest @@ -328,7 +327,7 @@ fun ArtistDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -631,6 +630,7 @@ fun ArtistDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.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 69639590..fe0b5e5c 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 @@ -88,7 +88,6 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID import kotlin.time.Duration @@ -133,7 +132,7 @@ fun SeriesDetails( viewModel.setFavorite(itemId, favorite, null) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = Optional.present(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -307,6 +306,7 @@ fun SeriesDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 fed2d342..77922f9d 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 @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -116,7 +115,7 @@ fun SeriesOverview( viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -348,6 +347,7 @@ fun SeriesOverview( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.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 27d84670..cc06ed88 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 @@ -91,7 +91,6 @@ import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -177,7 +176,7 @@ fun HomePage( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, @@ -258,6 +257,7 @@ fun HomePage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 ea978971..b1705970 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 @@ -240,7 +240,7 @@ class SearchViewModel val sorted = items.sortedWith( compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, + .thenBy { it.sortName }, ) _state.update { update.invoke(SearchResult.Success(sorted), it) } } catch (ex: CancellationException) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt index 60dada9d..dd0d0b38 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt @@ -10,14 +10,20 @@ object SearchRelevance { fun score( item: BaseItem, query: String, + ): Int = score(item.name, item.type, query) + + fun score( + name: String?, + type: BaseItemKind, + query: String, ): Int { - val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE + val name = name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE val q = query.lowercase(Locale.getDefault()) REGEX_PATTERN.find(query)?.let { match -> val pattern = match.groupValues[1] val caseInsensitive = match.groupValues[2] == "i" - return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type) + return regexScore(name, pattern, caseInsensitive) + typeBonus(type) } return when { @@ -26,7 +32,7 @@ object SearchRelevance { name.contains(" $q") -> 200 name.contains(q) -> 300 else -> fuzzyScore(name, q) - } + typeBonus(item.type) + } + typeBonus(type) } private fun regexScore( From ed97fc3009c8b8969899c35e5cd40d595fe84cdf Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 16 Jul 2026 11:36:07 -0400 Subject: [PATCH 163/475] Add context menu to search page (#1701) ## Description Adds long clicking search results to show the context menu. It's the same context menu as elsewhere in the app. ### Related issues Closes #1695 ### Testing Emulator to toggle favorite and watched status on various types ## Screenshots N/A ## AI or LLM usage None --- .../ui/discover/DiscoverSearchPage.kt | 1 + .../wholphin/ui/main/SearchPage.kt | 260 ++++++++++++++---- 2 files changed, 211 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index fe4a198a..4179f1d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -239,6 +239,7 @@ fun DiscoverSearchPage( result = seerrResults, focusRequester = gridFocusRequester, onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, onPlayItem = { _, _ -> }, onClickPosition = { position = it.column }, onClickDiscover = onClickDiscover, 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 b1705970..50c2cc6a 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 @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main +import android.content.Context import android.view.Gravity import androidx.activity.compose.BackHandler import androidx.annotation.StringRes @@ -66,15 +67,20 @@ import androidx.tv.material3.Switch import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateSearchPreferences +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.SeerrService 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.Cards import com.github.damontecres.wholphin.ui.SlimItemFields @@ -84,27 +90,31 @@ import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRowTitle import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank +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.onMain import com.github.damontecres.wholphin.ui.preferences.SwitchColors -import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.SearchRelevance import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -115,29 +125,36 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber +import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class SearchViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, private val appPreferences: DataStore<AppPreferences>, private val seerrService: SeerrService, val voiceInputManager: VoiceInputManager, val userPreferencesService: UserPreferencesService, - ) : ViewModel() { - val voiceState = voiceInputManager.state - val soundLevel = voiceInputManager.soundLevel - val partialResult = voiceInputManager.partialResult + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val mediaReportService: MediaReportService, + ) : ViewModel(), + ContextMenuProvider { val seerrActive = seerrService.active private val _state = MutableStateFlow(SearchState()) val state: StateFlow<SearchState> = _state + val position = MutableStateFlow(RowColumn(0, 0)) private var currentQuery: String? = null private var combinedMode = false @@ -234,8 +251,8 @@ class SearchViewModel ) val result = api.itemsApi.getItems(request).content val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) + result.items.map { + BaseItem(it, false) } val sorted = items.sortedWith( @@ -271,8 +288,8 @@ class SearchViewModel val result = api.itemsApi.getItems(request).content val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) + result.items.map { + BaseItem(it, false) } val sorted = items.sortedWith( @@ -325,10 +342,113 @@ class SearchViewModel addCloseable(voiceInputManager) } - fun getHints(query: String) { - // TODO -// api.searchApi.getSearchHints() + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + when (position.row) { + MOVIE_ROW -> state.value.movies + SERIES_ROW -> state.value.series + EPISODE_ROW -> state.value.episodes + COLLECTION_ROW -> state.value.collections + ALBUM_ROW -> state.value.albums + ARTIST_ROW -> state.value.artists + SONG_ROW -> state.value.songs + SEERR_ROW -> null + else -> null + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + _state.update { + if (combinedMode) { + it.copy( + combinedResults = newList, + ) + } else { + when (position.row) { + MOVIE_ROW -> it.copy(movies = newList) + SERIES_ROW -> it.copy(series = newList) + EPISODE_ROW -> it.copy(episodes = newList) + COLLECTION_ROW -> it.copy(collections = newList) + ALBUM_ROW -> it.copy(albums = newList) + ARTIST_ROW -> it.copy(artists = newList) + SONG_ROW -> it.copy(songs = newList) + SEERR_ROW -> it + else -> it + } + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) } sealed interface SearchResult { @@ -374,9 +494,6 @@ private const val SEERR_ROW = SONG_ROW + 1 private const val COMBINED_ROW = TAB_ROW + 1 -/** Delay for focus to settle after voice search dialog dismisses. */ -private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L - @Composable fun SearchPage( userPreferences: UserPreferences, @@ -398,16 +515,22 @@ fun SearchPage( // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - val tabFocusRequesters = remember { List(2) { FocusRequester() } } val seerrActive by viewModel.seerrActive.collectAsState(initial = false) var selectedTab by rememberSaveable { mutableIntStateOf(0) } var showViewOptions by rememberSaveable { mutableStateOf(false) } - - var position by rememberPosition(0, 0) var searchClicked by rememberSaveable(query) { mutableStateOf(false) } var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + LifecycleResumeEffect(Unit) { onPauseOrDispose { viewModel.voiceInputManager.stopListening() @@ -427,7 +550,7 @@ fun SearchPage( } else -> { - delay(750L) + delay(750.milliseconds) viewModel.search(query, combinedMode) } } @@ -435,9 +558,13 @@ fun SearchPage( LaunchedEffect(Unit) { focusRequesters.getOrNull(position.row)?.tryRequestFocus() } - val onClickItem = { index: Int, item: BaseItem -> + val onClickItem = { _: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } val onPlayItem = { _: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) } @@ -475,10 +602,10 @@ fun SearchPage( selectedTab, seerrActive, ) { - if (!searchClicked) return@LaunchedEffect + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect withContext(WholphinDispatchers.IO) { - // Want to focus on the first successful row after all of the ones before it are finished searching + // Want to focus on the first successful row after all the ones before it are finished searching val results = if (isLibraryTab) { if (combinedMode) { @@ -513,7 +640,7 @@ fun SearchPage( } else { SEERR_ROW } - position = RowColumn(targetRow, 0) +// setPosition(RowColumn(targetRow, 0)) onMain { focusRequesters[targetRow].tryRequestFocus() } } } @@ -628,12 +755,15 @@ fun SearchPage( 0 -> COMBINED_ROW else -> SEERR_ROW } - position = RowColumn(row, 0) + setPosition(RowColumn(row, 0)) }, modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp), + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, ) } Box( @@ -648,8 +778,11 @@ fun SearchPage( result = state.combinedResults, focusRequester = focusRequesters[COMBINED_ROW], onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, onPlayItem = onPlayItem, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, onClickDiscover = onClickDiscover, positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), @@ -661,8 +794,9 @@ fun SearchPage( result = state.seerrResults, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, onPlayItem = onPlayItem, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, onClickDiscover = onClickDiscover, positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), @@ -688,7 +822,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[MOVIE_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(MOVIE_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -698,7 +835,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SERIES_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(SERIES_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -708,13 +848,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[EPISODE_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(EPISODE_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = @Composable { index, item, mod, onClick, onLongClick -> EpisodeCard( item = item, onClick = { - position = RowColumn(EPISODE_ROW, index) + setPosition(RowColumn(EPISODE_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -730,7 +873,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[COLLECTION_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(COLLECTION_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -740,13 +886,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[ALBUM_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(ALBUM_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(ALBUM_ROW, index) + setPosition(RowColumn(ALBUM_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -764,13 +913,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[ARTIST_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(ARTIST_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(ARTIST_ROW, index) + setPosition(RowColumn(ARTIST_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -788,13 +940,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SONG_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(SONG_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(SONG_ROW, index) + setPosition(RowColumn(SONG_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -812,14 +967,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, onClickDiscover = onClickDiscover, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) } } } } + contextMenu.Compose() } if (showViewOptions) { @@ -923,16 +1080,15 @@ fun SearchCombinedResults( result: SearchResult, focusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, onPlayItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, ) { - when (val r = result) { - SearchResult.NoQuery -> { - Unit - } + when (result) { + SearchResult.NoQuery -> {} SearchResult.Searching -> { SearchResultPlaceholder( @@ -945,14 +1101,14 @@ fun SearchCombinedResults( is SearchResult.Error -> { SearchResultPlaceholder( title = stringResource(R.string.results), - message = r.ex.localizedMessage ?: "Error occurred during search", + message = result.ex.localizedMessage ?: "Error occurred during search", messageColor = MaterialTheme.colorScheme.error, modifier = modifier.padding(16.dp), ) } is SearchResult.Success -> { - if (r.items.isEmpty()) { + if (result.items.isEmpty()) { SearchResultPlaceholder( title = stringResource(R.string.results), message = stringResource(R.string.no_results), @@ -960,9 +1116,10 @@ fun SearchCombinedResults( ) } else { SearchGrid( - items = r.items, + items = result.items, focusRequester = focusRequester, onClickItem = onClickItem, + onLongClickItem = onLongClickItem, onPlayItem = onPlayItem, onClickPosition = onClickPosition, positionCallback = positionCallback, @@ -981,7 +1138,7 @@ fun SearchCombinedResults( } is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { + if (result.items.isEmpty()) { SearchResultPlaceholder( title = stringResource(R.string.results), message = stringResource(R.string.no_results), @@ -989,9 +1146,10 @@ fun SearchCombinedResults( ) } else { SearchGrid( - items = r.items, + items = result.items, focusRequester = focusRequester, onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, onPlayItem = { _, _ -> }, onClickPosition = onClickPosition, positionCallback = positionCallback, @@ -1015,6 +1173,7 @@ private fun <T : CardGridItem> SearchGrid( items: List<T?>, focusRequester: FocusRequester, onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, onPlayItem: (Int, T) -> Unit, onClickPosition: (RowColumn) -> Unit, cardContent: @Composable (GridItemDetails<T>) -> Unit, @@ -1033,7 +1192,7 @@ private fun <T : CardGridItem> SearchGrid( onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) onClickItem.invoke(index, item) }, - onLongClickItem = { _, _ -> }, + onLongClickItem = onLongClickItem, onClickPlay = { index, item -> onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) onPlayItem.invoke(index, item) @@ -1056,6 +1215,7 @@ fun LazyListScope.searchResultRow( position: RowColumn, focusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, @@ -1108,7 +1268,7 @@ fun LazyListScope.searchResultRow( title = stringResource(title), items = r.items, onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, + onLongClickItem = onLongClickItem, modifier = modifier.focusRequester(focusRequester), cardContent = cardContent, ) From 3cc2bf2dd1be84f556a6ce8ebe6a565c7d473b15 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 17 Jul 2026 11:23:31 -0400 Subject: [PATCH 164/475] Fix resume query for Jellyfin 12 (#1715) ## Description Fixes querying for resume item (continue watching) for Jellyfin 12 so that whole series are not included. The patch is also compatible with Jellyfin 10.11. ### Related issues Fixes #1689 I couldn't find an issue in Jellyfin that mentions this behavior change, so I'm guessing it was a side effect. But since the old query did ask to include Series, I don't think it's a bug in the server. ### Testing Manual API queries & emulator on both Jellyfin 11 & 12-rc2 servers ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/LatestNextUpService.kt | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index f67dd100..4ce6583f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -9,7 +9,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.util.LocalDateTimeSerializer import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.github.damontecres.wholphin.util.supportItemKinds import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore @@ -23,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest @@ -60,16 +60,8 @@ class LatestNextUpService userId = userId, fields = SlimItemFields, limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, + mediaTypes = listOf(MediaType.VIDEO), + excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, enableTotalRecordCount = false, ) val items = From 35c4d13f8ff9d908fade1fdd7608e73570ec6f25 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 18 Jul 2026 17:50:35 -0400 Subject: [PATCH 165/475] Various minor bug fixes (#1707) ## Description Gathering several small bug fixes: - Clear the backdrop when manually reloading the home page - Convert the "# years old" string resource to plural - Fix a spelling error in the source English strings - Ensure #1680 is respected when searching for subtitles to download - Slightly adjust padding for the header in collection details to match other headers ### Related issues Fixes string issues reported in https://github.com/damontecres/Wholphin/issues/1705#issuecomment-4982909931 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/StreamChoiceService.kt | 56 ++++++++++++------- .../wholphin/ui/components/HiddenFocusBox.kt | 2 +- .../wholphin/ui/detail/PersonPage.kt | 11 ++-- .../ui/detail/collection/CollectionDetails.kt | 1 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 1 + .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../ui/playback/SubtitleSearchUtils.kt | 21 ++++--- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-da/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-es/strings.xml | 4 +- app/src/main/res/values-et/strings.xml | 4 +- app/src/main/res/values-fr/strings.xml | 4 +- app/src/main/res/values-hu/strings.xml | 4 +- app/src/main/res/values-in/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-iw/strings.xml | 4 +- app/src/main/res/values-lv/strings.xml | 4 +- app/src/main/res/values-nb-rNO/strings.xml | 4 +- app/src/main/res/values-nl/strings.xml | 4 +- app/src/main/res/values-nn/strings.xml | 4 +- app/src/main/res/values-pl/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 4 +- app/src/main/res/values-pt/strings.xml | 4 +- app/src/main/res/values-ro/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-sk/strings.xml | 4 +- app/src/main/res/values-sl/strings.xml | 4 +- app/src/main/res/values-sv/strings.xml | 4 +- app/src/main/res/values-th/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values/strings.xml | 12 ++-- 35 files changed, 147 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 1bd2986c..09450b34 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -138,17 +138,7 @@ class StreamChoiceService playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that val audioLanguage = - if (seriesLang != null) { - seriesLang - } else { - val pref = prefs.userPreferences?.preferredAudioLanguage - when (pref) { - UserProfileSettings.USE_USER_PROFILE -> userConfig?.audioLanguagePreference - UserProfileSettings.PREFER_ANY_LANGUAGE -> null - else -> pref - } - } - + seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) if (audioLanguage.isNotNullOrBlank()) { val sorted = candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) @@ -242,16 +232,7 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } val subtitleLanguage = - if (seriesLang != null) { - seriesLang - } else { - val pref = prefs.userPreferences?.preferredSubtitleLanguage - when (pref) { - UserProfileSettings.USE_USER_PROFILE -> userConfig?.subtitleLanguagePreference - UserProfileSettings.PREFER_ANY_LANGUAGE -> null - else -> pref - }?.takeIf { it.isNotNullOrBlank() } - } + seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { // Client-side manual override: User selected "Only Forced" in player menu return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) @@ -398,3 +379,36 @@ private val String?.isUnknown: Boolean this.equals("zxx", true) private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown + +/** + * Based on the user's preferences, get their preferred language for audio or subtitles + * + * @param prefs the [UserPreferences] + */ +fun getPreferredLanguage( + type: MediaStreamType, + prefs: UserPreferences, + userConfig: UserConfiguration?, +): String? { + val (pref, profileLang) = + when (type) { + MediaStreamType.AUDIO -> { + prefs.userPreferences?.preferredAudioLanguage to + userConfig?.audioLanguagePreference + } + + MediaStreamType.SUBTITLE -> { + prefs.userPreferences?.preferredSubtitleLanguage to + userConfig?.subtitleLanguagePreference + } + + else -> { + throw IllegalArgumentException("Only audio or subtitle supported, not $type") + } + } + return when (pref) { + UserProfileSettings.USE_USER_PROFILE -> profileLang + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + }?.takeIf { it.isNotNullOrBlank() } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt index d74707e5..f86868bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt @@ -25,7 +25,7 @@ fun HiddenFocusBox( modifier = Modifier .fillMaxWidth() - .height(1.dp) + .height(0.dp) .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) onFocus.invoke() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 218627ae..c7f9ee26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -503,8 +504,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.born) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" @@ -533,8 +535,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.died) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index f22c7f33..7366bf59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -343,6 +343,7 @@ fun CollectionDetailsContent( modifier = Modifier .padding( + start = HeaderUtils.startPadding, top = HeaderUtils.topPadding, bottom = 8.dp, ).height(HeaderUtils.height), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 0d66fd8f..67fb849d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -432,6 +432,7 @@ fun NavDrawer( viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() + onClearBackdrop.invoke() } else { viewModel.navigationManager.goToHome() } 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 677200e2..fae25907 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 @@ -181,7 +181,7 @@ class PlaybackViewModel private val _state = MutableStateFlow(PlaybackState()) val state: StateFlow<PlaybackState> = _state - private lateinit var preferences: UserPreferences + internal lateinit var preferences: UserPreferences internal lateinit var itemId: UUID internal lateinit var currentItem: PlaylistItem internal var forceTranscoding: Boolean = false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index d733e176..e9e809ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.services.getPreferredLanguage import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain @@ -42,15 +43,17 @@ sealed interface SubtitleSearchStatus { /** * Trigger a search for subtitles in the given language for the currently playing media */ -fun PlaybackViewModel.searchForSubtitles( - language: String = - serverRepository - .currentUserDto - ?.configuration - ?.subtitleLanguagePreference - ?.takeIf { it.isNotNullOrBlank() } - ?: Locale.current.language, -) { +fun PlaybackViewModel.searchForSubtitles(language: String? = null) { + val language = + language ?: getPreferredLanguage( + MediaStreamType.SUBTITLE, + preferences, + serverRepository + .currentUserDto + ?.configuration, + )?.takeIf { it.isNotNullOrBlank() } + ?: Locale.current.language + subtitleSearchState.update { it.copy( status = SubtitleSearchStatus.Searching, diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a683ad9b..bd688e39 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -161,7 +161,9 @@ <string name="video">Video</string> <string name="videos">Videa</string> <string name="watch_live">Sledujte živě</string> - <string name="years_old">%1$d let starý</string> + <plurals name="years_old"> + <item quantity="other">%1$d let starý</item> + </plurals> <string name="play_with_transcoding">Přehraj s překódováním</string> <string name="media_information">Informace o médiu</string> <string name="show_clock">Zobrazit hodiny</string> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6f6cbd17..53ad50cc 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -164,7 +164,9 @@ <string name="video">Video</string> <string name="videos">Videoer</string> <string name="watch_live">Se direkte</string> - <string name="years_old">%1$d år gammel</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammel</item> + </plurals> <string name="play_with_transcoding">Afspil med transkodning</string> <string name="media_information">Medieinformation</string> <string name="show_clock">Vis ur</string> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1e51b8c5..bb7265dd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -58,7 +58,9 @@ <string name="updates">Aktualisierungen</string> <string name="version">Version</string> <string name="watch_live">Live ansehen</string> - <string name="years_old">%1$d Jahre alt</string> + <plurals name="years_old"> + <item quantity="other">%1$d Jahre alt</item> + </plurals> <string name="extras_title">Extras</string> <plurals name="scenes"> <item quantity="one">Szene</item> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b2a170e8..9b5616ca 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -73,7 +73,9 @@ <string name="version">Versión</string> <string name="video">Vídeo</string> <string name="videos">Vídeos</string> - <string name="years_old">%1$d años</string> + <plurals name="years_old"> + <item quantity="other">%1$d años</item> + </plurals> <string name="play_with_transcoding">Reproducir con transcodificación</string> <string name="show_clock">Mostrar reloj</string> <string name="create_playlist">Crear nueva lista de reproducción</string> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4e8ff8a9..cfbf2725 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -231,7 +231,9 @@ <string name="video">Video</string> <string name="videos">Videod</string> <string name="watch_live">Vaata otse-eetris</string> - <string name="years_old">%1$d aasta(t) vana</string> + <plurals name="years_old"> + <item quantity="other">%1$d aasta(t) vana</item> + </plurals> <string name="play_with_transcoding">Esita koos teisendamisega</string> <string name="show_clock">Näita kella</string> <string name="aired_episode_order">Eetris olnud osade järjestus</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3b0a9df4..421c1b0e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -178,7 +178,9 @@ <string name="tv_guide">Guide</string> <string name="ui_interface">Interface</string> <string name="video_scale">Échelle de la vidéo</string> - <string name="years_old">%1$d ans</string> + <plurals name="years_old"> + <item quantity="other">%1$d ans</item> + </plurals> <string name="italic_font">Mettre la police en italique</string> <string name="official_rating">Classification parentale</string> <string name="extras_title">Bonus</string> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 356175c2..fedecb72 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -136,7 +136,9 @@ <string name="video">Videó</string> <string name="videos">Videók</string> <string name="watch_live">Nézze élőben</string> - <string name="years_old">%1$d éves</string> + <plurals name="years_old"> + <item quantity="other">%1$d éves</item> + </plurals> <string name="play_with_transcoding">Lejátszás átalakítással</string> <string name="show_clock">Aktuális idő mutatása</string> <string name="aired_episode_order">Sorrend epizód vetítése alapján</string> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 5ac10a6c..2bdc1d68 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -137,7 +137,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Tonton langsung</string> - <string name="years_old">%1$d tahun</string> + <plurals name="years_old"> + <item quantity="other">%1$d tahun</item> + </plurals> <string name="play_with_transcoding">Putar dengan transcoding</string> <string name="show_clock">Tampilkan Jam</string> <string name="aired_episode_order">Urutan Episode Tayang</string> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 503a6142..baeb5346 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -183,7 +183,9 @@ <string name="video_scale">Scala Video</string> <string name="video">Video</string> <string name="videos">I video</string> - <string name="years_old">%1$d anni</string> + <plurals name="years_old"> + <item quantity="other">%1$d anni</item> + </plurals> <string name="play_with_transcoding">Riproduci con transcodifica</string> <string name="immediate">Immediato</string> <string name="show_next_up_when">Mostra prossimo episodio</string> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 1b4462d0..2a541e9c 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -215,7 +215,9 @@ <string name="video_scale">קנה מידה של הוידאו</string> <string name="video">וידאו</string> <string name="watch_live">צפה בשידור חי</string> - <string name="years_old">בגיל %1$d</string> + <plurals name="years_old"> + <item quantity="other">בגיל %1$d</item> + </plurals> <string name="play_with_transcoding">נגן עם המרת קידוד</string> <string name="media_information">פרטי המדיה</string> <string name="show_clock">הצג שעון</string> diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index e9cc76c9..c2c66df5 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -164,7 +164,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Skatīties tiešraidi</string> - <string name="years_old">%1$d gadus vecs</string> + <plurals name="years_old"> + <item quantity="other">%1$d gadus vecs</item> + </plurals> <string name="play_with_transcoding">Atskaņot ar transkodēšanu</string> <string name="media_information">Informācija par failu</string> <string name="show_clock">Rādīt Pulksteni</string> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 48c5b438..59793adf 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -135,7 +135,9 @@ <string name="video">Video</string> <string name="videos">Videoer</string> <string name="watch_live">Se direkte</string> - <string name="years_old">%1$d år gammel</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammel</item> + </plurals> <string name="play_with_transcoding">Spill av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 44518453..28539092 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -138,7 +138,9 @@ <string name="video">Video</string> <string name="videos">Video\'s</string> <string name="watch_live">Live kijken</string> - <string name="years_old">%1$d jaar oud</string> + <plurals name="years_old"> + <item quantity="other">%1$d jaar oud</item> + </plurals> <string name="play_with_transcoding">Afspelen met transcodering</string> <string name="show_clock">Klok tonen</string> <string name="aired_episode_order">Volgorde van uitgezonden afleveringen</string> diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 7f6658f4..5917854a 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -138,7 +138,9 @@ <string name="video">Video</string> <string name="videos">Videoar</string> <string name="watch_live">Sjå direkte</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spel av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3221c150..30bee7af 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -263,7 +263,9 @@ <string name="people_title">Obsada</string> <string name="none">Brak</string> <string name="unknown">Nieznany</string> - <string name="years_old">%1$d lat temu</string> + <plurals name="years_old"> + <item quantity="other">%1$d lat temu</item> + </plurals> <string name="success">Powodzenie</string> <string name="official_rating">Ocena rodzicielska</string> <string name="extras_title">Dodatki</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2f2047af..c446b6f9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -166,7 +166,9 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <string name="watch_live">Assista ao vivo</string> - <string name="years_old">%1$d anos de idade</string> + <plurals name="years_old"> + <item quantity="other">%1$d anos de idade</item> + </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> <string name="media_information">Informação de Mídia</string> <string name="show_clock">Mostrar Relógio</string> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1ada5a3d..4c06ad97 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -137,7 +137,9 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <string name="watch_live">Ver em directo</string> - <string name="years_old">%1$d anos</string> + <plurals name="years_old"> + <item quantity="other">%1$d anos</item> + </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> <string name="show_clock">Mostrar Relógio</string> <string name="aired_episode_order">Ordem de Emissão</string> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 64017e76..dd2e07e2 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -217,7 +217,9 @@ <string name="updates">Actualizări</string> <string name="version">Versiune</string> <string name="show_clock">Arată ceasul</string> - <string name="years_old">%1$d ani vechime</string> + <plurals name="years_old"> + <item quantity="other">%1$d ani vechime</item> + </plurals> <string name="tv_guide">Ghid</string> <string name="one_click_pause">Pauză cu un click</string> <plurals name="interviews"> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 847d1514..b9d4ea6e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,7 +111,9 @@ <string name="video_scale">Масштаб видео</string> <string name="video">Видео</string> <string name="videos">Видео</string> - <string name="years_old">%1$d возраст</string> + <plurals name="years_old"> + <item quantity="other">%1$d возраст</item> + </plurals> <string name="play_with_transcoding">Воспроизвести с перекодированием</string> <string name="show_clock">Показывать часы</string> <string name="create_playlist">Создать новый плейлист</string> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index abecae1d..1fa31795 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -167,7 +167,9 @@ <string name="video">Video</string> <string name="videos">Videá</string> <string name="watch_live">Sledovať naživo</string> - <string name="years_old">%1$d rokov</string> + <plurals name="years_old"> + <item quantity="other">%1$d rokov</item> + </plurals> <string name="play_with_transcoding">Prehrávať s prekódovaním</string> <string name="media_information">Informácie o médiu</string> <string name="show_clock">Zobraziť hodiny</string> diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 99719417..e844ce79 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -164,7 +164,9 @@ <string name="video">Video</string> <string name="videos">Videji</string> <string name="watch_live">Glej v živo</string> - <string name="years_old">%1$d let staro</string> + <plurals name="years_old"> + <item quantity="other">%1$d let staro</item> + </plurals> <string name="play_with_transcoding">Igrajte s transkodiranjem</string> <string name="media_information">Informacije o mediji</string> <string name="show_clock">Prikaži uro</string> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index faaa40fd..01c09b61 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -89,7 +89,9 @@ <string name="video">Video</string> <string name="videos">Videor</string> <string name="watch_live">Titta live</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spela med transkodning</string> <string name="show_clock">Visa klocka</string> <string name="aired_episode_order">Sändningsordning</string> diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 22bc4601..c544add8 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -165,7 +165,9 @@ <string name="video">วิดีโอ</string> <string name="videos">วิดีโอ</string> <string name="watch_live">ชมสด</string> - <string name="years_old">%1$d ปี</string> + <plurals name="years_old"> + <item quantity="other">%1$d ปี</item> + </plurals> <string name="play_with_transcoding">เล่นโดยใช้การแปลงรหัส</string> <string name="media_information">ข้อมูลสื่อ</string> <string name="show_clock">แสดงนาฬิกา</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c7a300b2..1ef29efc 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -169,7 +169,9 @@ <string name="video">Video</string> <string name="videos">Videolar</string> <string name="watch_live">Canlı izle</string> - <string name="years_old">%1$d yaşında</string> + <plurals name="years_old"> + <item quantity="other">%1$d yaşında</item> + </plurals> <string name="play_with_transcoding">Kod dönüştürerek oynat</string> <string name="media_information">Medya Bilgisi</string> <string name="show_clock">Saati göster</string> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index b3d7a7a0..ccbb47a4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -177,7 +177,9 @@ <string name="video">Відео</string> <string name="videos">Відоси</string> <string name="watch_live">Дивитись онлайн</string> - <string name="years_old">%1$d років</string> + <plurals name="years_old"> + <item quantity="other">%1$d років</item> + </plurals> <string name="play_with_transcoding">Відтворити з перекодуванням</string> <string name="media_information">Інформація</string> <string name="show_clock">Показати годиник</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index bcee8122..3c1a4bd8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -137,7 +137,9 @@ <string name="video">视频</string> <string name="videos">视频</string> <string name="watch_live">观看直播</string> - <string name="years_old">%1$d 岁</string> + <plurals name="years_old"> + <item quantity="other">%1$d 岁</item> + </plurals> <string name="play_with_transcoding">转码播放</string> <string name="show_clock">显示时钟</string> <string name="aired_episode_order">剧集播出顺序</string> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0f378ae5..2dbaa495 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -203,7 +203,9 @@ <string name="tv_dvr_schedule">錄影排程</string> <string name="tv_shows_title">電視劇</string> <string name="ui_interface">介面</string> - <string name="years_old">%1$d 歲</string> + <plurals name="years_old"> + <item quantity="other">%1$d 歲</item> + </plurals> <string name="play_with_transcoding">轉碼播放</string> <string name="one_click_pause_summary_on">按下方向鍵中央以暫停/播放</string> <plurals name="deleted_scenes"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89888b8c..eb3d89af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,8 +166,12 @@ <string name="video_scale">Video Scale</string> <string name="video">Video</string> <string name="videos">Videos</string> - <string name="watch_live">Watch live</string> - <string name="years_old">%1$d years old</string> + <string name="watch_live">Watch live</string>g + <plurals name="years_old"> + <item quantity="zero">%1$d years old</item> + <item quantity="one">%1$d year old</item> + <item quantity="other">%1$d years old</item> + </plurals> <string name="play_with_transcoding">Play with transcoding</string> <string name="media_information">Media Information</string> <string name="show_clock">Show Clock</string> @@ -398,7 +402,7 @@ <string name="incorrect">Incorrect</string> <string name="pin_too_short">PIN must be 4 keys or longer</string> <string name="will_remove_pin">Will remove PIN</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="image_cache_size">Image disk cache size (MB)</string> <string name="view_options">View options</string> <string name="columns">Columns</string> @@ -499,7 +503,7 @@ <string name="image_subtitle_opacity">Image subtitle opacity</string> <string name="brightness">Brightness</string> - <string name="contrast">Constrast</string> + <string name="contrast">Contrast</string> <string name="saturation">Saturation</string> <string name="hue">Hue</string> <string name="red">Red</string> From 4f5cd3e7b1d12899f516056e187656fe470e1bf0 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 18 Jul 2026 17:52:16 -0400 Subject: [PATCH 166/475] Fix errors when a plural translation is missing (#1716) ## Description Previously not every plural translation had full coverage and some did not have the fallback "other" quantity translation. So for some languages this would cause an error when trying to display the string depending on the quantity. This PR ensures every plural translation has the "other" fallback. Note: the "other" fallback is copied from an existing translation so I'm sure some will not be grammatically correct! Dev note: I used a python script to parse and fix the entries, so it also rewrote some of the unrelated `CDATA` entries. ### Related issues Fixes #1676 ### Testing On the emulator, I reproduced the error in the issue with the Danish translation, verified it was fixed after the patch was applied ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ExtrasService.kt | 10 ++++--- app/src/main/res/values-ar/strings.xml | 15 +++++++++-- app/src/main/res/values-cs/strings.xml | 27 ++++++++++++++----- app/src/main/res/values-da/strings.xml | 23 +++++++++++++--- app/src/main/res/values-el/strings.xml | 12 ++++++++- app/src/main/res/values-hu/strings.xml | 10 ++++--- app/src/main/res/values-lv/strings.xml | 19 +++++++++++-- app/src/main/res/values-nb-rNO/strings.xml | 20 +++++++++++--- app/src/main/res/values-nn/strings.xml | 24 ++++++++++++----- app/src/main/res/values-pt-rBR/strings.xml | 13 ++++----- app/src/main/res/values-ro/strings.xml | 18 ++++++++++--- app/src/main/res/values-ru/strings.xml | 14 +++++++--- app/src/main/res/values-sl/strings.xml | 27 ++++++++++++++----- app/src/main/res/values-sv/strings.xml | 24 ++++++++++++----- .../wholphin/services/ExtrasServiceTest.kt | 10 +++---- 15 files changed, 200 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index e7770ff5..381032e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -5,6 +5,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.pluralRes +import com.github.damontecres.wholphin.data.stringRes +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -48,9 +50,10 @@ class ExtrasService if (items.size == 1) { val item = items.first() val title = - item.title ?: context.resources.getQuantityString(type.pluralRes, 1) + item.title.takeIf { it.isNotNullOrBlank() } + ?: context.resources.getQuantityString(type.pluralRes, 1) val subtitle = - if (item.title == null) { + if (item.title.isNotNullOrBlank()) { context.resources.getQuantityString(type.pluralRes, 1) } else { null @@ -64,8 +67,7 @@ class ExtrasService imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), ) } else if (items.size > 1) { - val title = - context.resources.getQuantityString(type.pluralRes, items.size) + val title = context.resources.getString(type.stringRes) val subtitle = context.resources.getQuantityString( R.plurals.items, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fa8f0243..6db20221 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -31,7 +31,7 @@ <string name="director">المخرج</string> <string name="disabled">مُعطّل</string> <string name="discovered_servers">الخوادم المكتشفة</string> - <string name="download_and_update"><![CDATA[تنزيل وتحديث]]></string> + <string name="download_and_update">تنزيل وتحديث</string> <string name="downloading">تنزيل…</string> <string name="enabled">مُفعّل</string> <string name="ends_at">ينتهي عند %1$s</string> @@ -66,6 +66,7 @@ <string name="more">المزيد</string> <plurals name="movies"> <item quantity="zero">الأفلام</item> + <item quantity="other">الأفلام</item> </plurals> <string name="name">الاسم</string> <string name="next_up">التالي</string> @@ -80,6 +81,7 @@ <string name="path">المسار</string> <plurals name="people"> <item quantity="zero">الأشخاص</item> + <item quantity="other">الأشخاص</item> </plurals> <string name="play_count">عدد مرات التشغيل</string> <string name="play_from_here">تشغيل من هنا</string> @@ -104,7 +106,7 @@ <string name="restart">إعادة تشغيل</string> <string name="resume">استئناف</string> <string name="save">حفظ</string> - <string name="search_and_download"><![CDATA[بحث وتنزيل]]></string> + <string name="search_and_download">بحث وتنزيل</string> <string name="search">بحث</string> <string name="searching">جارٍ البحث…</string> <string name="voice_search">البحث الصوتي</string> @@ -151,32 +153,41 @@ <string name="show_clock">إظهار الساعة</string> <plurals name="behind_the_scenes"> <item quantity="zero">الكواليس</item> + <item quantity="other">الكواليس</item> </plurals> <string name="official_rating">التصنيف العمري</string> <string name="runtime_sort">مدة العرض</string> <string name="extras_title">إضافات</string> <plurals name="other_extras"> <item quantity="zero">أخرى</item> + <item quantity="other">أخرى</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">الأغاني الرئيسية</item> + <item quantity="other">الأغاني الرئيسية</item> </plurals> <plurals name="clips"> <item quantity="zero">مقاطع</item> + <item quantity="other">مقاطع</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">مشاهد محذوفة</item> + <item quantity="other">مشاهد محذوفة</item> </plurals> <plurals name="interviews"> <item quantity="zero">مقابلات</item> + <item quantity="other">مقابلات</item> </plurals> <plurals name="scenes"> <item quantity="zero">مشاهد</item> + <item quantity="other">مشاهد</item> </plurals> <plurals name="featurettes"> <item quantity="zero">مقاطع خاصة</item> + <item quantity="other">مقاطع خاصة</item> </plurals> <plurals name="shorts"> <item quantity="zero">مقاطع قصيرة</item> + <item quantity="other">مقاطع قصيرة</item> </plurals> </resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bd688e39..97223729 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -63,6 +63,7 @@ <string name="more">Více</string> <plurals name="movies"> <item quantity="one">Filmy</item> + <item quantity="other">Filmy</item> </plurals> <string name="name">Jméno</string> <string name="next_up">Další v pořadí</string> @@ -77,6 +78,7 @@ <string name="path">Cesta</string> <plurals name="people"> <item quantity="one">Lidé</item> + <item quantity="other">Lidé</item> </plurals> <string name="play_count">Počet přehrání</string> <string name="play_from_here">Přehrát odtud</string> @@ -145,6 +147,7 @@ <string name="top_unwatched">Nejlépe hodnocené Nezhlédnuté</string> <plurals name="trailers"> <item quantity="one">Upoutávky</item> + <item quantity="other">Upoutávky</item> </plurals> <string name="tv_dvr_schedule">Harmonogram nahrávání</string> <string name="tv_guide">Televizní program</string> @@ -152,6 +155,7 @@ <string name="tv_seasons">Sezóny</string> <plurals name="tv_shows"> <item quantity="one">Seriály</item> + <item quantity="other">Seriály</item> </plurals> <string name="ui_interface">Prostředí</string> <string name="unknown">Neznámý</string> @@ -180,30 +184,39 @@ <string name="extras_title">Doplňky</string> <plurals name="other_extras"> <item quantity="one">Jiný</item> + <item quantity="other">Jiný</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zákulisí</item> + <item quantity="other">V zákulisí</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Znělky</item> + <item quantity="other">Znělky</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tématická videa</item> + <item quantity="other">Tématická videa</item> </plurals> <plurals name="clips"> <item quantity="one">Klipy</item> + <item quantity="other">Klipy</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Vymazané scény</item> + <item quantity="other">Vymazané scény</item> </plurals> <plurals name="interviews"> <item quantity="one">Rozhovory</item> + <item quantity="other">Rozhovory</item> </plurals> <plurals name="scenes"> <item quantity="one">Scény</item> + <item quantity="other">Scény</item> </plurals> <plurals name="featurettes"> <item quantity="one">Krátkometrážní filmy</item> + <item quantity="other">Krátkometrážní filmy</item> </plurals> <plurals name="hours"> <item quantity="one">%d hodina</item> @@ -385,15 +398,17 @@ <string name="request_4k">Žádost ve 4K</string> <string name="software_decoding_av1">Softwarové dekódování AV1</string> <string name="commercial">Komerční</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update">Download & Update</string> <string name="immediate">Bezprostředně</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download">Search & Download</string> <string name="runtime_sort">Trvání</string> <plurals name="samples"> <item quantity="one">Vzorky</item> + <item quantity="other">Vzorky</item> </plurals> <plurals name="shorts"> <item quantity="one">Krátká videa</item> + <item quantity="other">Krátká videa</item> </plurals> <plurals name="downloads"> <item quantity="one">%s Stažen</item> @@ -401,11 +416,11 @@ <item quantity="many">%s Stažení</item> <item quantity="other">%s Stažení</item> </plurals> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next">Combine Continue Watching & Next Up</string> <string name="direct_play_ass">Direct play ASS titulky</string> <string name="direct_play_pgs">Direct play PGS titulky</string> <string name="player_backend">Přehrávání Backendu</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew">Cast & Crew</string> <string name="anamorphic">Anamorfní</string> <string name="interlaced">Prokládané</string> <string name="color_code_programs">Color code programs</string> @@ -515,12 +530,12 @@ <string name="image_type_thumb">Náhled</string> <string name="backdrop_style_dynamic">Obrázek s dynamickou barvou</string> <string name="backdrop_style_image">Pouze obrázek</string> - <string name="enter_url_api_key"><![CDATA[Zadejte adresu a klíč API]]></string> + <string name="enter_url_api_key">Zadejte adresu a klíč API</string> <string name="api_key">Klíč API</string> <string name="seerr_login">Přihlásit se k serveru Seerr</string> <string name="seerr_jellyfin_user">Uživatel Jellyfin</string> <string name="seerr_local_user">Lokální uživatel</string> - <string name="search_and_download_subtitles"><![CDATA[Hledat a stáhnout titulky]]></string> + <string name="search_and_download_subtitles">Hledat a stáhnout titulky</string> <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> <string name="series_continueing">Současnost</string> </resources> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 53ad50cc..c13cadc6 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,7 +30,7 @@ <string name="director">Instruktør</string> <string name="disabled">Deaktiveret</string> <string name="discovered_servers">Opdagede servere</string> - <string name="download_and_update"><![CDATA[Download og opdater]]></string> + <string name="download_and_update">Download og opdater</string> <string name="downloading">Downloader…</string> <string name="enabled">Aktiveret</string> <string name="ends_at">Slutter %1$s</string> @@ -65,6 +65,7 @@ <string name="more">Mere</string> <plurals name="movies"> <item quantity="one">Film</item> + <item quantity="other">Film</item> </plurals> <string name="name">Navn</string> <string name="next_up">Næste</string> @@ -79,6 +80,7 @@ <string name="path">Filsti</string> <plurals name="people"> <item quantity="one">Personer</item> + <item quantity="other">Personer</item> </plurals> <string name="play_count">Antal afspilninger</string> <string name="play_from_here">Afspil herfra</string> @@ -103,7 +105,7 @@ <string name="restart">Genstart</string> <string name="resume">Fortsæt</string> <string name="save">Gem</string> - <string name="search_and_download"><![CDATA[Søg og download]]></string> + <string name="search_and_download">Søg og download</string> <string name="search">Søg</string> <string name="searching">Søger…</string> <string name="voice_search">Stemmesøgning</string> @@ -148,6 +150,7 @@ <string name="top_unwatched">Bedst bedømte usete</string> <plurals name="trailers"> <item quantity="one">Trailere</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">TV-guide</string> @@ -155,6 +158,7 @@ <string name="tv_seasons">Sæsoner</string> <plurals name="tv_shows"> <item quantity="one">TV-serier</item> + <item quantity="other">TV-serier</item> </plurals> <string name="ui_interface">Grænseflade</string> <string name="unknown">Ukendt</string> @@ -184,36 +188,47 @@ <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Andet</item> + <item quantity="other">Andet</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bag kulisserne</item> + <item quantity="other">Bag kulisserne</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasange</item> + <item quantity="other">Temasange</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoer</item> + <item quantity="other">Temavideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klip</item> + <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Slettede scener</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Interview</item> + <item quantity="other">Interview</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Prøver</item> + <item quantity="other">Prøver</item> </plurals> <plurals name="featurettes"> <item quantity="one">Specialindslag</item> + <item quantity="other">Specialindslag</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilm</item> + <item quantity="other">Kortfilm</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -244,7 +259,7 @@ <string name="auto_play_next">Afspil næste automatisk</string> <string name="check_for_updates">Søg efter opdateringer</string> <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> - <string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se & Næste]]></string> + <string name="combine_continue_next">Kombiner Fortsæt med at se & Næste</string> <string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string> <string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string> <string name="downmix_stereo">Mix altid ned til stereo</string> @@ -329,7 +344,7 @@ <string name="aspect_ratio">Aspektforhold</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Billedtype</string> - <string name="cast_and_crew"><![CDATA[Skuespillere og filmhold]]></string> + <string name="cast_and_crew">Skuespillere og filmhold</string> <string name="guest_stars">Gæsteroller</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Skift af billedopdateringsfrekvens</string> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6ecbebec..a4070eca 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -19,9 +19,11 @@ <string name="background">Παρασκήνιο</string> <plurals name="theme_songs"> <item quantity="one">Θεματικά τραγούδια</item> + <item quantity="other">Θεματικά τραγούδια</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Θεματικά βίντεο</item> + <item quantity="other">Θεματικά βίντεο</item> </plurals> <string name="next_up">Επόμενο</string> <string name="rewatch_next_up">Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\'</string> @@ -40,7 +42,7 @@ <string name="director">Σκηνοθέτης</string> <string name="studios">Στούντιο</string> <string name="sdr">SDR</string> - <string name="cast_and_crew"><![CDATA[Ηθοποιοί και Συνεργείο]]></string> + <string name="cast_and_crew">Ηθοποιοί και Συνεργείο</string> <string name="more_like_this">Περισσότερα Σαν Αυτό</string> <string name="episodes">Επεισόδια</string> <string name="jump_letters">#ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ</string> @@ -169,27 +171,35 @@ <string name="extras_title">Επιπρόσθετα</string> <plurals name="other_extras"> <item quantity="one">Άλλο</item> + <item quantity="other">Άλλο</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Παρασκήνια</item> + <item quantity="other">Παρασκήνια</item> </plurals> <plurals name="clips"> <item quantity="one">Αποσπάσματα</item> + <item quantity="other">Αποσπάσματα</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Διαγραμμένες σκηνές</item> + <item quantity="other">Διαγραμμένες σκηνές</item> </plurals> <plurals name="interviews"> <item quantity="one">Συνεντεύξεις</item> + <item quantity="other">Συνεντεύξεις</item> </plurals> <plurals name="scenes"> <item quantity="one">Σκηνές</item> + <item quantity="other">Σκηνές</item> </plurals> <plurals name="samples"> <item quantity="one">Δείγματα</item> + <item quantity="other">Δείγματα</item> </plurals> <plurals name="featurettes"> <item quantity="one">Χαρακτηριστικά</item> + <item quantity="other">Χαρακτηριστικά</item> </plurals> <string name="auto_check_for_updates">Αυτόματος έλεγχος για ενημερώσεις</string> <string name="auto_play_next_delay">Καθυστέρηση πριν την έναρξη του επόμενου</string> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index fedecb72..64d8c3b1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,7 +25,7 @@ <string name="director">Rendező</string> <string name="disabled">Letiltva</string> <string name="discovered_servers">Felfedezett szerverek</string> - <string name="download_and_update"><![CDATA[Letöltés & Frissítés]]></string> + <string name="download_and_update">Letöltés & Frissítés</string> <string name="downloading">Letöltés folyamatban…</string> <string name="enabled">Engedélyezve</string> <string name="enter_server_url">Adja meg a szerver IP-t vagy URL-t</string> @@ -95,7 +95,7 @@ <string name="restart">Újraindítás</string> <string name="resume">Folytatás</string> <string name="save">Mentés</string> - <string name="search_and_download"><![CDATA[Keresés & Letöltés]]></string> + <string name="search_and_download">Keresés & Letöltés</string> <string name="search">Keresés</string> <string name="searching">Keresés…</string> <string name="select_server">Szerver kiválasztása</string> @@ -122,6 +122,7 @@ <string name="top_unwatched">Legjobbra értékelt, nem megnézett</string> <plurals name="trailers"> <item quantity="one">Előzetesek</item> + <item quantity="other">Előzetesek</item> </plurals> <string name="tv_dvr_schedule">DVR Időzítés</string> <string name="tv_guide">Műsorújság</string> @@ -159,6 +160,7 @@ </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Színfalak mögött</item> + <item quantity="other">Színfalak mögött</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Főcímdal</item> @@ -352,10 +354,10 @@ <string name="subtitle_delay">Felirat késleltetése</string> <string name="backdrop_display">Háttér stílusa</string> <string name="enter_server_address">Adja meg a szerver címét</string> - <string name="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></string> + <string name="combine_continue_next">Folytatás & Következő menü összevonása</string> <string name="player_backend">Lejátszás backend</string> <string name="image_cache_size">Kép-gyorsítótár mérete (MB)</string> - <string name="cast_and_crew"><![CDATA[Stábtagok]]></string> + <string name="cast_and_crew">Stábtagok</string> <string name="nav_drawer_switch_on_focus">Navigációs menü oldalainak váltása fókusznál</string> <string name="playback_overrides">Lejátszás felülbírálatai</string> <string name="resolution_switching">Felbontásváltás</string> diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index c2c66df5..50efdb0c 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režisors</string> <string name="disabled">Atslēgts</string> <string name="discovered_servers">Atrastie serveri</string> - <string name="download_and_update"><![CDATA[Lejupielādēt & Atjaunināt]]></string> + <string name="download_and_update">Lejupielādēt & Atjaunināt</string> <string name="downloading">Lejupielādē…</string> <string name="enabled">Ieslēgts</string> <string name="ends_at">Beigsies %1$s</string> @@ -65,6 +65,7 @@ <string name="more">Vairāk</string> <plurals name="movies"> <item quantity="zero">Filmas</item> + <item quantity="other">Filmas</item> </plurals> <string name="name">Nosaukums</string> <string name="next_up">Rindā</string> @@ -79,6 +80,7 @@ <string name="path">Ceļš</string> <plurals name="people"> <item quantity="zero">Cilvēki</item> + <item quantity="other">Cilvēki</item> </plurals> <string name="play_count">Skatījumu skaits</string> <string name="play_from_here">Atskaņot no šejienes</string> @@ -103,7 +105,7 @@ <string name="restart">Restartēt</string> <string name="resume">Turpināt</string> <string name="save">Saglabāt</string> - <string name="search_and_download"><![CDATA[Meklēt & Lejupielādēt]]></string> + <string name="search_and_download">Meklēt & Lejupielādēt</string> <string name="search">Meklēt</string> <string name="searching">Meklē…</string> <string name="voice_search">Balss meklēšana</string> @@ -148,6 +150,7 @@ <string name="top_unwatched">Augstāk novērtētie un neredzētie</string> <plurals name="trailers"> <item quantity="zero">Treileri</item> + <item quantity="other">Treileri</item> </plurals> <string name="tv_dvr_schedule">Ierakstu plāns</string> <string name="tv_guide">Programma</string> @@ -155,6 +158,7 @@ <string name="tv_seasons">Sezonas</string> <plurals name="tv_shows"> <item quantity="zero">TV Šovi</item> + <item quantity="other">TV Šovi</item> </plurals> <string name="ui_interface">Saskarne</string> <string name="unknown">Nezināms</string> @@ -184,36 +188,47 @@ <string name="extras_title">Ekstras</string> <plurals name="other_extras"> <item quantity="zero">Cits</item> + <item quantity="other">Cits</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="zero">Aizkadri</item> + <item quantity="other">Aizkadri</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">Tituldziesmas</item> + <item quantity="other">Tituldziesmas</item> </plurals> <plurals name="theme_videos"> <item quantity="zero">Titulvideo</item> + <item quantity="other">Titulvideo</item> </plurals> <plurals name="clips"> <item quantity="zero">Klipi</item> + <item quantity="other">Klipi</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">Izgrieztās ainas</item> + <item quantity="other">Izgrieztās ainas</item> </plurals> <plurals name="interviews"> <item quantity="zero">Intervijas</item> + <item quantity="other">Intervijas</item> </plurals> <plurals name="scenes"> <item quantity="zero">Ainas</item> + <item quantity="other">Ainas</item> </plurals> <plurals name="samples"> <item quantity="zero">Paraugi</item> + <item quantity="other">Paraugi</item> </plurals> <plurals name="featurettes"> <item quantity="zero">Īssižeti</item> + <item quantity="other">Īssižeti</item> </plurals> <plurals name="shorts"> <item quantity="zero">Īsie rullīši</item> + <item quantity="other">Īsie rullīši</item> </plurals> <string name="favorite_items_title">Izlase %s</string> <plurals name="downloads"> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 59793adf..9ddb6c77 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -27,7 +27,7 @@ <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Oppdagede servere</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Laster ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi server-IP eller URL</string> @@ -94,7 +94,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Fortsett</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Velg server</string> @@ -121,6 +121,7 @@ <string name="top_unwatched">Best vurderte usette</string> <plurals name="trailers"> <item quantity="one">Trailere</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> @@ -155,36 +156,47 @@ <string name="extras_title">Ekstramateriale</string> <plurals name="other_extras"> <item quantity="one">Annet</item> + <item quantity="other">Annet</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissene</item> + <item quantity="other">Bak kulissene</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasanger</item> + <item quantity="other">Temasanger</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoer</item> + <item quantity="other">Temavideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Slettede scener</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Smakebiter</item> + <item quantity="other">Smakebiter</item> </plurals> <plurals name="featurettes"> <item quantity="one">Bakomfilmer</item> + <item quantity="other">Bakomfilmer</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmer</item> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedlasting</item> @@ -210,7 +222,7 @@ <string name="auto_play_next">Spill neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringer</string> <string name="combine_continue_next_summary">Gjelder kun TV-serier</string> - <string name="combine_continue_next"><![CDATA[Slå sammen \'Fortsett å se\' og \'Neste\']]></string> + <string name="combine_continue_next">Slå sammen \'Fortsett å se\' og \'Neste\'</string> <string name="direct_play_ass">Direkteavspilling av ASS-undertekster</string> <string name="direct_play_pgs">Direkteavspilling av PGS-undertekster</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> @@ -295,7 +307,7 @@ <string name="aspect_ratio">Bildeforhold</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Bildetype</string> - <string name="cast_and_crew"><![CDATA[Skuespillere og medarbeidere]]></string> + <string name="cast_and_crew">Skuespillere og medarbeidere</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Bytting av bildeoppdateringsfrekvens</string> diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 5917854a..55e08a8d 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -32,7 +32,7 @@ <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Funne servarar</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Lastar ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi servar-IP eller URL</string> @@ -98,7 +98,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Hald fram</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Vel servar</string> @@ -124,6 +124,7 @@ <string name="top_unwatched">Best vurderte usette</string> <plurals name="trailers"> <item quantity="one">Trailerar</item> + <item quantity="other">Trailerar</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> @@ -158,36 +159,47 @@ <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Anna</item> + <item quantity="other">Anna</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissane</item> + <item quantity="other">Bak kulissane</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasongar</item> + <item quantity="other">Temasongar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoar</item> + <item quantity="other">Temavideoar</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Sletta sener</item> + <item quantity="other">Sletta sener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervju</item> + <item quantity="other">Intervju</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Smakebitar</item> + <item quantity="other">Smakebitar</item> </plurals> <plurals name="featurettes"> <item quantity="one">Bakomfilmar</item> + <item quantity="other">Bakomfilmar</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmar</item> + <item quantity="other">Kortfilmar</item> </plurals> <string name="advanced_settings">Avanserte innstillingar</string> <string name="advanced_ui">Avansert grensesnitt</string> @@ -197,7 +209,7 @@ <string name="auto_play_next">Spel neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringar</string> <string name="combine_continue_next_summary">Gjeld berre TV-seriar</string> - <string name="combine_continue_next"><![CDATA[Slå saman \'Hald fram med å sjå\' og \'Neste\']]></string> + <string name="combine_continue_next">Slå saman \'Hald fram med å sjå\' og \'Neste\'</string> <string name="direct_play_ass">Direkteavspeling av ASS-undertekstar</string> <string name="direct_play_pgs">Direkteavspeling av PGS-undertekstar</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> @@ -281,7 +293,7 @@ <string name="aspect_ratio">Bileteforhold</string> <string name="show_details">Vis detaljar</string> <string name="image_type">Bilettype</string> - <string name="cast_and_crew"><![CDATA[Skodespelarar og medarbeidarar]]></string> + <string name="cast_and_crew">Skodespelarar og medarbeidarar</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstorleik</string> <string name="refresh_rate_switching">Byting av bildeoppdateringsfrekvens</string> @@ -507,12 +519,12 @@ <string name="image_type_thumb">Miniatyr</string> <string name="backdrop_style_dynamic">Bilete med dynamisk farge</string> <string name="backdrop_style_image">Berre bilete</string> - <string name="enter_url_api_key"><![CDATA[Skriv inn URL og API-nøkkel]]></string> + <string name="enter_url_api_key">Skriv inn URL og API-nøkkel</string> <string name="api_key">API-nøkkel</string> <string name="seerr_login">Logg inn på Seerr-tenar</string> <string name="seerr_jellyfin_user">Jellyfin-brukar</string> <string name="seerr_local_user">Lokal brukar</string> - <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekstar]]></string> + <string name="search_and_download_subtitles">Søk og last ned undertekstar</string> <string name="no_subtitles_found">Fann ingen eksterne undertekstar</string> <string name="series_continueing">No</string> <string name="in_app_screensaver">Bruk skjermsparar i appen</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c446b6f9..ffee134b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -563,18 +563,19 @@ <string name="mixer">Mixagem</string> <plurals name="featurettes"> <item quantity="one">Bastidores</item> + <item quantity="other">Bastidores</item> </plurals> <plurals name="minutes"> <item quantity="one">%s minuto</item> <item quantity="many">%s minutos</item> <item quantity="other">%s minutos</item> </plurals> - <string name="enter_url_api_key"><![CDATA[Insira a URL e a chave de API]]></string> - <string name="search_and_download_subtitles"><![CDATA[Procurar e baixar legendas]]></string> - <string name="download_and_update"><![CDATA[Baixar e atualizar]]></string> - <string name="search_and_download"><![CDATA[Procurar e baixar]]></string> - <string name="combine_continue_next"><![CDATA[Combinar \"Continue Assistindo\" e \"A Seguir\"]]></string> - <string name="cast_and_crew"><![CDATA[Elenco e equipe]]></string> + <string name="enter_url_api_key">Insira a URL e a chave de API</string> + <string name="search_and_download_subtitles">Procurar e baixar legendas</string> + <string name="download_and_update">Baixar e atualizar</string> + <string name="search_and_download">Procurar e baixar</string> + <string name="combine_continue_next">Combinar \"Continue Assistindo\" e \"A Seguir\"</string> + <string name="cast_and_crew">Elenco e equipe</string> <string name="albums">Álbuns</string> <string name="artists">Artistas</string> <string name="songs">Músicas</string> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index dd2e07e2..d6a2d917 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -42,6 +42,7 @@ <string name="no_update_available">Nu este disponibilă nicio actualizare</string> <plurals name="movies"> <item quantity="one">Filme</item> + <item quantity="other">Filme</item> </plurals> <string name="library">Librărie</string> <string name="go_to">Mergi la</string> @@ -53,6 +54,7 @@ <string name="mark_watched">Marchează ca vizionat</string> <plurals name="people"> <item quantity="one">Persoane</item> + <item quantity="other">Persoane</item> </plurals> <string name="play_count">Număr de redări</string> <string name="more">Mai mult</string> @@ -84,7 +86,7 @@ <string name="aspect_ratio">Rată de aspect</string> <string name="show_details">Înregistrare detaliată</string> <string name="image_type">Tip imagine</string> - <string name="cast_and_crew"><![CDATA[Distribuție și echipă]]></string> + <string name="cast_and_crew">Distribuție și echipă</string> <string name="guest_stars">Vedete invitate</string> <string name="level">Nivel</string> <string name="channels">Canale</string> @@ -169,7 +171,7 @@ <string name="api_key">Cheie API</string> <string name="seerr_login">Conectează-te la serverul Seerr</string> <string name="discovered_servers">Servere descoperite</string> - <string name="download_and_update"><![CDATA[Descarcă & Actualizează]]></string> + <string name="download_and_update">Descarcă & Actualizează</string> <string name="commercial">Reclamă</string> <string name="community_rating">Evaluarea comunității</string> <string name="compose_error_message_title">A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău.</string> @@ -211,6 +213,7 @@ <string name="tv_seasons">Sezoane</string> <plurals name="tv_shows"> <item quantity="one">Seriale TV</item> + <item quantity="other">Seriale TV</item> </plurals> <string name="ui_interface">Interfață</string> <string name="unknown">Necunoscut</string> @@ -224,6 +227,7 @@ <string name="one_click_pause">Pauză cu un click</string> <plurals name="interviews"> <item quantity="one">Interviuri</item> + <item quantity="other">Interviuri</item> </plurals> <string name="background">Fundal</string> <plurals name="downloads"> @@ -291,7 +295,7 @@ <string name="skip_ask">Cere să sari peste</string> <string name="seerr_jellyfin_user">Utilizator Jellyfin</string> <string name="seerr_local_user">Utilizator local</string> - <string name="search_and_download_subtitles"><![CDATA[Caută & descarcă subtitrări]]></string> + <string name="search_and_download_subtitles">Caută & descarcă subtitrări</string> <string name="composer">Compozitor</string> <string name="writer">Scriitor</string> <string name="lyrics">Versuri</string> @@ -313,7 +317,7 @@ <string name="playback_debug_info">Afișează informații de depanare privind redarea</string> <string name="only_forced_subtitles">Doar subtitrări forțate</string> <string name="save">Salvează</string> - <string name="search_and_download"><![CDATA[Caută & Descarcă]]></string> + <string name="search_and_download">Caută & Descarcă</string> <string name="resume">Reia</string> <string name="voice_starting">Începe</string> <string name="voice_search_prompt">Vorbește pentru a căuta</string> @@ -323,6 +327,7 @@ <string name="top_unwatched">Cele mai bine cotate, nevizionate</string> <plurals name="trailers"> <item quantity="one">Trailere</item> + <item quantity="other">Trailere</item> </plurals> <string name="play_with_transcoding">Redă cu transcodare</string> <string name="media_information">Informații media</string> @@ -330,21 +335,26 @@ <string name="add_to_playlist">Adaugă în lista de redare</string> <plurals name="other_extras"> <item quantity="one">Altele</item> + <item quantity="other">Altele</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">În spatele scenelor</item> + <item quantity="other">În spatele scenelor</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Melodii tematice</item> + <item quantity="other">Melodii tematice</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Scene șterse</item> + <item quantity="other">Scene șterse</item> </plurals> <string name="official_rating">Evaluare parentală</string> <string name="runtime_sort">Timp de execuție</string> <string name="extras_title">Extra</string> <plurals name="clips"> <item quantity="one">Clipuri</item> + <item quantity="other">Clipuri</item> </plurals> <string name="favorite_items_title">%s favorit</string> <string name="app_theme">Temă aplicație</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b9d4ea6e..d1c93f25 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -27,7 +27,7 @@ <string name="directed_by">Режиссёр %1$s</string> <string name="director">Режиссёр</string> <string name="discovered_servers">Обнаруженные серверы</string> - <string name="download_and_update"><![CDATA[Загрузить и Обновить]]></string> + <string name="download_and_update">Загрузить и Обновить</string> <string name="downloading">Скачивание…</string> <string name="enter_server_url">Введите IP или URL сервера</string> <string name="episodes">Эпизоды</string> @@ -73,7 +73,7 @@ <string name="remove_favorite">Убрать из избранного</string> <string name="resume">Продолжить</string> <string name="save">Сохранить</string> - <string name="search_and_download"><![CDATA[Найти и скачать]]></string> + <string name="search_and_download">Найти и скачать</string> <string name="search">Найти</string> <string name="searching">Поиск…</string> <string name="select_server">Выберите сервер</string> @@ -134,6 +134,7 @@ </plurals> <plurals name="behind_the_scenes"> <item quantity="one">За кадром</item> + <item quantity="other">За кадром</item> </plurals> <plurals name="clips"> <item quantity="one">Клип</item> @@ -201,7 +202,7 @@ <string name="watch_live">Смотреть прямой эфир</string> <string name="aired_episode_order">Порядок выхода эпизодов</string> <string name="reset">Сброс</string> - <string name="cast_and_crew"><![CDATA[Снимались и снимали]]></string> + <string name="cast_and_crew">Снимались и снимали</string> <string name="guest_stars">Приглашённые звёзды</string> <string name="aspect_ratio">Соотношение сторон</string> <string name="show_details">Показать подробности</string> @@ -273,7 +274,7 @@ <string name="auto_play_next_delay">Задержка перед воспроизведением следующей серии</string> <string name="auto_play_next">Автовоспроизведение следующей серии</string> <string name="combine_continue_next_summary">Применяется только к Телешоу</string> - <string name="combine_continue_next"><![CDATA[Объединить Продолжить просмотр и Далее]]></string> + <string name="combine_continue_next">Объединить Продолжить просмотр и Далее</string> <string name="direct_play_ass">Прямое воспроизведение ASS-субтитров</string> <string name="direct_play_pgs">Прямое воспроизведение PGS-субтитров</string> <string name="font_size">Размер шрифта</string> @@ -297,9 +298,11 @@ </plurals> <plurals name="samples"> <item quantity="one">Отрывки</item> + <item quantity="other">Отрывки</item> </plurals> <plurals name="featurettes"> <item quantity="one">Дополнительные материалы</item> + <item quantity="other">Дополнительные материалы</item> </plurals> <string name="player_backend">Выбор плеера</string> <string name="edge_size">Толщина обводки</string> @@ -308,9 +311,11 @@ <string name="forced_track">Форсированные</string> <plurals name="theme_songs"> <item quantity="one">Саундтреки</item> + <item quantity="other">Саундтреки</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Видеоролики</item> + <item quantity="other">Видеоролики</item> </plurals> <string name="nav_drawer_pins">Настроить элементы боковой панели</string> <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> @@ -325,6 +330,7 @@ <string name="pass_out_protection">Остановка воспроизведения при бездействии</string> <plurals name="shorts"> <item quantity="one">Короткометражки</item> + <item quantity="other">Короткометражки</item> </plurals> <string name="ends_at">Конец в %1$s</string> <string name="enter_server_address">Введите адрес сервера</string> diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index e844ce79..46ec0b68 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režiser</string> <string name="disabled">Onemogočeno</string> <string name="discovered_servers">Najdeni strežniki</string> - <string name="download_and_update"><![CDATA[Prenesi in posodobi]]></string> + <string name="download_and_update">Prenesi in posodobi</string> <string name="downloading">Prenos poteka…</string> <string name="enabled">Omogočeno</string> <string name="ends_at">Konča se ob %1$s</string> @@ -66,6 +66,7 @@ <string name="more">Več</string> <plurals name="movies"> <item quantity="one">Filmi</item> + <item quantity="other">Filmi</item> </plurals> <string name="name">Ime</string> <string name="next_up">Naslednjo</string> @@ -80,6 +81,7 @@ <string name="path">Pot</string> <plurals name="people"> <item quantity="one">Osebe</item> + <item quantity="other">Osebe</item> </plurals> <string name="play_count">Število predvajanj</string> <string name="play_from_here">Predvajaj od tu</string> @@ -104,7 +106,7 @@ <string name="restart">Zaženi znova</string> <string name="resume">Nadaljuj</string> <string name="save">Shrani</string> - <string name="search_and_download"><![CDATA[Poišči in prenesi]]></string> + <string name="search_and_download">Poišči in prenesi</string> <string name="search">Išči</string> <string name="searching">Iskanje…</string> <string name="voice_search">Glasovno iskanje</string> @@ -148,6 +150,7 @@ <string name="top_unwatched">Najbolje ocenjeno, neogledano</string> <plurals name="trailers"> <item quantity="one">Napovedniki</item> + <item quantity="other">Napovedniki</item> </plurals> <string name="tv_dvr_schedule">Program snemalnika</string> <string name="tv_guide">Vodnik</string> @@ -155,6 +158,7 @@ <string name="tv_seasons">Sezone</string> <plurals name="tv_shows"> <item quantity="one">TV oddaje</item> + <item quantity="other">TV oddaje</item> </plurals> <string name="ui_interface">Vmesnik</string> <string name="unknown">Neznano</string> @@ -184,36 +188,47 @@ <string name="extras_title">Dodatki</string> <plurals name="other_extras"> <item quantity="one">Ostalo</item> + <item quantity="other">Ostalo</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zakulisju</item> + <item quantity="other">V zakulisju</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Tematske pesmi</item> + <item quantity="other">Tematske pesmi</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tematski videi</item> + <item quantity="other">Tematski videi</item> </plurals> <plurals name="clips"> <item quantity="one">Posnetki</item> + <item quantity="other">Posnetki</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Izbrisane scene</item> + <item quantity="other">Izbrisane scene</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuji</item> + <item quantity="other">Intervjuji</item> </plurals> <plurals name="scenes"> <item quantity="one">Prizori</item> + <item quantity="other">Prizori</item> </plurals> <plurals name="samples"> <item quantity="one">Vzorci</item> + <item quantity="other">Vzorci</item> </plurals> <plurals name="featurettes"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> <plurals name="shorts"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> <string name="favorite_items_title">Dodaj %s med priljubljene</string> <plurals name="downloads"> @@ -261,7 +276,7 @@ <string name="auto_play_next">Samodejno predvajanje naslednjega</string> <string name="check_for_updates">Preveri za posodobitve</string> <string name="combine_continue_next_summary">Velja samo za televizijske serije</string> - <string name="combine_continue_next"><![CDATA[Združi »Nadaljuj z ogledom« in »Naslednje«]]></string> + <string name="combine_continue_next">Združi »Nadaljuj z ogledom« in »Naslednje«</string> <string name="direct_play_ass">Za podnapise v formatu ASS uporabite knjižnico libass</string> <string name="direct_play_pgs">Neposredno predvajanje podnapisov PGS</string> <string name="downmix_stereo">Vedno pretvori v stereo</string> @@ -345,7 +360,7 @@ <string name="aspect_ratio">Razmerje stranic</string> <string name="show_details">Pokaži podrobnosti</string> <string name="image_type">Vrsta slike</string> - <string name="cast_and_crew"><![CDATA[Igralska zasedba in ekipa]]></string> + <string name="cast_and_crew">Igralska zasedba in ekipa</string> <string name="guest_stars">Gostujoči igralci</string> <string name="edge_size">Velikost roba</string> <string name="refresh_rate_switching">Preklapljanje hitrosti osveževanja</string> @@ -542,12 +557,12 @@ <string name="image_type_thumb">Thumb</string> <string name="backdrop_style_dynamic">Slika z dinamičnimi barvami</string> <string name="backdrop_style_image">Samo slika</string> - <string name="enter_url_api_key"><![CDATA[Vnesite URL in API ključ]]></string> + <string name="enter_url_api_key">Vnesite URL in API ključ</string> <string name="api_key">API ključ</string> <string name="seerr_login">Prijava na strežnik Seerr</string> <string name="seerr_jellyfin_user">Jellyfin uporabnik</string> <string name="seerr_local_user">Lokalni uporabnik</string> - <string name="search_and_download_subtitles"><![CDATA[Išči in prenesi podnapise]]></string> + <string name="search_and_download_subtitles">Išči in prenesi podnapise</string> <string name="no_subtitles_found">Ni bilo najdenih podnapisov</string> <string name="series_continueing">danes</string> <string name="in_app_screensaver">Uporabite ohranjevalnik zaslona aplikacije</string> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 01c09b61..a33ef920 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -75,6 +75,7 @@ <string name="top_unwatched">Topprankade osedda</string> <plurals name="trailers"> <item quantity="one">Förhandsvisningar</item> + <item quantity="other">Förhandsvisningar</item> </plurals> <string name="tv_dvr_schedule">DVR-schema</string> <string name="tv_guide">TV Guide</string> @@ -108,36 +109,47 @@ <string name="extras_title">Extramaterial</string> <plurals name="other_extras"> <item quantity="one">Annat</item> + <item quantity="other">Annat</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bakom kulisserna</item> + <item quantity="other">Bakom kulisserna</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temalåtar</item> + <item quantity="other">Temalåtar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideor</item> + <item quantity="other">Temavideor</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Borttagna scener</item> + <item quantity="other">Borttagna scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Exempel</item> + <item quantity="other">Exempel</item> </plurals> <plurals name="featurettes"> <item quantity="one">Specialinslag</item> + <item quantity="other">Specialinslag</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmer</item> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedladdning</item> @@ -233,7 +245,7 @@ <string name="disable_if_crash">Inaktivera om du upplever krascher</string> <string name="mpv_options">MPV inställningar</string> <string name="exoplayer_options">ExoPlayer inställningar</string> - <string name="download_and_update"><![CDATA[Ladda ner och uppdatera]]></string> + <string name="download_and_update">Ladda ner och uppdatera</string> <string name="skip_segment_intro">Hoppa över intro</string> <string name="skip_segment_outro">Hoppa över eftertexter</string> <string name="skip_segment_unknown">Hoppa över segment</string> @@ -364,8 +376,8 @@ <string name="skip_ask">Fråga om att hoppa över</string> <string name="skip_automatically">Hoppa över automatiskt</string> <string name="send_app_logs_summary">Användbart för felsökning</string> - <string name="search_and_download_subtitles"><![CDATA[Sök och ladda ner undertexter]]></string> - <string name="search_and_download"><![CDATA[Sök och ladda ner]]></string> + <string name="search_and_download_subtitles">Sök och ladda ner undertexter</string> + <string name="search_and_download">Sök och ladda ner</string> <string name="saturation">Mättnad</string> <string name="save_for_album">Spara till album</string> <string name="save_to_server">Spara till serverns användarprofil</string> @@ -445,7 +457,7 @@ <string name="view_options_type_grid">Rutnät</string> <string name="view_options_type_list">Lista</string> <string name="view_options_type_list_compact">Kompakt lista</string> - <string name="enter_url_api_key"><![CDATA[Ange URL och API-nyckel]]></string> + <string name="enter_url_api_key">Ange URL och API-nyckel</string> <string name="no_subtitles_found">Inga externa undertexter hittades</string> <string name="in_app_screensaver">Använd skärmsläckare i appen</string> <string name="lyricist">Textförfattare</string> @@ -503,13 +515,13 @@ <string name="no_servers_found">Inga servrar hittades</string> <string name="only_forced_subtitles">Endast tvingade undertexter</string> <string name="no_trailers">Inga trailrar</string> - <string name="combine_continue_next"><![CDATA[Kombinera Fortsätt titta och kommande avsnitt]]></string> + <string name="combine_continue_next">Kombinera Fortsätt titta och kommande avsnitt</string> <string name="framerate">Bildfrekvens</string> <string name="bit_depth">Bitdjup</string> <string name="video_range">Videoomfång</string> <string name="video_range_type">Typ av videoomfång</string> <string name="color_space">Färgrymd</string> - <string name="cast_and_crew"><![CDATA[Rollista och team]]></string> + <string name="cast_and_crew">Rollista och team</string> <string name="genres_in">Genrer i %1$s</string> <string name="remove_continue_watching">Ta bort från Fortsätt titta</string> <string name="local">Lokal</string> diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt index 5988d5d6..3998201f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.content.res.Resources -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.successResponse @@ -47,12 +46,9 @@ class ExtrasServiceTest { every { mockApiClient.userLibraryApi } returns mockUserLibraryApi every { mockContext.resources } returns mockResources - every { mockResources.getQuantityString(R.plurals.interviews, any()) } returns "Interviews" - every { mockResources.getQuantityString(R.plurals.clips, any()) } returns "Clips" - every { mockResources.getQuantityString(R.plurals.interviews, any(), any<Int>()) } returns "Interviews" - every { mockResources.getQuantityString(R.plurals.clips, any(), any<Int>()) } returns "Clips" - - every { mockResources.getQuantityString(R.plurals.items, any(), any<Int>()) } returns "Items" + every { mockResources.getString(any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any(), any<Int>()) } returns "Interviews" every { mockImageUrlService.getItemImageUrl(any<BaseItem>(), ImageType.PRIMARY, any(), any(), any()) } returns "https://localhost/image.jpg" From b1f9c32a195629a2a864a830e25e5e4822d70e9c Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 20 Jul 2026 10:04:34 -0400 Subject: [PATCH 167/475] Parse intents to search, view, or play media (#1688) ## Description Adds parsing for intents to search, view, or play media based on the action and parameters. This allows for remotely opening Wholphin and starting playback. This is considered experimental, so the syntax or behaviors may change! See the `Intents.md` file in this PR for syntax and examples. ### Related issues Replaces https://github.com/damontecres/Wholphin/pull/738 Closes https://github.com/damontecres/Wholphin/discussions/1095 ### Testing Emulator with the test commands in the docs ## Screenshots N/A ## AI or LLM usage None --- Intents.md | 151 ++++++++++++++++ app/src/main/AndroidManifest.xml | 7 + .../damontecres/wholphin/MainActivity.kt | 129 +++++++------- .../wholphin/data/model/JellyfinServer.kt | 2 + .../wholphin/services/IntentService.kt | 164 ++++++++++++++++++ .../services/tvprovider/TvProviderWorker.kt | 25 +-- .../wholphin/ui/main/SearchPage.kt | 3 +- .../wholphin/ui/nav/Destination.kt | 7 +- .../wholphin/ui/nav/DestinationContent.kt | 3 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 2 +- 10 files changed, 415 insertions(+), 78 deletions(-) create mode 100644 Intents.md create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt diff --git a/Intents.md b/Intents.md new file mode 100644 index 00000000..a083ae7a --- /dev/null +++ b/Intents.md @@ -0,0 +1,151 @@ +# Intents + +> [!WARNING] +> This feature is only available in the develop builds until the next release! + +> [!IMPORTANT] +> This feature is experimental and the exact behaviors and/or parameters may change at any time + +Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media. + +Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`). + +UUID parameters can be specified with or without hyphens. + +If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...` + +## Server/user switching + +You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it. + +If not specified, the app will use the current user if auto sign in is enabled. + +## Search + +Action: `android.intent.action.SEARCH` + +Shorthand action: `search` + +Parameters: +- `query` - Optional, prepopulate the search field + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query "Avengers" +``` + +URI based +```bash +adb shell am start -d 'wholphin://search?query=Avengers' +``` + +Multi-word query: +```bash +# Whole shell command is wrapped in double quotes +adb shell "am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query 'Avengers Endgame'" + +adb shell am start -d 'wholphin://search?query=Avengers+Endgame' +adb shell am start -d 'wholphin://search?query=Avengers%20Endgame' +``` + +## View + +This opens the app to the specified item such as an episode or movie + +Action: `android.intent.action.VIEW` + +Shorthand action: `view` + +Parameters: +- `itemId` - Required, the UUID of the media item + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +## Playback + +Action: `com.github.damontecres.wholphin.PLAYBACK` + +Shorthand action: `play` + +Parameters: +- `itemId` - Required, the UUID of the media item +- `position` - Optional, the start position for playback in milliseconds + +### Examples + +Full format +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +Start at 4m30s: +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --el position 270000 + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000' +``` + +## More examples + +### Switch user + +Full format +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587' +``` + +### Debug build + +Debug builds have a different package, so you must specify the component by its full name: + +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64e7493c..42404590 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,13 @@ <category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <action android:name="com.github.damontecres.wholphin.PLAYBACK" /> + + <category android:name="android.intent.category.DEFAULT" /> + <data android:scheme="wholphin" /> + </intent-filter> </activity> <provider 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 229fd378..9c9a3eb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -36,6 +36,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver @@ -54,7 +56,6 @@ import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.collectLatestIn 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.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel @@ -80,7 +81,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -145,6 +145,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var screensaverService: ScreensaverService + @Inject + lateinit var intentService: IntentService + private var signInAuto = true private var playerBackend: PlayerBackend? = null @@ -187,8 +190,7 @@ class MainActivity : AppCompatActivity() { } navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) } else { - val startDestination = intent?.let(::extractDestination) ?: Destination.Home() - navigationManager.backStack = NavBackStack(startDestination) + navigationManager.backStack = NavBackStack(Destination.Home()) } viewModel.serverRepository.currentUserFlow @@ -223,7 +225,7 @@ class MainActivity : AppCompatActivity() { playerBackend = prefs.playbackPreferences.playerBackend } - viewModel.appStart() + viewModel.appStart(intent) setContent { Surface( Modifier @@ -307,7 +309,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart() + viewModel.appStart(null) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -384,42 +386,9 @@ class MainActivity : AppCompatActivity() { super.onNewIntent(intent) Timber.v("onNewIntent") setIntent(intent) - extractDestination(intent)?.let { - navigationManager.replace(it) - } + viewModel.appStart(intent) } - private fun extractDestination(intent: Intent): Destination? = - intent.let { - val itemId = - it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() - val type = - it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) - if (itemId != null && type != null) { - val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() - val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() - val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) - val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) - if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { - Destination.SeriesOverview( - itemId = seriesId, - type = BaseItemKind.SERIES, - seasonEpisode = - SeasonEpisodeIds( - seasonId = seasonId, - seasonNumber = seasonNumber, - episodeId = itemId, - episodeNumber = episodeNumber, - ), - ) - } else { - Destination.MediaItem(itemId, type) - } - } else { - null - } - } - fun changeDisplayMode(modeId: Int) { lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes @@ -431,13 +400,6 @@ class MainActivity : AppCompatActivity() { } companion object { - const val INTENT_ITEM_ID = "itemId" - const val INTENT_ITEM_TYPE = "itemType" - const val INTENT_SERIES_ID = "seriesId" - const val INTENT_EPISODE_NUMBER = "epNum" - const val INTENT_SEASON_NUMBER = "seaNum" - const val INTENT_SEASON_ID = "seaId" - private const val KEY_BACK_STACK = "backStack" private const val KEY_EXTERNAL_PLAYER = "extPlayer" @@ -453,16 +415,55 @@ class MainActivityViewModel @param:ApplicationContext private val context: Context, private val preferences: DataStore<AppPreferences>, val serverRepository: ServerRepository, - private val navigationManager: SetupNavigationManager, + private val setupNavigationManager: SetupNavigationManager, + private val navigationManager: NavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, + private val intentService: IntentService, ) : ViewModel() { private val mutex = Mutex() - fun appStart() { + fun appStart(intent: Intent?) { viewModelScope.launchDefault { mutex.withLock { + try { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, "Invalid intent: ${result.message}") + return@withLock + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + navigationManager.replace(result.destination) + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return@withLock + } + + IntentResult.NoOp, + null, + -> { + // No-op, proceed below + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error parsing intent") + } + try { val needUpgrade = appUpgradeHandler.needUpgrade() if (needUpgrade) { @@ -479,9 +480,9 @@ class MainActivityViewModel val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val profileProtected = - serverRepository.current.value?.user?.let { - it.hasPin || it.requireLogin - } == true + serverRepository.current.value + ?.user + ?.isProtected == true if (prefs.signInAutomatically && !profileProtected) { val current = serverRepository.restoreSession( @@ -490,38 +491,46 @@ class MainActivityViewModel ) if (current != null) { if (current.user.hasPin || current.user.requireLogin) { - navigationManager.navigateTo(SetupDestination.UserList(current.server)) + setupNavigationManager.navigateTo( + SetupDestination.UserList( + current.server, + ), + ) } else { // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + setupNavigationManager.navigateTo( + SetupDestination.AppContent( + current, + ), + ) } } else { // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.Loading) + setupNavigationManager.navigateTo(SetupDestination.Loading) backdropService.clearBackdrop() val currentServerId = prefs.currentServerId?.toUUIDOrNull() if (currentServerId != null) { val currentServer = serverRepository.serverDao.getServer(currentServerId)?.server if (currentServer != null) { - navigationManager.navigateTo( + setupNavigationManager.navigateTo( SetupDestination.UserList( currentServer, ), ) } else { - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } } catch (ex: Exception) { Timber.e(ex, "Error during appStart") - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 44c19b4a..4c54311c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -71,6 +71,8 @@ data class JellyfinUser( ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() + val isProtected: Boolean get() = hasPin || requireLogin + override fun toString(): String = "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt new file mode 100644 index 00000000..0a043506 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -0,0 +1,164 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.nav.Destination +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class IntentService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + suspend fun parseIntent(intent: Intent): IntentResult { + Timber.v("Parsing intent %s", intent) + Timber.v("Intent extras: %s", intent.extras) + Timber.v("Intent data: %s", intent.data) + if (intent.getStringParam("type") != null) { + return getDestinationFromChannel(intent)?.let { IntentResult.Target(it) } + ?: IntentResult.Error("Invalid parameters") + } + val action = intent.action ?: intent.data?.host + if (action == Intent.ACTION_MAIN || action.isNullOrBlank()) { + return IntentResult.NoOp + } + + val userId = intent.getStringParam("userId")?.toUUIDOrNull() + val serverId = intent.getStringParam("serverId")?.toUUIDOrNull() + if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = + serverRepository.serverDao.getUser(serverId, userId) + ?: return IntentResult.Error("User not found") + if (!user.isProtected) { + serverRepository.restoreSession(serverId, userId) + } else { + return IntentResult.Error("Cannot switch to specified user") + } + } else if (serverRepository.currentUser == null) { + Timber.v("No current user, so restoring last") + val appPrefs = userPreferencesService.flow.first().appPreferences + if (appPrefs.signInAutomatically) { + val userId = appPrefs.currentUserId.toUUIDOrNull() + val serverId = appPrefs.currentServerId.toUUIDOrNull() + if (userId != null && serverId != null) { + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + } else { + return IntentResult.Error("Could not auto sign-in, specify a server & user") + } + } else { + return IntentResult.Error("Auto sign-in not enabled, specify a server & user") + } + } + + if (action == Intent.ACTION_SEARCH || action == "search") { + val query = intent.getStringParam(SearchManager.QUERY) + return IntentResult.Target(Destination.Search(query ?: "")) + } + val itemId = + intent.getStringParam("itemId")?.toUUIDOrNull() + ?: return IntentResult.Error("No item id provided") + + val item = + try { + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching item %s", itemId) + return IntentResult.Error("Could not fetch item $itemId") + } + + val destination = + when (action) { + Intent.ACTION_VIEW, "view" -> { + item.destination() + } + + "com.github.damontecres.wholphin.PLAYBACK", "play" -> { + val position = intent.getLongParam("position")?.coerceAtLeast(0) + Destination.Playback( + itemId = itemId, + positionMs = position ?: 0L, + ) + } + + else -> { + return IntentResult.Error("Invalid action: ${intent.action}") + } + } + return IntentResult.Target(destination) + } + + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) + + private fun Intent.getLongParam(key: String) = + getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull() + + private fun getDestinationFromChannel(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } + } + +sealed interface IntentResult { + data class Error( + val message: String, + ) : IntentResult + + data object NoOp : IntentResult + + data class Target( + val destination: Destination, + ) : IntentResult +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 0df2193b..d3dbf4c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted @@ -226,27 +227,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } @@ -363,27 +364,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } 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 50c2cc6a..2b54f693 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 @@ -496,6 +496,7 @@ private const val COMBINED_ROW = TAB_ROW + 1 @Composable fun SearchPage( + initialQuery: String, userPreferences: UserPreferences, modifier: Modifier = Modifier, viewModel: SearchViewModel = hiltViewModel(), @@ -513,7 +514,7 @@ fun SearchPage( val voiceSearchButtonVisible = prefs.showVoiceSearchButton // val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf("") } + var query by rememberSaveable { mutableStateOf(initialQuery) } val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } val seerrActive by viewModel.seerrActive.collectAsState(initial = false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index accfd693..2468cd78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -54,10 +54,11 @@ sealed class Destination( ) : Destination(true) @Serializable - data object UserAppPreferences : Destination(true) + data class Search( + val query: String = "", + ) : Destination() - @Serializable - data object Search : Destination() + data object UserAppPreferences : Destination(true) @Serializable data class SeriesOverview( 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 e0f89304..d7756161 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 @@ -351,9 +351,10 @@ fun DestinationContent( LicenseInfo(modifier) } - Destination.Search -> { + is Destination.Search -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( + initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 67fb849d..19f027a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -409,7 +409,7 @@ fun NavDrawer( interactionSource = interactionSource, onClick = { viewModel.setIndex(SEARCH_INDEX) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) }, modifier = Modifier From 2aae7eed38cc1cf9f67994fd80da56aaff1e0d8c Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 20 Jul 2026 10:39:42 -0400 Subject: [PATCH 168/475] Add search intent-filter for #1688 --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 42404590..5a5994ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,7 @@ </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> + <action android:name="android.intent.action.SEARCH" /> <action android:name="com.github.damontecres.wholphin.PLAYBACK" /> <category android:name="android.intent.category.DEFAULT" /> From 0a4ab7a5476830e2bb9143aba8621e477a2ef4e1 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 21 Jul 2026 17:20:59 -0400 Subject: [PATCH 169/475] Several UI fixes (#1730) ## Description - Fixes not scrolling to the last season on series overview if there is a Specials season - Apply the "bouncing" fix from #1543 to all applicable card types - Fix card images not automatically reloading when changing image type on grid pages - Get rid of the sticky headers which overlap items on view options dialogs and use a fixed header as elsewhere in the app ### Related issues Related to #1708 which didn't introduce the bug, but made it much more likely to occur ### Testing Emulator & unit tests ## Screenshots N/A, the sticky header UI changes are very minor ## AI or LLM usage None --- .../wholphin/ui/cards/BannerCard.kt | 13 +--- .../wholphin/ui/cards/DiscoverItemCard.kt | 12 +--- .../wholphin/ui/cards/EpisodeCard.kt | 12 +--- .../damontecres/wholphin/ui/cards/GridCard.kt | 12 +--- .../wholphin/ui/cards/ItemCardImage.kt | 2 +- .../wholphin/ui/cards/PersonCard.kt | 24 +------ .../wholphin/ui/cards/SlidingCardText.kt | 46 +++++++++++++ .../wholphin/ui/cards/ViewMoreCard.kt | 13 +--- .../ui/components/ViewOptionsDialog.kt | 69 +++++++++++-------- .../detail/livetv/LiveTvViewOptionsDialog.kt | 59 +++++++++------- .../ui/detail/series/SeriesViewModel.kt | 2 +- .../test/TestFindIndexByNumberOrId.kt | 25 +++++++ 12 files changed, 159 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt 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 8d00edfd..d4d2ac50 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -219,13 +218,11 @@ fun BannerCardWithTitle( useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = cardHeight * aspectRationToUse Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.width(width), ) { BannerCard( @@ -245,13 +242,7 @@ fun BannerCardWithTitle( imageContentScale = imageContentScale, useSeriesForPrimary = useSeriesForPrimary, ) - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = title ?: "", style = MaterialTheme.typography.bodyLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index a6476bd5..20cab060 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -59,8 +59,6 @@ fun DiscoverItemCard( width: Dp = Cards.height2x3 * AspectRatios.TALL, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -78,7 +76,7 @@ fun DiscoverItemCard( } Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.size(width, Dp.Unspecified), ) { Card( @@ -180,13 +178,7 @@ fun DiscoverItemCard( } } } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 78080c3a..3cad76ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -51,8 +51,6 @@ fun EpisodeCard( ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -74,7 +72,7 @@ fun EpisodeCard( val density = LocalDensity.current val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.size(width, height), ) { Card( @@ -126,13 +124,7 @@ fun EpisodeCard( } } } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = dto?.seriesName ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 5cf16a61..f49f2d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -52,8 +52,6 @@ fun GridCard( ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -71,7 +69,7 @@ fun GridCard( } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -107,13 +105,7 @@ fun GridCard( ) } AnimatedVisibility(showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 63f6bdd8..41a3aca4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -67,7 +67,7 @@ fun ItemCardImage( ) { val imageUrlService = LocalImageUrlService.current val imageUrl = - remember(item) { + remember(item, imageType, fillWidth, fillHeight) { item?.let { imageUrlService.getItemImageUrl( item, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 4e146134..3154d257 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Border @@ -99,10 +95,6 @@ fun PersonCard( focusedAfterDelay = false } - // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") - Column( verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, @@ -170,21 +162,7 @@ fun PersonCard( .clip(CircleShape), ) } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelowState.value.roundToPx() - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) - } - }.fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = name ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt new file mode 100644 index 00000000..90d99281 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt @@ -0,0 +1,46 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Slides the [content] up/down as the card is [focused] or not + * + * This is used for the card's title, moving it down when focused as the card itself scales up + */ +@Composable +fun SlidingCardText( + focused: Boolean, + modifier: Modifier = Modifier, + spaceBetweenFocused: Dp = 4.dp, + spaceBetweenUnfocused: Dp = 12.dp, + content: @Composable ColumnScope.() -> Unit, +) { + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + modifier + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) + } + }.fillMaxWidth(), + content = content, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt index 32265e70..fa7df8d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt @@ -53,8 +53,6 @@ fun ViewMoreCard( showTitle: Boolean = true, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -79,7 +77,7 @@ fun ViewMoreCard( size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -108,14 +106,7 @@ fun ViewMoreCard( } } if (showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .width(width) - .padding(bottom = spaceBelow), - ) { + SlidingCardText(focused) { Text( text = stringResource(R.string.view_more), maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index 4f4d7588..c9c8f98b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource 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.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -74,45 +78,52 @@ fun ViewOptionsDialog( ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS } } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(options, key = { it.title }) { pref -> - pref as AppPreference<ViewOptions, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(viewOptions) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = { pref -> - if (pref == ViewOptions.ViewOptionsReset) { - onViewOptionsChange.invoke(defaultViewOptions) - } - }, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(options, key = { it.title }) { pref -> + pref as AppPreference<ViewOptions, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + if (pref == ViewOptions.ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt index a0971f8e..320ef649 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource 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.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -54,40 +58,47 @@ fun LiveTvViewOptionsDialog( window.setGravity(Gravity.END) window.setDimAmount(0f) } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(liveTvPreferences) { pref -> - pref as AppPreference<AppPreferences, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(preferences, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(liveTvPreferences) { pref -> + pref as AppPreference<AppPreferences, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(preferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + } } } } 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 033ae5f6..202c6a06 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 @@ -746,7 +746,7 @@ suspend fun findIndexByNumberOrId( // Start searching from the target number and choose direction from there val num = list.getBlocking(listIndex)?.indexNumber if (num.lt(targetNum)) { - for (i in listIndex + 1 until list.lastIndex) { + for (i in listIndex + 1 until list.size) { val item = list.getBlocking(i) if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { return i diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt index f50ed223..6fe8bd3a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -28,6 +28,7 @@ class TestFindIndexByNumberOrId { val epS02E03 = create(3, 2) val epS02E04 = create(4, 2) val epS02E05 = create(5, 2) + val epS02E06 = create(6, 2) val epS00E08 = create(8, 0) val epS00E02 = create(2, 0) @@ -70,6 +71,30 @@ class TestFindIndexByNumberOrId { } } + @Test + fun `Test last index with zero start`() = + runTest { + val episodes = + BlockingList.of( + listOf( + create(0, 2), + epS02E01, + epS02E02, + epS02E03, + epS02E04, + epS02E05, + epS02E06, + ), + ) + findIndexByNumberOrId( + targetNum = 6, + targetId = epS02E06.id, + list = BlockingList.of(episodes), + ).let { index -> + Assert.assertEquals(6, index) + } + } + @Test fun `Test missing episodes`() = runTest { From 48005ce67af296fe86af279aff8ec81e4ae06308 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 21 Jul 2026 17:21:07 -0400 Subject: [PATCH 170/475] Updates to chapter card UI (#1729) ## Description Changes the chapter cards to use the chapter's image's aspect ratio instead of forcing the video's aspect ratio. These ratios can be different sometimes often for "dummy" chapters. Also, the font size for the chapter name & position is reduced a bit. ### Related issues N/A ### Testing Emulator ## Screenshots <img width="873" height="193" alt="image" src="https://github.com/user-attachments/assets/e1c0e8a8-2e37-4644-bc99-899e20c4af09" /> ## AI or LLM usage None --- .../wholphin/data/model/Chapter.kt | 28 ++++------ .../wholphin/ui/cards/ChapterCard.kt | 54 +++++++++++++++---- .../wholphin/ui/cards/ChapterRow.kt | 6 +-- .../wholphin/ui/detail/movie/MovieDetails.kt | 3 -- .../ui/detail/movie/MovieViewModel.kt | 2 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../ui/playback/overlay/ChapterRowOverlay.kt | 6 +-- .../ui/playback/overlay/PlaybackOverlay.kt | 3 -- 8 files changed, 57 insertions(+), 47 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 3e07964c..71a300fb 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 @@ -1,38 +1,30 @@ package com.github.damontecres.wholphin.data.model -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration /** * Represents a chapter within a video */ data class Chapter( + val itemId: UUID, val name: String?, val position: Duration, - val imageUrl: String?, + val tag: String?, + val index: Int, ) { companion object { - fun fromDto( - dto: BaseItemDto, - api: ApiClient, - ): List<Chapter> = + fun fromDto(dto: BaseItemDto): List<Chapter> = dto.chapters ?.mapIndexed { index, chapter -> Chapter( - chapter.name, - chapter.startPositionTicks.ticks, - chapter.imageTag?.let { - api.imageApi.getItemImageUrl( - itemId = dto.id, - imageType = ImageType.CHAPTER, - tag = it, - imageIndex = index, - ) - }, + itemId = dto.id, + name = chapter.name, + position = chapter.startPositionTicks.ticks, + tag = chapter.imageTag, + index = index, ) }?.sortedBy { it.position } .orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index d3c87124..634cc988 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -5,42 +5,62 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.tv.material3.Card +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.roundSeconds -import kotlin.time.Duration +import org.jellyfin.sdk.model.api.ImageType /** * Card for a [com.github.damontecres.wholphin.data.model.Chapter] */ @Composable fun ChapterCard( - name: String?, - position: Duration, - imageUrl: String?, + chapter: Chapter, onClick: () -> Unit, - aspectRatio: Float, modifier: Modifier = Modifier, cardHeight: Dp = 120.dp, onLongClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(chapter, cardHeight) { + imageUrlService.getItemImageUrl( + itemId = chapter.itemId, + imageType = ImageType.CHAPTER, + tag = chapter.tag, + imageIndex = chapter.index, + fillHeight = with(density) { cardHeight.roundToPx() }, + ) + } + var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) } Card( - modifier = modifier.size(cardHeight * aspectRatio, cardHeight), + modifier = modifier.height(cardHeight), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -51,24 +71,36 @@ fun ChapterCard( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), + onSuccess = { + width = + with(density) { + it.painter.intrinsicSize.width + .toDp() + } + }, ) Box( modifier = Modifier + .width(width) .align(Alignment.BottomStart) - .fillMaxWidth() .background(AppColors.TransparentBlack50), ) { Column(modifier = Modifier.padding(4.dp)) { - name?.let { + chapter.name?.let { Text( text = it, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, ) } val resources = LocalResources.current - val positionText = remember(position) { resources.formatDuration(position.roundSeconds) } + val positionText = + remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) } Text( text = positionText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index b1e64a2a..5fa6316f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -21,7 +21,6 @@ import com.github.damontecres.wholphin.data.model.Chapter @Composable fun ChapterRow( chapters: List<Chapter>, - aspectRatio: Float, onClick: (Chapter) -> Unit, modifier: Modifier = Modifier, onLongClick: ((Chapter) -> Unit)? = null, @@ -47,11 +46,8 @@ fun ChapterRow( ) { itemsIndexed(chapters) { index, item -> ChapterCard( - name = item.name, - position = item.position, - imageUrl = item.imageUrl, + chapter = item, onClick = { onClick(item) }, - aspectRatio = aspectRatio, modifier = Modifier, onLongClick = onLongClick?.let { { it(item) } }, ) 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 bc4a3461..db4608ac 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 @@ -33,10 +33,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ChapterRow @@ -412,7 +410,6 @@ fun MovieDetailsContent( item { ChapterRow( chapters = chapters, - aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE, onClick = { position = CHAPTER_ROW playOnClick.invoke(it.position) 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 9df28425..49a7d742 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 @@ -117,7 +117,7 @@ class MovieViewModel userPreferencesService.getCurrent(), ) val remoteTrailers = trailerService.getRemoteTrailers(movie) - val chapters = Chapter.fromDto(movie.data, api) + val chapters = Chapter.fromDto(movie.data) _state.update { it.copy( loading = DataLoadingState.Success(movie), 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 fae25907..2dc01d36 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 @@ -570,7 +570,7 @@ class PlaybackViewModel } } - val chapters = Chapter.fromDto(base, api) + val chapters = Chapter.fromDto(base) _state.update { it.copy(currentItemPlayback = itemPlaybackToUse) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt index 45e335ab..fa32950b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -44,7 +44,6 @@ fun ChapterRowOverlay( controllerViewState: ControllerViewState, chapters: List<Chapter>, hasNext: Boolean, - aspectRatio: Float, onChangeState: (OverlayViewState) -> Unit, modifier: Modifier = Modifier, ) { @@ -115,10 +114,7 @@ fun ChapterRowOverlay( if (isFocused) controllerViewState.pulseControls() } ChapterCard( - name = chapter.name, - position = chapter.position, - imageUrl = chapter.imageUrl, - aspectRatio = aspectRatio, + chapter = chapter, onClick = { player.seekTo(chapter.position.inWholeMilliseconds) controllerViewState.hideControls() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index a6ade035..9af4a171 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -52,9 +52,7 @@ import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.PlaylistItem -import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.formatDuration @@ -228,7 +226,6 @@ fun PlaybackOverlay( controllerViewState = controllerViewState, chapters = chapters, hasNext = nextEnabled, - aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, onChangeState = onChangeState, modifier = Modifier From 00490b14ece8ac4f65d668203118cf5e73fbe03f Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 23 Jul 2026 13:30:43 -0400 Subject: [PATCH 171/475] Improvements to subtitle search & download (#1728) ## Description Changes to searching & downloading subtitles: - Activating newly downloaded subtitles more reliably - Better audio index adjustment checks after downloading subtitles - Sort subtitle downloadable options by whether the file hash matches first - Update the UI for subtitle downloadable options Also, some improvements to the subtitle choice dialog: - It now loops from top to bottom (and vice versa) - It now focuses initially on current choice ### Dev notes This PR now the selected tracks from `state.currentItemPlayback` to `state.currentPlayback`. `currentItemPlayback` now only reflects the user's choices saved in the database instead of a combination of DB and "actual" state. `currentPlayback` stores the actual, resolved track indexes regardless of how they were derived. This means the indexes values in `state.currentItemPlayback` and `state.currentPlayback` may be different. For example, if the user has chosen "Only forced", `state.currentItemPlayback==-3` but `state.currentPlayback` might be an actual forced track index. ### Related issues Fixes #1549 Fixes #1718 Closes #1723 Fixes bug reported in https://github.com/damontecres/Wholphin/issues/1498#issuecomment-5019965271 ### Testing Emulator ## Screenshots Now shows SDH & hash matching <img width="779" height="280" alt="image" src="https://github.com/user-attachments/assets/4c33e5af-c9a1-4c9c-8217-6bc2ab3fcd27" /> ## AI or LLM usage None --- .../wholphin/ui/components/Dialogs.kt | 18 +- .../wholphin/ui/playback/CurrentMediaInfo.kt | 37 +++ .../wholphin/ui/playback/CurrentPlayback.kt | 13 +- .../ui/playback/DownloadSubtitlesDialog.kt | 216 ++++++------- .../wholphin/ui/playback/PlaybackDialog.kt | 63 ++++ .../wholphin/ui/playback/PlaybackPage.kt | 49 +-- .../wholphin/ui/playback/PlaybackViewModel.kt | 285 ++++++++---------- .../ui/playback/SubtitleSearchUtils.kt | 74 +++-- .../util/TrackActivityPlaybackListener.kt | 8 +- app/src/main/res/values/strings.xml | 2 + 10 files changed, 437 insertions(+), 328 deletions(-) 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 75c7fbf7..f1091839 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 @@ -88,7 +88,7 @@ import java.util.UUID data class DialogParams( val fromLongClick: Boolean, val title: String, - val items: List<DialogItem>, + val items: List<DialogItemEntry>, ) sealed interface DialogItemEntry @@ -675,8 +675,9 @@ fun chooseStream( type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, -): DialogParams = - DialogParams( +): DialogParams { + val filteredStreams = streams.filter { it.type == type } + return DialogParams( fromLongClick = false, title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), items = @@ -709,10 +710,12 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, ), ) + if (filteredStreams.isNotEmpty()) { + add(DialogItemDivider) + } } addAll( - streams - .filter { it.type == type } + filteredStreams .let { if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } @@ -728,7 +731,9 @@ fun chooseStream( }, headlineContent = { Text( - text = simpleStream.streamTitle ?: simpleStream.displayTitle, + text = + simpleStream.streamTitle + ?: simpleStream.displayTitle, ) }, supportingContent = { @@ -740,3 +745,4 @@ fun chooseStream( ) }, ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 40b5e89b..380901a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,7 +4,10 @@ import androidx.core.net.toUri import androidx.media3.common.MediaMetadata import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.ticks @@ -26,6 +29,40 @@ data class CurrentMediaInfo( } } +/** + * Gets a sorted list of subtitle streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getSubtitleStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> { + val subtitleLanguagePreference = + serverRepository.currentUserDto + ?.configuration + ?.subtitleLanguagePreference + return mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + .let { + if (subtitleLanguagePreference.isNotNullOrBlank()) { + it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } + } else { + it + } + }?.map { + // TODO should use a string provider instead + SimpleMediaStream.from(context.resources, it, true) + }.orEmpty() +} + +/** + * Gets a list of audio streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getAudioStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> = + mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + SimpleMediaStream.from(context.resources, it, true) + } +// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) + .orEmpty() + fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata = MediaMetadata .Builder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index b0f51e56..54663af6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt @@ -1,11 +1,14 @@ package com.github.damontecres.wholphin.ui.playback import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.TrackSupport import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.TranscodingInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID import kotlin.time.Duration /** @@ -25,4 +28,12 @@ data class CurrentPlayback( val audioDecoder: String? = null, val transcodeInfo: TranscodingInfo? = null, val subtitleDelay: Duration = Duration.ZERO, -) + val audioIndex: Int = TrackIndex.UNSPECIFIED, + val subtitleIndex: Int = TrackIndex.UNSPECIFIED, +) { + val audioIndexEnabled = audioIndex >= 0 + val subtitleIndexEnabled = subtitleIndex >= 0 + + val itemId: UUID get() = item.id + val sourceId: UUID? = mediaSourceInfo.id?.toUUIDOrNull() +} 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 c9845191..2ec02410 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 @@ -1,21 +1,15 @@ package com.github.damontecres.wholphin.ui.playback 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 -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.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,11 +19,9 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -37,15 +29,13 @@ import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.abbreviateNumber -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogItemDivider import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.RemoteSubtitleInfo @@ -55,53 +45,41 @@ fun DownloadSubtitlesContent( language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, - onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { when (val s = state) { SubtitleSearchStatus.Inactive -> {} SubtitleSearchStatus.Searching -> { - Wrapper { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } SubtitleSearchStatus.Downloading -> { - Wrapper { - Text( - text = stringResource(R.string.downloading), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.downloading), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } is SubtitleSearchStatus.Error -> { - Wrapper { ErrorMessage(null, s.ex, modifier) } + ErrorMessage(null, s.ex, modifier) } is SubtitleSearchStatus.Success -> { - val dialogItems = convertRemoteSubtitles(s.options, onClickDownload) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = stringResource(R.string.search_and_download_subtitles), @@ -128,51 +106,35 @@ fun DownloadSubtitlesContent( onSearch(lang) }, ), - // onKeyboardAction = { -// onSearch(lang.text.toString()) -// }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search, ), ) } - if (dialogItems.isEmpty()) { + if (s.options.isEmpty()) { Text( text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) } else { + val focusRequesters = + remember(s.options.size) { List(s.options.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } LazyColumn( modifier = Modifier, ) { - itemsIndexed(dialogItems) { index, item -> - when (item) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } - - is DialogItem -> { - ListItem( - selected = false, - enabled = item.enabled, - onClick = { - item.onClick.invoke() - }, - headlineContent = item.headlineContent, - overlineContent = item.overlineContent, - supportingContent = item.supportingContent, - leadingContent = item.leadingContent, - trailingContent = item.trailingContent, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } + itemsIndexed(s.options) { index, item -> + SubtitleInfo( + subtitle = item, + onClick = onClickDownload, + modifier = + Modifier + .focusRequester(focusRequesters[index]), + ) } } } @@ -182,54 +144,56 @@ fun DownloadSubtitlesContent( } @Composable -private fun Wrapper(content: @Composable BoxScope.() -> Unit) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - Box( - modifier = - Modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), - content = content, - ) -} - -fun convertRemoteSubtitles( - options: List<RemoteSubtitleInfo>, +fun SubtitleInfo( + subtitle: RemoteSubtitleInfo, onClick: (RemoteSubtitleInfo) -> Unit, -) = options.map { op -> - DialogItem( - onClick = { onClick.invoke(op) }, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + onClick = { onClick.invoke(subtitle) }, headlineContent = { Text( - text = op.name ?: "", + text = subtitle.name ?: "", ) }, + overlineContent = + if (subtitle.isHashMatch == true) { + { + Text(stringResource(R.string.hash_matches)) + } + } else { + null + }, supportingContent = { - val strings = - buildList { - op.providerName?.let(::add) - op.threeLetterIsoLanguageName?.let(::add) - if (op.forced == true) { - add(stringResource(R.string.forced_track)) - } - add( - pluralStringResource( - R.plurals.downloads, - op.downloadCount ?: 0, - abbreviateNumber(op.downloadCount ?: 0), - ), - ) + val resources = LocalResources.current + val details = + remember(resources, subtitle) { + val strings = + buildList { + subtitle.providerName?.let(::add) + if (subtitle.forced == true) { + add(resources.getString(R.string.forced_track)) + } + if (subtitle.hearingImpaired == true) { + add(resources.getString(R.string.subtitles_hearing_impaired)) + } + add( + resources.getQuantityString( + R.plurals.downloads, + subtitle.downloadCount ?: 0, + abbreviateNumber(subtitle.downloadCount ?: 0), + ), + ) + } + strings.joinToString(" - ") } Text( - text = strings.joinToString(" - "), + text = details, ) }, trailingContent = { - op.communityRating?.let { rating -> + subtitle.communityRating?.let { rating -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -245,5 +209,43 @@ fun convertRemoteSubtitles( } } }, + modifier = modifier, ) } + +@PreviewTvSpec +@Composable +fun SubtitleInfoPreview() { + WholphinTheme { + Column(Modifier.width(400.dp)) { + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = true, + hearingImpaired = false, + isHashMatch = true, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = false, + hearingImpaired = true, + isHashMatch = false, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + } + } +} 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 c3bff78c..491498cb 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background 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.fillMaxWidth @@ -11,15 +12,21 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView @@ -42,6 +49,7 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration enum class PlaybackDialogType { @@ -276,6 +284,24 @@ fun SubtitleChoiceBottomDialog( hasDownloadPermission: Boolean, currentChoice: Int? = null, ) { + val numberBefore = 2 + val initialChoice = + remember(choices) { + choices.indexOfFirstOrNull { it.index == currentChoice } + } + val scope = rememberCoroutineScope() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialChoice?.plus(numberBefore) ?: 0, + ) + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + val choiceFocusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + LaunchedEffect(Unit) { + val focusRequester = + initialChoice?.let { choiceFocusRequesters.getOrNull(it) } ?: firstFocusRequester + focusRequester.tryRequestFocus() + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -298,6 +324,7 @@ fun SubtitleChoiceBottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -307,8 +334,11 @@ fun SubtitleChoiceBottomDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { item { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = currentChoice == TrackIndex.DISABLED, + interactionSource = interactionSource, onClick = { onSelectChoice(TrackIndex.DISABLED) }, @@ -321,6 +351,19 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + modifier = + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(Int.MAX_VALUE) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, ) } item { @@ -339,6 +382,9 @@ fun SubtitleChoiceBottomDialog( }, supportingContent = {}, ) + if (choices.isNotEmpty()) { + HorizontalDivider() + } } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } @@ -359,10 +405,13 @@ fun SubtitleChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(choiceFocusRequesters[index]), ) } item { HorizontalDivider() + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = false, enabled = hasDownloadPermission, @@ -374,6 +423,20 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + 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 6d22cd10..62eebd04 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 @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection 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 @@ -75,6 +74,7 @@ import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination @@ -379,7 +379,13 @@ fun PlaybackPageContent( } } - if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) { + val controlsVisible = + remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) { + controllerViewState.controlsVisible || + playbackDialog != null || + subtitleSearchState.status != SubtitleSearchStatus.Inactive + } + if (!controlsVisible && skipIndicatorDuration == 0L) { PauseIndicator( player = player, modifier = @@ -444,7 +450,7 @@ fun PlaybackPageContent( val subtitleVisible = skipIndicatorDuration == 0L && - state.currentItemPlayback?.subtitleIndexEnabled == true && + state.currentPlayback?.subtitleIndexEnabled == true && !presentationState.coverSurface AndroidView( @@ -629,28 +635,29 @@ fun PlaybackPageContent( } viewModel.cancelSubtitleSearch() } - Dialog( + BasicDialog( onDismissRequest = onDismissRequest, properties = DialogProperties( usePlatformDefaultWidth = false, ), ) { - DownloadSubtitlesContent( - state = subtitleSearchState.status, - language = subtitleSearchState.language, - onSearch = { lang -> - viewModel.searchForSubtitles(lang) - }, - onClickDownload = { - viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) - }, - onDismissRequest = onDismissRequest, - modifier = - Modifier - .widthIn(max = 640.dp) - .heightIn(max = 400.dp), - ) + Box(modifier = Modifier.padding(24.dp)) { + DownloadSubtitlesContent( + state = subtitleSearchState.status, + language = subtitleSearchState.language, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), + ) + } } } @@ -660,9 +667,9 @@ fun PlaybackPageContent( settings = PlaybackSettings( showDebugInfo = showDebugInfo, - audioIndex = state.currentItemPlayback?.audioIndex, + audioIndex = state.currentPlayback?.audioIndex, audioStreams = state.currentMediaInfo.audioStreams, - subtitleIndex = state.currentItemPlayback?.subtitleIndex, + subtitleIndex = state.currentPlayback?.subtitleIndex, subtitleStreams = state.currentMediaInfo.subtitleStreams, playbackSpeed = playbackSpeed, contentScale = contentScale, 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 2dc01d36..da55b98b 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 @@ -31,7 +31,6 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex @@ -65,7 +64,6 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings. import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState @@ -142,7 +140,7 @@ class PlaybackViewModel private val playlistCreator: PlaylistCreator, private val itemPlaybackDao: ItemPlaybackDao, internal val serverRepository: ServerRepository, - private val itemPlaybackRepository: ItemPlaybackRepository, + internal val itemPlaybackRepository: ItemPlaybackRepository, private val playerFactory: PlayerFactory, private val datePlayedService: DatePlayedService, private val deviceInfo: DeviceInfo, @@ -405,7 +403,7 @@ class PlaybackViewModel } } - private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { + internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { _state.update { it.copy(currentPlayback = block.invoke(it.currentPlayback)) } @@ -494,32 +492,9 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleLanguagePreference = - serverRepository.currentUserDto - ?.configuration - ?.subtitleLanguagePreference - val subtitleStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - .let { - if (subtitleLanguagePreference.isNotNullOrBlank()) { - it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } - } else { - it - } - }?.map { - // TODO should use a string provider instead - SimpleMediaStream.from(context.resources, it, true) - }.orEmpty() - val audioStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - SimpleMediaStream.from(context.resources, it, true) - } -// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) - .orEmpty() + val subtitleStreams = getSubtitleStreams(mediaSource) + val audioStreams = getAudioStreams(mediaSource) val audioStream = streamChoiceService .chooseAudioStream( @@ -544,15 +519,6 @@ class PlaybackViewModel Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - val itemPlaybackToUse = - itemPlayback ?: ItemPlayback( - rowId = -1, - userId = -1, - itemId = base.id, - sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null, - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, - ) val trickPlayInfo = item.data.trickplay ?.get(mediaSource.id) @@ -572,7 +538,10 @@ class PlaybackViewModel val chapters = Chapter.fromDto(base) _state.update { - it.copy(currentItemPlayback = itemPlaybackToUse) + it.copy( + currentItemPlayback = itemPlayback, + currentPlayback = null, + ) } updateCurrentMedia { CurrentMediaInfo( @@ -586,12 +555,11 @@ class PlaybackViewModel } withContext(WholphinDispatchers.Main) { changeStreams( - item, - itemPlaybackToUse, - audioIndex, - subtitleIndex, - if (positionMs > 0) positionMs else C.TIME_UNSET, - itemPlayback != null, // If it was passed in, then it was not queried from the database + item = item, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, + sourceId = mediaSource.id, enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) @@ -608,38 +576,34 @@ class PlaybackViewModel @OptIn(UnstableApi::class) internal suspend fun changeStreams( item: BaseItem, - currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!, + sourceId: String?, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = 0, - userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, ): Unit = withContext(WholphinDispatchers.IO) { val itemId = item.id - val currentPlayback = state.value.currentPlayback - if (currentPlayback != null && - currentPlayback.item.id == item.id && - currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay - ) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - currentItemPlayback = currentItemPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - userInitiated = userInitiated, - ) - if (wasSuccessful) return@withContext + state.value.currentPlayback?.let { currentPlayback -> + if (currentPlayback.item.id == item.id && + currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && + enableDirectPlay + ) { + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + ) + if (wasSuccessful) return@withContext + } } Timber.i( - "changeStreams (%s): userInitiated=%s, audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", itemId, - userInitiated, audioIndex, subtitleIndex, enableDirectPlay, @@ -668,7 +632,7 @@ class PlaybackViewModel maxAudioChannels = null, audioStreamIndex = audioIndex, subtitleStreamIndex = subtitleIndex, - mediaSourceId = currentItemPlayback.sourceId?.toServerString(), + mediaSourceId = sourceId, alwaysBurnInSubtitleWhenTranscoding = false, maxStreamingBitrate = maxBitrate.toInt(), enableDirectPlay = enableDirectPlay, @@ -680,6 +644,7 @@ class PlaybackViewModel ), ) if (response.errorCode != null) { + Timber.e("Error in PostedPlaybackInfo: %s", response.errorCode) _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } return@withContext } @@ -782,6 +747,8 @@ class PlaybackViewModel playSessionId = response.playSessionId, liveStreamId = source.liveStreamId, mediaSourceInfo = source, + audioIndex = audioIndex ?: TrackIndex.DISABLED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, ) preferences.appPreferences.playbackPreferences.let { prefs -> @@ -802,7 +769,7 @@ class PlaybackViewModel player.removeListener(it) } - val playbackItemState = PlaybackItemState(playback, currentItemPlayback) + val playbackItemState = PlaybackItemState(playback) val activityListener = TrackActivityPlaybackListener( api = api, @@ -843,15 +810,17 @@ class PlaybackViewModel result.trackSelectionParameters } else { // Fall back to transcoding - Timber.w("Failed to select tracks, falling back to transcoding") + Timber.w( + "Failed to select tracks, falling back to transcoding: %s", + result, + ) viewModelScope.launchIO { changeStreams( item = item, - currentItemPlayback = currentItemPlayback, + sourceId = sourceId, audioIndex = audioIndex, subtitleIndex = subtitleIndex, positionMs = positionMs, - userInitiated = userInitiated, enableDirectPlay = false, enableDirectStream = true, ) @@ -874,10 +843,8 @@ class PlaybackViewModel @OptIn(UnstableApi::class) private suspend fun changeStreamsDirectPlay( currentPlayback: CurrentPlayback, - currentItemPlayback: ItemPlayback, audioIndex: Int?, subtitleIndex: Int?, - userInitiated: Boolean, ): Boolean = withContext(WholphinDispatchers.IO) { Timber.v("changeStreams direct play") @@ -912,29 +879,7 @@ class PlaybackViewModel } if (result.bothSelected) { onMain { player.trackSelectionParameters = result.trackSelectionParameters } - // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index - subtitleIndex = - if (currentItemPlayback.subtitleIndex < 0) { - currentItemPlayback.subtitleIndex - } else { - subtitleIndex ?: TrackIndex.DISABLED - }, - ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - _state.update { it.copy(currentItemPlayback = updated) } - } - } else { - _state.update { it.copy(currentItemPlayback = itemPlayback) } - } _state.update { it.copy( currentPlayback = @@ -954,78 +899,92 @@ class PlaybackViewModel fun changeAudioStream(index: Int) { viewModelScope.launchIO { - Timber.d("Changing audio track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = state.value.currentItemPlayback!!, - trackIndex = index, - type = MediaStreamType.AUDIO, - ) - _state.update { it.copy(currentItemPlayback = itemPlayback) } - - // Resolve ONLY_FORCED to actual track based on new audio language - val source = state.value.currentPlayback?.mediaSourceInfo - val resolvedSubtitleIndex = - if (source != null) { - streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, - subtitleIndex = itemPlayback.subtitleIndex, - prefs = preferences, + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing audio track to %s", index) + saveTrackSelection(index, MediaStreamType.AUDIO) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(audioIndex = index), ) - } else { - itemPlayback.subtitleIndex.takeIf { it >= 0 } } - changeStreams( - currentItem.item, - itemPlayback, - index, - resolvedSubtitleIndex, - onMain { player.currentPosition }, - true, - ) + // Resolve ONLY_FORCED to actual track based on new audio language + val resolvedSubtitleIndex = + streamChoiceService.resolveSubtitleIndex( + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = index, + seriesId = currentItem.item.data.seriesId, + subtitleIndex = currentPlayback.subtitleIndex, + prefs = preferences, + ) + + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = index, + subtitleIndex = resolvedSubtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change audio, but currentPlayback is null") + } } } fun changeSubtitleStream(index: Int): Job = viewModelScope.launchIO { - Timber.d("Changing subtitle track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = state.value.currentItemPlayback!!, - trackIndex = index, - type = MediaStreamType.SUBTITLE, - ) - _state.update { it.copy(currentItemPlayback = itemPlayback) } + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing subtitle track to %s", index) + saveTrackSelection(index, MediaStreamType.SUBTITLE) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(subtitleIndex = index), + ) + } - // Resolve ONLY_FORCED to actual track index for playback - val source = state.value.currentPlayback?.mediaSourceInfo - val resolvedIndex = - if (source != null) { + // Resolve ONLY_FORCED to actual track index for playback + val resolvedIndex = streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = itemPlayback.audioIndex, + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = currentPlayback.audioIndex, seriesId = currentItem.item.data.seriesId, subtitleIndex = index, prefs = preferences, ) - } else { - index.takeIf { it >= 0 } - } - changeStreams( - currentItem.item, - itemPlayback, - itemPlayback.audioIndex, - resolvedIndex, - onMain { player.currentPosition }, - true, + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = resolvedIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change subtitle, but currentPlayback is null") + } + } + + internal suspend fun saveTrackSelection( + trackIndex: Int, + type: MediaStreamType, + ) { + val itemPlayback = + itemPlaybackRepository.saveTrackSelection( + item = currentItem.item, + itemPlayback = state.value.currentItemPlayback, + trackIndex = trackIndex, + type = type, + ) + _state.update { + it.copy( + currentItemPlayback = itemPlayback, ) } + } private suspend fun prefetchTrickplay( duration: Duration, @@ -1050,7 +1009,11 @@ class PlaybackViewModel fun getTrickplayUrl( index: Int, trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, - mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId, + mediaSourceId: UUID? = + state.value.currentPlayback + ?.mediaSourceInfo + ?.id + ?.toUUIDOrNull(), ): String? = trickPlayInfo?.let { val itemId = currentItem.id @@ -1388,14 +1351,16 @@ class PlaybackViewModel PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") - val currentItemPlayback = state.value.currentItemPlayback!! + val currentPlayback = state.value.currentPlayback + if (currentPlayback == null) { + Timber.w("Playback error, currentPlayback is null") + } changeStreams( - currentItem.item, - currentItemPlayback, - currentItemPlayback.audioIndex, - currentItemPlayback.subtitleIndex, - player.currentPosition, - false, + item = currentItem.item, + sourceId = currentPlayback?.mediaSourceInfo?.id, + audioIndex = currentPlayback?.audioIndex, + subtitleIndex = currentPlayback?.subtitleIndex, + positionMs = player.currentPosition, enableDirectPlay = false, enableDirectStream = false, ) @@ -1475,7 +1440,7 @@ class PlaybackViewModel * Atomically update [currentMediaInfo] */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = - withContext(WholphinDispatchers.IO) { + withContext(WholphinDispatchers.Default) { _state.update { it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) } @@ -1598,16 +1563,16 @@ class PlaybackViewModel } suspend fun loadSubtitleDelay() { - state.value.currentItemPlayback?.let { + state.value.currentPlayback?.let { if (it.subtitleIndexEnabled) { val result = - itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex) + itemPlaybackRepository.getTrackModifications(it.item.id, it.subtitleIndex) if (result != null) { Timber.v( "Loading subtitle delay %s for track=%s, itemId=%s", result.delayMs, it.subtitleIndex, - it.itemId, + it.item.id, ) updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index e9e809ae..c08759c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -21,7 +21,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds sealed interface SubtitleSearchStatus { data object Inactive : SubtitleSearchStatus @@ -62,16 +64,17 @@ fun PlaybackViewModel.searchForSubtitles(language: String? = null) { } viewModelScope.launchIO { try { - state.value.currentItemPlayback?.itemId?.let { - Timber.v("Searching for remote subtitles for %s", it) + state.value.currentPlayback?.itemId?.let { itemId -> + Timber.v("Searching for remote subtitles for %s", itemId) val results = api.subtitleApi .searchRemoteSubtitles( - itemId = it, + itemId = itemId, language = language, ).content .sortedWith( - compareByDescending<RemoteSubtitleInfo> { it.communityRating } + compareByDescending<RemoteSubtitleInfo> { it.isHashMatch } + .thenByDescending { it.communityRating } .thenByDescending { it.downloadCount }, ) subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } @@ -104,21 +107,19 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } viewModelScope.launchIO { try { - state.value.currentItemPlayback?.let { + state.value.currentPlayback?.let { currentPlayback -> Timber.v( "Downloading remote subtitles for itemId=%s, sourceId=%s: %s", - it.itemId, - it.sourceId, + currentPlayback.itemId, + currentPlayback.sourceId, subtitleId, ) api.subtitleApi.downloadRemoteSubtitles( - itemId = it.sourceId ?: it.itemId, + itemId = currentPlayback.sourceId ?: currentPlayback.itemId, subtitleId = subtitleId, ) - val currentSource = state.value.currentPlayback?.mediaSourceInfo val currentSubtitleStreams = - currentSource - ?.mediaStreams + currentPlayback.mediaSourceInfo.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() val externalPaths = currentSubtitleStreams.map { it.path } @@ -131,14 +132,16 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- - delay(1500) - val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content) + delay(1500.milliseconds) + val base = + BaseItem(api.userLibraryApi.getItem(itemId = currentPlayback.itemId).content) currentItem = when (currentItem) { is PlaylistItem.Intro -> PlaylistItem.Intro(base) is PlaylistItem.Media -> PlaylistItem.Media(base) } - mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it) + mediaSource = + base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId } if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -169,28 +172,43 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( stream.isExternal && stream.path !in externalPaths } if (newStream != null) { - var audioIndex = it?.audioIndex - if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { - // User has picked a specific audio track - // Since, now adding a new external subtitle track, need to adjust the audio index as well + var audioIndex = currentPlayback.audioIndex + if (audioIndex != TrackIndex.UNSPECIFIED && audioIndex >= newStream.index) { + // User has previously picked a specific audio track + // If the new external subtitle track was added before the audio track, need to adjust the audio index as well Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 + state.value.currentItemPlayback?.let { currentItemPlayback -> + if (currentItemPlayback.audioIndex != TrackIndex.UNSPECIFIED) { + Timber.d("User has a previously saved audio index that needs to be updated") + saveTrackSelection(audioIndex, MediaStreamType.AUDIO) + } + } } + saveTrackSelection(newStream.index, MediaStreamType.SUBTITLE) updateCurrentMedia { it.copy( - subtitleStreams = - subtitlesStreams.map { - SimpleMediaStream.from(context.resources, it, true) - }, + sourceId = mediaSource.id, + audioStreams = getAudioStreams(mediaSource), + subtitleStreams = getSubtitleStreams(mediaSource), ) } + updateCurrentPlayback { + it?.copy( + item = currentItem.item, + mediaSourceInfo = mediaSource, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + ) + } + this@downloadAndSwitchSubtitles.changeStreams( - currentItem.item, - it, - audioIndex, - newStream.index, - onMain { player.currentPosition }, - true, + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 6e11013b..2ab4c2f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.util import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope @@ -177,12 +176,11 @@ data class PlaybackItemState( ) { constructor( playback: CurrentPlayback, - itemPlayback: ItemPlayback, ) : this( - itemId = itemPlayback.itemId, + itemId = playback.item.id, playMethod = playback.playMethod, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + audioStreamIndex = playback.audioIndex.takeIf { it >= 0 }, + subtitleStreamIndex = playback.subtitleIndex.takeIf { it >= 0 }, playSessionId = playback.playSessionId, liveStreamId = playback.liveStreamId, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb3d89af..a9bbb1e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -872,4 +872,6 @@ <string name="override_user_profile_settings">More user profile settings</string> <string name="override_user_profile_settings_summary">Override your user profile settings from the server</string> <string name="any_language">Any language</string> + <string name="hash_matches">Hash matches</string> + <string name="subtitles_hearing_impaired">SDH</string> </resources> From 5bc874a9ff52ba9dda85c210bdc4938c4a06966b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 23 Jul 2026 14:23:48 -0400 Subject: [PATCH 172/475] Fix music artist listing & show music in collections (#1739) ## Description Switches to use the Artists API instead of Items API when fetching the list of artists in a library. Enables fetching artists, albums, and music videos in a collection. Also adds titles for the remaining `BaseItemKind` types for future proofing even if they aren't used anywhere in the app. ### Related issues Fixes #1733 Fixes #1734 Fixes #1735 ### Testing Emulator, created a new collection with the above. Also verified the artist query against the web UI ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/GetItemsFilter.kt | 18 +++++++ .../damontecres/wholphin/ui/Formatting.kt | 44 ++++++++--------- .../ui/components/CollectionFolderView.kt | 22 +++++++++ .../ui/detail/CollectionFolderMusic.kt | 3 +- .../ui/detail/collection/CollectionDetails.kt | 2 +- .../ui/detail/collection/CollectionRows.kt | 49 ++++++++++++++----- .../detail/collection/CollectionViewModel.kt | 3 ++ .../wholphin/util/ApiRequestPager.kt | 22 +++++++++ app/src/main/res/values/strings.xml | 6 +++ 9 files changed, 134 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index 792ec6ca..3746092b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.VideoType +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -138,6 +139,22 @@ data class GetItemsFilter( isFavorite = favorite, ) + fun applyTo(req: GetArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + /** * Merge another [GetItemsFilter] onto this one, replacing only unset values */ @@ -162,4 +179,5 @@ data class GetItemsFilter( enum class GetItemsFilterOverride { NONE, PERSON, + ARTIST, } 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 a9ccd690..aeb22e95 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 @@ -230,36 +230,36 @@ fun formatTypeName(type: BaseItemKind): Int = BaseItemKind.PLAYLIST -> R.string.playlists BaseItemKind.PERSON -> R.string.people_title BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.AUDIO -> TODO() + BaseItemKind.AUDIO -> R.string.songs BaseItemKind.CHANNEL -> R.string.channels BaseItemKind.GENRE -> R.string.genres BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels - BaseItemKind.MUSIC_ALBUM -> TODO() - BaseItemKind.MUSIC_ARTIST -> TODO() - BaseItemKind.MUSIC_GENRE -> TODO() - BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.MUSIC_ALBUM -> R.string.albums + BaseItemKind.MUSIC_ARTIST -> R.string.artists + BaseItemKind.MUSIC_GENRE -> R.string.genres + BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.PHOTO -> R.string.photos - BaseItemKind.PHOTO_ALBUM -> TODO() - BaseItemKind.PROGRAM -> TODO() - BaseItemKind.RECORDING -> TODO() + BaseItemKind.PHOTO_ALBUM -> R.string.photos + BaseItemKind.PROGRAM -> R.string.programs + BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.SEASON -> R.string.tv_seasons BaseItemKind.STUDIO -> R.string.studios BaseItemKind.TRAILER -> R.string.trailers_title BaseItemKind.TV_CHANNEL -> R.string.channels - BaseItemKind.TV_PROGRAM -> TODO() - BaseItemKind.USER_ROOT_FOLDER -> TODO() - BaseItemKind.USER_VIEW -> TODO() - BaseItemKind.YEAR -> TODO() - BaseItemKind.AGGREGATE_FOLDER -> TODO() - BaseItemKind.AUDIO_BOOK -> TODO() - BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() - BaseItemKind.BOOK -> TODO() - BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() - BaseItemKind.COLLECTION_FOLDER -> TODO() - BaseItemKind.FOLDER -> TODO() - BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() - BaseItemKind.LIVE_TV_PROGRAM -> TODO() - BaseItemKind.PLAYLISTS_FOLDER -> TODO() + BaseItemKind.TV_PROGRAM -> R.string.programs + BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title + BaseItemKind.USER_VIEW -> R.string.user_views + BaseItemKind.YEAR -> R.string.year + BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title + BaseItemKind.AUDIO_BOOK -> R.string.audio_books + BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title + BaseItemKind.BOOK -> R.string.books + BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title + BaseItemKind.COLLECTION_FOLDER -> R.string.library + BaseItemKind.FOLDER -> R.string.folders_title + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists + BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs + BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 939be43b..56eb4bd2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState @@ -92,6 +93,7 @@ import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUID @@ -373,6 +375,26 @@ class CollectionFolderViewModel ) newPager } + + GetItemsFilterOverride.ARTIST -> { + val item = state.value.item.successValue + val request = + filter.applyTo( + GetArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } } private fun createGetItemsRequest( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 431e8e8e..a95a1ea2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -17,6 +17,7 @@ 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.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.MusicService @@ -203,7 +204,7 @@ fun CollectionFolderMusic( CollectionFolderFilter( filter = GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), + override = GetItemsFilterOverride.ARTIST, ), ), showTitle = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 7366bf59..99db156f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -425,7 +425,7 @@ fun CollectionDetailsContent( onClickPlay = onClickPlay, modifier = Modifier.fillMaxSize(), onFocusPosition = { position -> - Timber.v("onFocusPosition=%s", position) +// Timber.v("onFocusPosition=%s", position) focusedItem = position.let { val key = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt index f1e85ee2..126a5765 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -51,18 +51,45 @@ fun CollectionRows( if (row is HomeRowLoadingState.Success) { // TODO not great to do this in the UI val viewOptions = - if (type == BaseItemKind.EPISODE) { - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - episodeAspectRatio = AspectRatio.WIDE, - showTitles = cardViewOptions.showTitles, - useSeries = false, - ) - } else { - HomeRowViewOptions( - showTitles = cardViewOptions.showTitles, - ) + when (type) { + BaseItemKind.EPISODE -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + episodeAspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + useSeries = false, + ) + } + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.PLAYLIST, + BaseItemKind.AUDIO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + showTitles = cardViewOptions.showTitles, + ) + } + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + ) + } + + else -> { + HomeRowViewOptions( + showTitles = cardViewOptions.showTitles, + ) + } } + row.copy(viewOptions = viewOptions) } else { row diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 1576b745..cab18bfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -489,6 +489,9 @@ class CollectionViewModel BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_VIDEO, ) const val VIEW_OPTIONS_KEY = "CollectionViewOptions" 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 555a5a51..a87d79ec 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 @@ -10,6 +10,7 @@ import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.artistsApi import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi @@ -21,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -367,6 +369,25 @@ object GetLiveTvChannelsRequestHandler : RequestHandler<GetLiveTvChannelsRequest ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request) } +@Serializable +object GetArtistsHandler : RequestHandler<GetArtistsRequest> { + override fun prepare( + request: GetArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetArtistsRequest, + ): Response<BaseItemDtoQueryResult> = api.artistsApi.getArtists((request)) +} + val requestSerializersModule = SerializersModule { polymorphic(Any::class) { @@ -382,5 +403,6 @@ val requestSerializersModule = subclass(GetStudiosRequest::class) subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) + subclass(GetArtistsHandler::class) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9bbb1e0..aa33cf7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -874,4 +874,10 @@ <string name="any_language">Any language</string> <string name="hash_matches">Hash matches</string> <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programs</string> + <string name="recordings">Recordings</string> + <string name="folders_title">Folders</string> + <string name="user_views">User views</string> + <string name="books">Books</string> + <string name="audio_books">Audio books</string> </resources> From d2bf5cd1e24b9d6c927942eb6b240e9f111b6df4 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 23 Jul 2026 15:32:31 -0400 Subject: [PATCH 173/475] Fix library freeze if first item's title starts with a character not in the alphabet string (#1740) ## Description A library's grid page tries to highlight the first letter of the focused item. But if the first letter was a not in the alphabet string (which varies by language), a exception was thrown on the main thread causing a freeze. This PR ensures only a non-negative index is highlighted on the letter buttons. ### Related issues Fixes #1361 Need to create an issue to improve alphabet letter button handling for non-English alphabets ### Testing Emulator, I manually passed a non-`[0-9A-Z]` character to replicate the freeze ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/CollectionFolderList.kt | 8 +++++--- .../com/github/damontecres/wholphin/ui/detail/CardGrid.kt | 4 ++-- .../damontecres/wholphin/ui/detail/music/LyricsContent.kt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index e82d87a5..3ae9085f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -103,9 +103,11 @@ fun CollectionFolderList( fun jumpTo(newPosition: Int) { scope.launch { - position = newPosition - listState.animateScrollToItem(newPosition) - positionFocusRequester.tryRequestFocus() + if (newPosition >= 0) { + position = newPosition + listState.animateScrollToItem(newPosition) + positionFocusRequester.tryRequestFocus() + } } } 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 6587c626..32c4a64a 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 @@ -515,7 +515,7 @@ fun AlphabetButtons( val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val index = letters.indexOf(currentLetter) + val index = remember(letters, currentLetter) { letters.indexOf(currentLetter) } LaunchedEffect(currentLetter) { scope.launch(ExceptionHandler()) { val firstVisibleItemIndex = listState.firstVisibleItemIndex @@ -523,7 +523,7 @@ fun AlphabetButtons( listState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index ?: -1 - if (index !in firstVisibleItemIndex..lastVisibleItemIndex) { + if (index >= 0 && index !in firstVisibleItemIndex..lastVisibleItemIndex) { listState.animateScrollToItem(index) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt index 6ccea46e..40dd5a87 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -58,7 +58,7 @@ fun LyricsContent( LaunchedEffect(currentLyricPosition) { if (currentLyricPosition != null) { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { - if (currentLyricPosition !in listState.firstVisibleItemIndex..it) { + if (currentLyricPosition >= 0 && currentLyricPosition !in listState.firstVisibleItemIndex..it) { listState.animateScrollToItem(currentLyricPosition) } } From cf3c00a1e11a95672511db276df00d360188f9d4 Mon Sep 17 00:00:00 2001 From: calebvetter <39844454+calebvetter@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:40 -0700 Subject: [PATCH 174/475] Add option for trickplay preview mode when D-Pad seeking (#1650) ## Description Adds a setting to allow the user to choose between the current D-Pad skip functionality, and a new simple trickplay overlay. The setting is added in Advanced, under the Playback section, called "D-Pad seek mode". The options are "Skip time" and "Trickplay previews". I left the default as "Skip time" since that's how it operates now, and nothing would change for users after this option is added unless they go and set it. When "Trickplay previews" is enabled, it shows an simple overlay when using the D-Pad to seek (without the full overlay open). The new simple overlay is the same implementation of the components in the full overlay: seekbar, trickplay images, current playhead time, remaining time. The components were pulled from the full overlay's inline UI code into their own reusable components, now used in both the full overlay and the simple overlay. #### Notes I could see there being an adjustment for the overlay hide delay. It's currently hard coded to 1.5s. This could be the default method, but it seems like preference is split as to whether people want this, or the minimalistic skipped seconds only version. ### Related issues This is directly in response to this issue, as well as my own desire for the feature :) https://github.com/damontecres/Wholphin/discussions/1084 It's also loosely related to this issue, in that it would solve it since it shows the playhead and remaining times. https://github.com/damontecres/Wholphin/discussions/1122 ### Testing Built and tested on an Amazon Fire TV Stick 4K Max. I wouldn't anticipate and compatibility or performance changes since it's a simple settings/UI change involving already-used UI components. ## Screenshots <img width="1920" height="1080" alt="wholphin-20260630-155708" src="https://github.com/user-attachments/assets/52046cc2-7413-4f66-89dd-355464fa0f91" /> <img width="1920" height="1080" alt="wholphin-20260630-155723" src="https://github.com/user-attachments/assets/57a4a271-9e44-4d2a-bea3-ce5c9cdc731a" /> <img width="1920" height="1080" alt="wholphin-20260630-153825" src="https://github.com/user-attachments/assets/2cf664ff-3db5-4a2e-8626-10219946669c" /> ## AI or LLM usage AI was used to generate the change, automate some testing, and provide information surrounding the change. I manually did some refactoring and cleanup to keep it clean and consistent with the rest of the code conventions. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../wholphin/preferences/AppPreference.kt | 14 ++++ .../preferences/AppPreferencesSerializer.kt | 1 + .../ui/detail/music/NowPlayingPage.kt | 2 + .../ui/playback/PlaybackKeyHandler.kt | 40 +++++++---- .../wholphin/ui/playback/PlaybackPage.kt | 59 ++++++++++++++- .../ui/playback/overlay/DpadSeekOverlay.kt | 71 +++++++++++++++++++ .../ui/playback/overlay/PlaybackControls.kt | 63 +++++++++------- .../ui/playback/overlay/PlaybackOverlay.kt | 37 ++-------- .../wholphin/ui/playback/overlay/SeekBar.kt | 24 +++++++ .../ui/playback/overlay/TrickplayPreview.kt | 63 ++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 6 ++ app/src/main/res/values/strings.xml | 8 +++ 12 files changed, 317 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt 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 12e39399..308ed909 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 @@ -661,6 +661,19 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), ) + val DpadSeekModePref = + AppChoicePreference<AppPreferences, DpadSeekMode>( + title = R.string.d_pad_seek_mode_title, + defaultValue = DpadSeekMode.SKIP_TIME, + getter = { it.playbackPreferences.dpadSeekMode }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { dpadSeekMode = value } + }, + displayValues = R.array.dpad_seek_mode_options, + indexToValue = { DpadSeekMode.forNumber(it) }, + valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 }, + ) + val GlobalContentScale = AppChoicePreference<AppPreferences, PrefContentScale>( title = R.string.global_content_scale, @@ -1226,6 +1239,7 @@ val advancedPreferences = AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, + AppPreference.DpadSeekModePref, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, 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 1e84663d..abcee865 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 @@ -55,6 +55,7 @@ class AppPreferencesSerializer AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue cinemaMode = AppPreference.CinemaMode.defaultValue + dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index b61d6b89..f403c7a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -123,6 +123,7 @@ fun NowPlayingPage( // seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, controllerViewState = controllerViewState, updateSkipIndicator = {}, + clearSkipIndicator = {}, skipBackOnResume = null, // skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, @@ -132,6 +133,7 @@ fun NowPlayingPage( }, onPlaybackDialogTypeClick = { }, getDurationMs = { player.duration }, + dpadSeekMode = preferences.playbackPreferences.dpadSeekMode, ) } 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 7209fbd9..f885826c 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 @@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.media3.common.Player import androidx.media3.common.util.Util +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import kotlin.time.Duration @@ -24,11 +25,15 @@ class PlaybackKeyHandler( private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, + private val clearSkipIndicator: () -> Unit, private val skipBackOnResume: Duration?, private val oneClickPause: Boolean, private val onInteraction: () -> Unit, private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, + private val isDpadSeekVisible: () -> Boolean = { false }, + private val onDpadSeek: (Long) -> Unit = { }, + private val dpadSeekMode: DpadSeekMode, ) { private var leftHandledByRepeat = false private var rightHandledByRepeat = false @@ -49,20 +54,19 @@ class PlaybackKeyHandler( if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { if (isLtr) { - updateSkipIndicator(-seekBack.inWholeMilliseconds) - player.seekBack(seekBack) + seekBy(-seekBack) } else { - updateSkipIndicator(seekForward.inWholeMilliseconds) - player.seekForward(seekForward) + seekBy(seekForward) } } else if (skipWithLeftRight && isSkipForward(it)) { if (isLtr) { - player.seekForward(seekForward) - updateSkipIndicator(seekForward.inWholeMilliseconds) + seekBy(seekForward) } else { - player.seekBack(seekBack) - updateSkipIndicator(seekBack.inWholeMilliseconds) + seekBy(-seekBack) } + } else if (isEnterKey(it) && isDpadSeekVisible()) { + // If d-pad seek bar is visible, hide it + clearSkipIndicator.invoke() } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) @@ -180,12 +184,24 @@ class PlaybackKeyHandler( ) { if (isBack) { val skipDuration = seekBack * multiplier - player.seekBack(skipDuration) - updateSkipIndicator(-skipDuration.inWholeMilliseconds) + seekBy(-skipDuration) } else { val skipDuration = seekForward * multiplier - player.seekForward(skipDuration) - updateSkipIndicator(skipDuration.inWholeMilliseconds) + seekBy(skipDuration) + } + } + + private fun seekBy(duration: Duration) { + val durationMs = duration.inWholeMilliseconds + if (dpadSeekMode == DpadSeekMode.TRICKPLAY) { + onDpadSeek.invoke(durationMs) + } else { + if (duration < Duration.ZERO) { + player.seekBack(-duration) + } else { + player.seekForward(duration) + } + updateSkipIndicator(durationMs) } } 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 62eebd04..46c7753d 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 @@ -11,6 +11,8 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -68,6 +70,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume @@ -78,6 +81,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.overlay.DpadSeekOverlay import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay @@ -221,6 +225,23 @@ fun PlaybackPageContent( skipIndicatorDuration += delta skipPosition = player.currentPosition } + val onDpadSeek: (Long) -> Unit = { deltaMs -> + if (skipIndicatorDuration == 0L) { + skipPosition = player.currentPosition + } + if ((skipIndicatorDuration > 0 && deltaMs < 0) || + (skipIndicatorDuration < 0 && deltaMs > 0) + ) { + skipIndicatorDuration = 0L + } + skipIndicatorDuration += deltaMs + skipPosition = + (skipPosition + deltaMs).coerceIn( + minimumValue = 0L, + maximumValue = player.duration.coerceAtLeast(0L), + ) + seekBarState.onValueChange(skipPosition) + } val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val keyHandler = remember(isLtr, preferences) { @@ -234,6 +255,7 @@ fun PlaybackPageContent( getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, + clearSkipIndicator = { skipIndicatorDuration = 0 }, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, @@ -242,6 +264,11 @@ fun PlaybackPageContent( viewModel.navigationManager.goBack() }, onPlaybackDialogTypeClick = { playbackDialog = it }, + isDpadSeekVisible = { + prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY && skipIndicatorDuration != 0L + }, + onDpadSeek = onDpadSeek, + dpadSeekMode = prefs.dpadSeekMode, ) } @@ -351,8 +378,38 @@ fun PlaybackPageContent( } } + AnimatedVisibility( + visible = + !controllerViewState.controlsVisible && + skipIndicatorDuration != 0L && + prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + DpadSeekOverlay( + player = player, + seekPositionMs = skipPosition, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .fillMaxWidth(.95f), + ) + // Clear the overlay after a delay + LaunchedEffect(skipIndicatorDuration) { + delay(1.5.seconds) + skipIndicatorDuration = 0L + } + } + // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && prefs.dpadSeekMode != DpadSeekMode.TRICKPLAY) { + // Skip time mode: show seek distance indicator SkipIndicator( durationMs = skipIndicatorDuration, onFinish = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt new file mode 100644 index 00000000..0a3ed634 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import org.jellyfin.sdk.model.api.TrickplayInfo + +/** + * The compact overlay shown during D-Pad seeking in trickplay mode. + */ +@Composable +fun DpadSeekOverlay( + player: Player, + seekPositionMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + val durationMs = player.duration.coerceAtLeast(0L) + val seekProgressPercent = + if (durationMs > 0) { + (seekPositionMs.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + val bufferedProgress = + if (durationMs > 0) { + (player.bufferedPosition.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + TrickplayPreview( + seekProgressMs = seekPositionMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent(xPercentage = seekProgressPercent) + .padding(bottom = 8.dp), + ) + } + StaticSeekBarImpl( + progress = seekProgressPercent, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + SeekTimecodes( + positionMs = seekPositionMs, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index 79f44929..d8d43300 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -272,32 +272,45 @@ fun SeekBar( seekBack = seekBack, seekForward = seekForward, ) - Row( + SeekTimecodes( + positionMs = position, + durationMs = player.duration, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - val resources = LocalResources.current - val positionSec = position / 1000 - val remainingSec = (player.duration - position) / 1000 - val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } - val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } - Text( - text = positionText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - Text( - text = remainingText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - } + ) + } +} + +@Composable +fun SeekTimecodes( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val resources = LocalResources.current + val positionSec = positionMs / 1000 + val remainingSec = (durationMs - positionMs) / 1000 + val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } + Text( + text = positionText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + Text( + text = remainingText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 9af4a171..5f5891de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,13 +40,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.media3.common.Player -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter @@ -55,7 +51,6 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay -import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.AnalyticsState import com.github.damontecres.wholphin.ui.playback.ControllerViewState @@ -65,7 +60,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * The overlay during playback showing controls, seek preview image, debug info, etc @@ -288,33 +282,10 @@ fun PlaybackOverlay( xPercentage = seekProgressPercent.coerceIn(0f, 1f), ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), ) { - if (trickplayInfo != null) { - val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight - val index = - (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage - val imageUrl = remember(index) { trickplayUrlFor(index) } - - if (imageUrl != null) { - SeekPreviewImage( - modifier = Modifier, - previewImageUrl = imageUrl, - seekProgressMs = seekProgressMs, - trickPlayInfo = trickplayInfo, - ) - } - } - val resources = LocalResources.current - val seekText = remember(seekProgressMs / 1000L) { resources.formatDuration((seekProgressMs / 1000L).seconds) } - Text( - text = seekText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .background( - Color.Black.copy(alpha = 0.6f), - shape = RoundedCornerShape(4.dp), - ).padding(horizontal = 8.dp, vertical = 4.dp), + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index d0dfec31..c03be433 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -108,6 +108,30 @@ fun SteppedSeekBarImpl( ) } +/** + * A read-only seek bar for showing progress without taking focus or handling D-Pad input. + * + * This is used by overlays where seeking is handled elsewhere and the bar is only a visual indicator. + */ +@Composable +fun StaticSeekBarImpl( + progress: Float, + durationMs: Long, + bufferedProgress: Float, + modifier: Modifier = Modifier, +) { + SeekBarDisplay( + enabled = false, + progress = progress, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + onLeft = {}, + onRight = {}, + interactionSource = remember { MutableInteractionSource() }, + modifier = modifier, + ) +} + /** * A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt new file mode 100644 index 00000000..4871a77a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt @@ -0,0 +1,63 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.formatDuration +import org.jellyfin.sdk.model.api.TrickplayInfo +import kotlin.time.Duration.Companion.seconds + +@Composable +fun TrickplayPreview( + seekProgressMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (trickplayInfo != null) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val index = + (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage + val imageUrl = remember(index) { trickplayUrlFor(index) } + + if (imageUrl != null) { + SeekPreviewImage( + modifier = Modifier, + previewImageUrl = imageUrl, + seekProgressMs = seekProgressMs, + trickPlayInfo = trickplayInfo, + ) + } + } + val resources = LocalResources.current + val seekProgressSec = seekProgressMs / 1000L + val seekText = remember(seekProgressSec) { resources.formatDuration(seekProgressSec.seconds) } + Text( + text = seekText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 4b1f6c34..021db051 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -87,6 +87,12 @@ message PlaybackPreferences { bool resolution_switching = 23; string external_player = 24; bool cinema_mode = 25; + DpadSeekMode dpad_seek_mode = 26; +} + +enum DpadSeekMode { + SKIP_TIME = 0; + TRICKPLAY = 1; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa33cf7b..5f5a54de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,9 @@ <string name="add_to_playlist">Add to playlist</string> <string name="one_click_pause">Pause with one click</string> <string name="one_click_pause_summary_on">Press D-Pad center to pause/play</string> + <string name="d_pad_seek_mode_title">D-Pad seek mode</string> + <string name="dpad_seek_mode_skip_time">Skip time</string> + <string name="dpad_seek_mode_trickplay">Trickplay previews</string> <string name="italic_font">Italicize font</string> <string name="font">Font</string> <string name="background">Background</string> @@ -847,6 +850,11 @@ <string name="confirm_enable_experimental_title">Enable experimental settings?</string> <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> <string name="video_tunneling">Video tunneling</string> + <string-array name="dpad_seek_mode_options"> + <item>@string/dpad_seek_mode_skip_time</item> + <item>@string/dpad_seek_mode_trickplay</item> + </string-array> + <string name="blocklisted">Blocklisted</string> <string name="unavailable">Unavailable</string> <string name="subtitle_mode">Subtitle mode</string> From 26a23d673bf6379fdee94448a0fffcbb843831e7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 25 Jul 2026 16:10:00 -0400 Subject: [PATCH 175/475] Add option for dpad skipping with no seek bar (#1742) ## Description Adds a third option for d-pad seeking to not show any seek bar which only shows the skip indicator and not the seek bar along the bottom ### Related issues Original work to add other mode in #1650 Closes #1520 ### Testing Emulator testing ## Screenshots <img width="546" height="347" alt="image" src="https://github.com/user-attachments/assets/2872566e-4598-4284-92cb-95a45b307835" /> ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 3 ++- .../wholphin/ui/playback/PlaybackKeyHandler.kt | 2 +- .../wholphin/ui/playback/PlaybackPage.kt | 11 ++++++----- app/src/main/proto/WholphinDataStore.proto | 5 +++-- app/src/main/res/values/strings.xml | 18 ++++++++++++++---- 5 files changed, 26 insertions(+), 13 deletions(-) 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 308ed909..43615ca3 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 @@ -664,7 +664,7 @@ sealed interface AppPreference<Pref, T> { val DpadSeekModePref = AppChoicePreference<AppPreferences, DpadSeekMode>( title = R.string.d_pad_seek_mode_title, - defaultValue = DpadSeekMode.SKIP_TIME, + defaultValue = DpadSeekMode.SEEKBAR_MINIMAL, getter = { it.playbackPreferences.dpadSeekMode }, setter = { prefs, value -> prefs.updatePlaybackPreferences { dpadSeekMode = value } @@ -672,6 +672,7 @@ sealed interface AppPreference<Pref, T> { displayValues = R.array.dpad_seek_mode_options, indexToValue = { DpadSeekMode.forNumber(it) }, valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 }, + subtitles = R.array.dpad_seek_mode_summaries, ) val GlobalContentScale = 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 f885826c..71ea05e2 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 @@ -193,7 +193,7 @@ class PlaybackKeyHandler( private fun seekBy(duration: Duration) { val durationMs = duration.inWholeMilliseconds - if (dpadSeekMode == DpadSeekMode.TRICKPLAY) { + if (dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY) { onDpadSeek.invoke(durationMs) } else { if (duration < Duration.ZERO) { 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 46c7753d..e428546f 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 @@ -265,7 +265,7 @@ fun PlaybackPageContent( }, onPlaybackDialogTypeClick = { playbackDialog = it }, isDpadSeekVisible = { - prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY && skipIndicatorDuration != 0L + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY && skipIndicatorDuration != 0L }, onDpadSeek = onDpadSeek, dpadSeekMode = prefs.dpadSeekMode, @@ -382,7 +382,7 @@ fun PlaybackPageContent( visible = !controllerViewState.controlsVisible && skipIndicatorDuration != 0L && - prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY, + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY, enter = fadeIn() + slideInVertically { it / 2 }, exit = fadeOut() + slideOutVertically { it }, modifier = @@ -408,7 +408,9 @@ fun PlaybackPageContent( } // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && prefs.dpadSeekMode != DpadSeekMode.TRICKPLAY) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && + prefs.dpadSeekMode != DpadSeekMode.SEEKBAR_TRICKPLAY + ) { // Skip time mode: show seek distance indicator SkipIndicator( durationMs = skipIndicatorDuration, @@ -421,8 +423,7 @@ fun PlaybackPageContent( .padding(bottom = 70.dp), ) // Show a small progress bar along the bottom of the screen - val showSkipProgress = true // TODO get from preferences - if (showSkipProgress) { + if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { val percent = skipPosition.toFloat() / player.duration.toFloat() Box( modifier = diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 021db051..2a9a4170 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -91,8 +91,9 @@ message PlaybackPreferences { } enum DpadSeekMode { - SKIP_TIME = 0; - TRICKPLAY = 1; + SEEKBAR_MINIMAL = 0; + SEEKBAR_TRICKPLAY = 1; + SEEKBAR_NONE = 2; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f5a54de..9db3f496 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,9 +180,6 @@ <string name="add_to_playlist">Add to playlist</string> <string name="one_click_pause">Pause with one click</string> <string name="one_click_pause_summary_on">Press D-Pad center to pause/play</string> - <string name="d_pad_seek_mode_title">D-Pad seek mode</string> - <string name="dpad_seek_mode_skip_time">Skip time</string> - <string name="dpad_seek_mode_trickplay">Trickplay previews</string> <string name="italic_font">Italicize font</string> <string name="font">Font</string> <string name="background">Background</string> @@ -850,9 +847,22 @@ <string name="confirm_enable_experimental_title">Enable experimental settings?</string> <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> <string name="video_tunneling">Video tunneling</string> + + <string name="d_pad_seek_mode_title">D-Pad seek bar mode</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> <string-array name="dpad_seek_mode_options"> - <item>@string/dpad_seek_mode_skip_time</item> + <item>@string/dpad_seek_mode_minimal</item> <item>@string/dpad_seek_mode_trickplay</item> + <item>@string/none</item> + </string-array> + <string name="dpad_seek_mode_minimal_summary">Small seek bar along the bottom of the screen (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Seek bar with trickplay preview images</string> + <string name="dpad_seek_mode_none_summary">No seek bar</string> + <string-array name="dpad_seek_mode_summaries"> + <item>@string/dpad_seek_mode_minimal_summary</item> + <item>@string/dpad_seek_mode_trickplay_summary</item> + <item>@string/dpad_seek_mode_none_summary</item> </string-array> <string name="blocklisted">Blocklisted</string> From 4171d0b861db5ae468d7902f42bce043059c34d3 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 25 Jul 2026 16:13:31 -0400 Subject: [PATCH 176/475] Minor UI changes to playlist details page (#1749) ## Description Makes some minor changes to the playlist details page - Center playlist name - Show duration with seconds for songs - Adjust some padding Also fixes an issue where focus could be lost when leaving the page while focused on a playlist item Dev note: bulk of the change in thie PR is refactoring the `LazyColumn` of playlist items into its own composable ### Related issues N/A ### Testing Emulator ## Screenshots N/A, the changes are very minor ## AI or LLM usage None --- .../wholphin/ui/detail/PlaylistDetails.kt | 286 ++++++++++-------- 1 file changed, 157 insertions(+), 129 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 5859df56..1e570c61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -81,6 +80,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.FilterByButton +import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.MusicContextActions import com.github.damontecres.wholphin.ui.components.Optional @@ -102,6 +102,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.roundMinutes +import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.LocalClock @@ -367,7 +368,6 @@ fun PlaylistDetails( ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current val state by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() @@ -405,19 +405,21 @@ fun PlaylistDetails( } } val musicContextActions = - MusicContextActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - ) + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } val contextActions = remember { ContextMenuActions( @@ -534,7 +536,6 @@ fun PlaylistDetailsContent( ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focus = remember { FocusRequester() } val focusedItem = items.getOrNull(focusedIndex) LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) @@ -555,9 +556,15 @@ fun PlaylistDetailsContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(top = 16.dp) .fillMaxSize(), ) { + val title = + if (loadingState is LoadingState.Success) { + playlist?.name ?: stringResource(R.string.playlist) + } else { + "" + } + GridTitle(title) Row( horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = @@ -577,115 +584,28 @@ fun PlaylistDetailsContent( .padding(top = 80.dp) .fillMaxWidth(.25f), ) - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) - } - - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist?.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - if (items.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = item?.id in musicState.queuedIds, - modifier = - Modifier - .ifElse( - item?.type != BaseItemKind.AUDIO, - Modifier.height(80.dp), - ).ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, - ) - } - } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusable(), - ) - } - } - } - } - } + PlaylistItems( + loadingState = loadingState, + items = items, + musicState = musicState, + playButtonFocusRequester = playButtonFocusRequester, + onFocusItem = { index, item -> + focusedIndex = index + }, + onClickItem = { index, item -> + savedIndex = index + item?.let { onClickIndex.invoke(index, item) } + }, + onLongClickItem = { index, item -> + savedIndex = index + item?.let { onLongClickIndex.invoke(index, item) } + }, + modifier = + Modifier + .padding(horizontal = 16.dp) + .weight(1f) + .focusRequester(focusRequester), + ) } } } @@ -771,6 +691,97 @@ fun PlaylistDetailsHeader( } } +@Composable +fun PlaylistItems( + loadingState: LoadingState, + items: List<BaseItem?>, + musicState: MusicServiceState, + playButtonFocusRequester: FocusRequester, + onFocusItem: (Int, BaseItem?) -> Unit, + onClickItem: (Int, BaseItem?) -> Unit, + onLongClickItem: (Int, BaseItem?) -> Unit, + modifier: Modifier = Modifier, +) { + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .padding(bottom = 32.dp) + .fillMaxHeight() + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }.focusGroup() + .focusRestorer(), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + onClickItem.invoke(index, item) + }, + onLongClick = { + onLongClickItem.invoke(index, item) + }, + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = item?.id in musicState.queuedIds, + modifier = + Modifier + .ifElse( + item?.type != BaseItemKind.AUDIO, + Modifier.height(80.dp), + ).onFocusChanged { + if (it.isFocused) { + onFocusItem(index, item) + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } else { + LaunchedEffect(Unit) { + playButtonFocusRequester.tryRequestFocus() + } + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier, + ) + } + } + } + } +} + @Composable fun PlaylistItem( item: BaseItem?, @@ -804,7 +815,23 @@ fun PlaylistItem( ) }, trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> + val duration = + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } + + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } + } + duration?.let { duration -> val now by LocalClock.current.now val context = LocalContext.current val endTimeStr = @@ -813,12 +840,13 @@ fun PlaylistItem( formatTime(context, endTime) } val resources = LocalResources.current - val durationText = remember(duration) { resources.formatDuration(duration) } + val durationText = + remember(resources, duration) { resources.formatDuration(duration) } Column { Text( text = durationText, ) - if (item.type != BaseItemKind.AUDIO) { + if (item?.type != BaseItemKind.AUDIO) { Text( text = stringResource(R.string.ends_at, endTimeStr), style = MaterialTheme.typography.bodySmall, From e7b0be2660300eca03c47e922729bcebfdd3c6c9 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 25 Jul 2026 22:52:02 -0400 Subject: [PATCH 177/475] Display server error message on server list (#1741) ## Description On the switch server page, display error message at the bottom for the focused server. This includes unsupported server version. ### Related issues Jellyfin 10.11/12.0 support discussion: #1640 ### Testing Emulator ## Screenshots <img width="1280" height="720" alt="server_error Large" src="https://github.com/user-attachments/assets/1227e7e7-a364-48d9-9a77-cb3f78d134a1" /> ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 4 + .../wholphin/ui/setup/ServerList.kt | 47 ++++----- .../wholphin/ui/setup/SwitchServerContent.kt | 98 +++++++++++++++++-- .../ui/setup/SwitchServerViewModel.kt | 30 +++++- .../wholphin/ui/setup/SwitchUserContent.kt | 33 +++++++ .../wholphin/ui/setup/SwitchUserViewModel.kt | 21 ++++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 200 insertions(+), 34 deletions(-) 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 ceb7264e..bd25a2d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -15,6 +15,8 @@ import org.acra.ReportField import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber import javax.inject.Inject @@ -120,5 +122,7 @@ class WholphinApplication : companion object { lateinit var instance: WholphinApplication private set + + val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion } } 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 84e4cea6..81cc7c2a 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 @@ -97,6 +97,7 @@ fun rememberIdColor( @Composable fun ServerIconCard( server: JellyfinServer, + serverVersionSupported: ServerVersionSupported, connectionStatus: ServerConnectionStatus, isCurrentServer: Boolean, onClick: () -> Unit, @@ -166,28 +167,8 @@ fun ServerIconCard( contentAlignment = Alignment.Center, ) { // Show connection status indicator or server name/letter - when (connectionStatus) { - is ServerConnectionStatus.Success -> { - // Show server name/letter - - Text( - text = displayText, - style = - MaterialTheme.typography.displayLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - - ServerConnectionStatus.Pending -> { - CircularProgress( - modifier = Modifier.size(cardSize * 0.4f), - ) - } - - is ServerConnectionStatus.Error -> { + when { + connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> { // Show warning icon with server letter below Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -195,7 +176,7 @@ fun ServerIconCard( ) { Icon( imageVector = Icons.Default.Warning, - contentDescription = connectionStatus.message, + contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, tint = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.size(cardSize * 0.3f), ) @@ -210,6 +191,26 @@ fun ServerIconCard( ) } } + + connectionStatus is ServerConnectionStatus.Success -> { + // Show server name/letter + + Text( + text = displayText, + style = + MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + connectionStatus == ServerConnectionStatus.Pending -> { + CircularProgress( + modifier = Modifier.size(cardSize * 0.4f), + ) + } } } } 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 d975fc29..71483c0a 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 @@ -1,5 +1,10 @@ package com.github.damontecres.wholphin.ui.setup +import android.content.res.Resources +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,12 +35,16 @@ import androidx.compose.ui.Modifier 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.LocalResources 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 import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -55,6 +64,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -85,8 +95,10 @@ private fun SwitchServerContentInternal( viewModel: SwitchServerViewModel, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } + var focusedIndex by rememberInt(0) Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -125,8 +137,13 @@ private fun SwitchServerContentInternal( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (state.servers.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(state.loading) { + val state = state + if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { + firstServerFocus.tryRequestFocus() + } else if (state.loading == LoadingState.Pending) { + firstServerFocus.tryRequestFocus() + } } LazyRow( horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -141,6 +158,7 @@ private fun SwitchServerContentInternal( ServerIconCard( server = server.server, connectionStatus = server.status, + serverVersionSupported = server.versionSupported, isCurrentServer = false, // TODO: Determine current server if needed onClick = { when (server.status) { @@ -162,10 +180,13 @@ private fun SwitchServerContentInternal( }, allowDelete = true, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = index + }.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost @@ -173,10 +194,13 @@ private fun SwitchServerContentInternal( AddServerCard( onClick = { showAddServer = true }, modifier = - Modifier.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = -1 + }.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -191,6 +215,37 @@ private fun SwitchServerContentInternal( ) } + val errorMessage = + remember(resources, focusedIndex, state.servers) { + val server = state.servers.getOrNull(focusedIndex) + serverErrorMessage(server, resources) + } + AnimatedContent( + targetState = errorMessage, + transitionSpec = { + slideInVertically { it / 2 } togetherWith slideOutVertically { it } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { message -> + + Text( + text = message ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + // Delete server dialog showDeleteDialog?.let { server -> DialogPopup( @@ -411,3 +466,26 @@ private fun SwitchServerContentInternal( } } } + +fun serverErrorMessage( + server: ServerState?, + resources: Resources, +): String? = + when { + server?.status is ServerConnectionStatus.Error -> { + server.status.message + } + + server?.status is ServerConnectionStatus.Success && + server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" + } + + server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + } + + else -> { + null + } + } 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 9c28aff8..209320eb 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 @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -24,6 +25,7 @@ import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -73,11 +75,30 @@ class SwitchServerViewModel server: JellyfinServer, result: ServerConnectionStatus, ) { + val supported = + if (result is ServerConnectionStatus.Success) { + val serverVersion = + result.systemInfo.version?.let { ServerVersion.fromString(it) } + if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED + } else { + ServerVersionSupported.SUPPORTED + } + } else { + ServerVersionSupported.UNKNOWN + } _state.update { val servers = it.servers.toMutableList().apply { val index = indexOfFirst { it.server.id == server.id } - set(index, get(index).copy(server = server, status = result)) + set( + index, + get(index).copy( + server = server, + status = result, + versionSupported = supported, + ), + ) } it.copy(servers = servers) } @@ -245,4 +266,11 @@ data class ServerState( val server: JellyfinServer, val status: ServerConnectionStatus = ServerConnectionStatus.Pending, val quickConnect: Boolean = false, + val versionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +enum class ServerVersionSupported { + SUPPORTED, + NOT_SUPPORTED, + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 2d9158de..22b5a594 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -25,11 +25,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -169,6 +171,37 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } + when (state.serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> {} + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + val resources = LocalResources.current + val message = + remember(resources) { + if (state.serverVersion.isNotNullOrBlank()) { + resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}" + } else { + resources.getString(R.string.server_version_not_supported) + ": " + + resources.getString(R.string.unknown) + } + } + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 8b8c159a..a282eb15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -33,7 +34,9 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult import timber.log.Timber @@ -78,10 +81,13 @@ class SwitchUserViewModel _state.update { SwitchUserState() } try { val serverUsers = getUsers() + val (serverVersion, supported) = checkServerVersion() _state.update { it.copy( loading = LoadingState.Success, users = serverUsers, + serverVersion = serverVersion, + serverVersionSupported = supported, ) } } catch (ex: Exception) { @@ -272,6 +278,19 @@ class SwitchUserViewModel knownUsers + publicUsers } + private suspend fun checkServerVersion(): Pair<String?, ServerVersionSupported> { + val api = jellyfin.createApi(server.url) + val systemInfo by api.systemApi.getPublicSystemInfo() + val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } + return if (serverVersion == null) { + systemInfo.version to ServerVersionSupported.UNKNOWN + } else if (serverVersion < WholphinApplication.minimumServerVersion) { + systemInfo.version to ServerVersionSupported.NOT_SUPPORTED + } else { + systemInfo.version to ServerVersionSupported.SUPPORTED + } + } + private fun setError( msg: String? = null, ex: Exception? = null, @@ -300,4 +319,6 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, + val serverVersion: String? = null, + val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9db3f496..115efc22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -898,4 +898,5 @@ <string name="user_views">User views</string> <string name="books">Books</string> <string name="audio_books">Audio books</string> + <string name="server_version_not_supported">Server version not supported</string> </resources> From 9954365c6fa94bcf9cf57a64a8487aec1af94242 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 25 Jul 2026 22:52:09 -0400 Subject: [PATCH 178/475] Add two new themes: Red & Brown (#1721) ## Description Adds new two new theme colors: Red & Brown ### Related issues Related to #91, #950 Closes #1646 ### Testing <!-- Describe how this change was tested and on what device(s) --> ## Screenshots <img width="2727" height="2171" alt="image" src="https://github.com/user-attachments/assets/ae19f98d-e782-466a-bafc-6c0502f64e00" /> ## AI or LLM usage None --- .../wholphin/ui/cards/WatchedIcon.kt | 4 + .../wholphin/ui/components/SliderBar.kt | 4 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 2 + .../ui/preferences/SwitchPreference.kt | 2 + .../damontecres/wholphin/ui/theme/Theme.kt | 4 + .../wholphin/ui/theme/ThemePreview.kt | 26 ++- .../ui/theme/colors/BrownThemeColors.kt | 204 ++++++++++++++++++ .../ui/theme/colors/RedThemeColors.kt | 204 ++++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 2 + app/src/main/res/values/strings.xml | 3 + 10 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt index 0a92199b..ce8952a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt @@ -45,6 +45,8 @@ fun WatchedIconBackground(): Color = AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border.copy(alpha = 1f) AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer @@ -60,6 +62,8 @@ fun WatchedIconColor(): Color = AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> Color.White // MaterialTheme.colorScheme.onSurface } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index f87f5c98..9021e213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -146,6 +146,8 @@ fun sliderActiveColor(focused: Boolean): Color { AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.border } @@ -188,6 +190,8 @@ fun sliderInactiveColor(focused: Boolean): Color { AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 19f027a8..efbf1a4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -772,6 +772,8 @@ fun navItemColor( AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border AppThemeColors.BOLD_BLUE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt index 372af78a..daf6fa07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt @@ -79,6 +79,8 @@ fun SwitchColors(): SwitchColors { AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 53c10bd2..8ae95b25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -7,10 +7,12 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BlueThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BoldBlueThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.BrownThemeColors import com.github.damontecres.wholphin.ui.theme.colors.GreenThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors val LocalTheme = compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE } @@ -23,6 +25,8 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.ORANGE -> OrangeThemeColors AppThemeColors.OLED_BLACK -> OledThemeColors AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors + AppThemeColors.RED -> RedThemeColors + AppThemeColors.BROWN -> BrownThemeColors AppThemeColors.UNRECOGNIZED -> PurpleThemeColors } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 70d30254..f5c83ce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -19,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem @@ -42,26 +44,37 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2500dp", + device = "spec:width=1200dp,height=3000dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @Composable private fun ThemePreview() { + val themes = AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED } Column { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED }) { + items(themes) { ThemeExample(it) } } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + color = Color.White, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) +} + @Composable private fun ThemeExample(theme: AppThemeColors) { val source = remember { PreviewInteractionSource() } @@ -71,10 +84,13 @@ private fun ThemeExample(theme: AppThemeColors) { Text( text = theme.toString(), color = Color.White, + modifier = Modifier.padding(8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(8.dp), ) { + SectionTitle("Cards") Row( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, @@ -134,6 +150,7 @@ private fun ThemeExample(theme: AppThemeColors) { aspectRatio = AspectRatios.SQUARE, ) } + SectionTitle("Controls") Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SliderPreference( preference = AppPreference.AutoPlayNextDelay, @@ -191,6 +208,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("Nav drawer") val navScope = NavScope(true) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -240,6 +258,7 @@ private fun ThemeExample(theme: AppThemeColors) { ) } } + SectionTitle("Playback controls") Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), @@ -271,6 +290,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("List items") Column( modifier = Modifier.background(MaterialTheme.colorScheme.surface), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt new file mode 100644 index 00000000..04c050fd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val BrownThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF8A5022) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDCC6) + val onPrimaryContainerLight = Color(0xFF6D390C) + val secondaryLight = Color(0xFF755845) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDCC6) + val onSecondaryContainerLight = Color(0xFF5B412F) + val tertiaryLight = Color(0xFF5F6135) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFE5E6AE) + val onTertiaryContainerLight = Color(0xFF47491F) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F5) + val onBackgroundLight = Color(0xFF221A15) + val surfaceLight = Color(0xFFFFF8F5) + val onSurfaceLight = Color(0xFF221A15) + val surfaceVariantLight = Color(0xFFF3DED3) + val onSurfaceVariantLight = Color(0xFF52443B) + val outlineLight = Color(0xFF84746A) + val outlineVariantLight = Color(0xFFD7C3B7) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF382F29) + val inverseOnSurfaceLight = Color(0xFFFEEDE4) + val inversePrimaryLight = Color(0xFFFFB784) + val surfaceDimLight = Color(0xFFE7D7CE) + val surfaceBrightLight = Color(0xFFFFF8F5) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF1EA) + val surfaceContainerLight = Color(0xFFFBEBE2) + val surfaceContainerHighLight = Color(0xFFF6E5DC) + val surfaceContainerHighestLight = Color(0xFFF0DFD6) + + val primaryDark = Color(0xFFFFB784) + val onPrimaryDark = Color(0xFF4F2500) + val primaryContainerDark = Color(0xFF6D390C) + val onPrimaryContainerDark = Color(0xFFFFDCC6) + val secondaryDark = Color(0xFFE4BFA8) + val onSecondaryDark = Color(0xFF422B1B) + val secondaryContainerDark = Color(0xFF5B412F) + val onSecondaryContainerDark = Color(0xFFFFDCC6) + val tertiaryDark = Color(0xFFC8CA94) + val onTertiaryDark = Color(0xFF30330B) + val tertiaryContainerDark = Color(0xFF47491F) + val onTertiaryContainerDark = Color(0xFFE5E6AE) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF19120D) + val onBackgroundDark = Color(0xFFF0DFD6) + val surfaceDark = Color(0xFF19120D) + val onSurfaceDark = Color(0xFFF0DFD6) + val surfaceVariantDark = Color(0xFF52443B) + val onSurfaceVariantDark = Color(0xFFD7C3B7) + val outlineDark = Color(0xFF9F8D83) + val outlineVariantDark = Color(0xFF52443B) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF0DFD6) + val inverseOnSurfaceDark = Color(0xFF382F29) + val inversePrimaryDark = Color(0xFF6D3B07) + val surfaceDimDark = Color(0xFF19120D) + val surfaceBrightDark = Color(0xFF413731) + val surfaceContainerLowestDark = Color(0xFF140D08) + val surfaceContainerLowDark = Color(0xFF221A15) + val surfaceContainerDark = Color(0xFF261E18) + val surfaceContainerHighDark = Color(0xFF312822) + val surfaceContainerHighestDark = Color(0xFF3C332D) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt new file mode 100644 index 00000000..ecdcbdf4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val RedThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF904A42) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDAD5) + val onPrimaryContainerLight = Color(0xFF73342C) + val secondaryLight = Color(0xFF775652) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDAD5) + val onSecondaryContainerLight = Color(0xFF5D3F3B) + val tertiaryLight = Color(0xFF715C2E) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFFDDFA6) + val onTertiaryContainerLight = Color(0xFF574419) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F7) + val onBackgroundLight = Color(0xFF231918) + val surfaceLight = Color(0xFFFFF8F7) + val onSurfaceLight = Color(0xFF231918) + val surfaceVariantLight = Color(0xFFF5DDDA) + val onSurfaceVariantLight = Color(0xFF534341) + val outlineLight = Color(0xFF857370) + val outlineVariantLight = Color(0xFFD8C2BE) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF392E2C) + val inverseOnSurfaceLight = Color(0xFFFFEDEA) + val inversePrimaryLight = Color(0xFFFFB4AA) + val surfaceDimLight = Color(0xFFE8D6D4) + val surfaceBrightLight = Color(0xFFFFF8F7) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF0EE) + val surfaceContainerLight = Color(0xFFFCEAE7) + val surfaceContainerHighLight = Color(0xFFF7E4E2) + val surfaceContainerHighestLight = Color(0xFFF1DEDC) + + val primaryDark = Color(0xFFFFB4AA) + val onPrimaryDark = Color(0xFF561E18) + val primaryContainerDark = Color(0xFF73342C) + val onPrimaryContainerDark = Color(0xFFFFDAD5) + val secondaryDark = Color(0xFFE7BDB7) + val onSecondaryDark = Color(0xFF442926) + val secondaryContainerDark = Color(0xFF5D3F3B) + val onSecondaryContainerDark = Color(0xFFFFDAD5) + val tertiaryDark = Color(0xFFDFC38C) + val onTertiaryDark = Color(0xFF3F2E04) + val tertiaryContainerDark = Color(0xFF574419) + val onTertiaryContainerDark = Color(0xFFFDDFA6) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF1A1110) + val onBackgroundDark = Color(0xFFF1DEDC) + val surfaceDark = Color(0xFF1A1110) + val onSurfaceDark = Color(0xFFF1DEDC) + val surfaceVariantDark = Color(0xFF534341) + val onSurfaceVariantDark = Color(0xFFD8C2BE) + val outlineDark = Color(0xFFA08C8A) + val outlineVariantDark = Color(0xFF534341) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF1DEDC) + val inverseOnSurfaceDark = Color(0xFF392E2C) + val inversePrimaryDark = Color(0xFFC0000C) + val surfaceDimDark = Color(0xFF1A1110) + val surfaceBrightDark = Color(0xFF423735) + val surfaceContainerLowestDark = Color(0xFF140C0B) + val surfaceContainerLowDark = Color(0xFF231918) + val surfaceContainerDark = Color(0xFF271D1C) + val surfaceContainerHighDark = Color(0xFF322826) + val surfaceContainerHighestDark = Color(0xFF3D3231) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 2a9a4170..2db6201b 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -120,6 +120,8 @@ enum AppThemeColors { ORANGE = 3; BOLD_BLUE = 4; OLED_BLACK = 5; + RED = 6; + BROWN = 7; } enum EdgeStyle{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 115efc22..e268b2b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -599,6 +599,7 @@ <string name="orange">Orange</string> <string name="bold_blue">Bold Blue</string> <string name="black">Black</string> + <string name="brown">Brown</string> <string-array name="app_theme_colors"> <item>@string/purple</item> <item>@string/blue</item> @@ -606,6 +607,8 @@ <item>@string/orange</item> <item>@string/bold_blue</item> <item>@string/black</item> + <item>@string/red</item> + <item>@string/brown</item> </string-array> <string name="skip_ignore">Ignore</string> From ad1e5ec20522b332aab3ec184d19f76802578d9e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 26 Jul 2026 16:16:15 -0400 Subject: [PATCH 179/475] Update libass-android to 0.5.0-beta01 (#1752) ## Description Updates `libass-android` to `0.5.0-beta01`. While this is a beta release, from my testing it is very stable. It also fixes several issues, including crashes, as well. So I want to merge into the develop branch, but it can always been reverted if necessary. ### Related issues Should fix these issues: - #1554 - #1561 - #1678 ### Testing On emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- 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 a98a6ec6..24b7215a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -assMedia = "0.4.0" +assMedia = "0.5.0-beta01" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" openapi-generator = "7.23.0" From f16a7bb6d628e32bfe93f76d28c389341ea3d2ea Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 26 Jul 2026 16:36:49 -0400 Subject: [PATCH 180/475] Fix trying to fetch images with zero fillWidth (#1753) ## Description The bug report was caused by a race condition where the image URL was built before the grid was fully laid out causing a `fillWidth=0` and the `remember` would not reset after the `fillWidth` was updated to the actual value. So this PR fixes the `remember`. Furthermore, some extra guards are added to only pass the `fillWidth` & `fillHeight` params if greater than 0. Finally, the `EpisodeCard` code is deleted because it's height & width logic was overly complicated and `BannerCardWithTitle` provides better functionality anyway. ### Related issues Should fix #1751 Maybe #1156 is also fixed by this ### Testing Emulator, but I haven't been able to reproduce the race condition, but the fix makes sense given the bug report ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ImageUrlService.kt | 8 +- .../wholphin/ui/cards/EpisodeCard.kt | 154 ------------------ .../wholphin/ui/cards/ItemCardImage.kt | 5 +- .../wholphin/ui/cards/SeasonCard.kt | 9 +- .../wholphin/ui/detail/CardGrid.kt | 2 +- .../wholphin/ui/main/SearchPage.kt | 8 +- 6 files changed, 19 insertions(+), 167 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt 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 1dd642b0..6b71d683 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 @@ -223,11 +223,11 @@ class ImageUrlService imageType = imageType, maxWidth = maxWidth, maxHeight = maxHeight, - width = width, - height = height, + width = width?.takeIf { it > 0 }, + height = height?.takeIf { it > 0 }, quality = quality, - fillWidth = fillWidth, - fillHeight = fillHeight, + fillWidth = fillWidth?.takeIf { it > 0 }, + fillHeight = fillHeight?.takeIf { it > 0 }, tag = tag, format = format, percentPlayed = percentPlayed, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt deleted file mode 100644 index 3cad76ab..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -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 -import androidx.compose.foundation.layout.aspectRatio -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.foundation.shape.RoundedCornerShape -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.seasonEpisode -import kotlinx.coroutines.delay - -@Composable -fun EpisodeCard( - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, - imageWidth: Dp = Dp.Unspecified, - showImageOverlay: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val dto = item?.data - val focused by interactionSource.collectIsFocusedAsState() - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } - val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN - val width = imageHeight * aspectRatio - val height = imageWidth * (1f / aspectRatio) - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.size(width, height), - ) { - Card( - modifier = - Modifier - .size(imageWidth, imageHeight) - .aspectRatio(aspectRatio), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = showImageOverlay, - favorite = dto?.userData?.isFavorite ?: false, - watched = dto?.userData?.played ?: false, - unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, - watchedPercent = dto?.userData?.playedPercentage, - numberOfVersions = dto?.mediaSourceCount ?: 0, - useFallbackText = false, - fillWidth = imageWidthPx, - modifier = - Modifier - .fillMaxSize(), - ) - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(8.dp), - ), - ) { - Text( - text = dto?.seasonEpisode ?: "", - modifier = Modifier.padding(4.dp), - ) - } - } - } - SlidingCardText(focused) { - Text( - text = dto?.seriesName ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.name ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 41a3aca4..3f7a6b1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.ImageType @@ -68,13 +69,15 @@ fun ItemCardImage( val imageUrlService = LocalImageUrlService.current val imageUrl = remember(item, imageType, fillWidth, fillHeight) { - item?.let { + if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) { imageUrlService.getItemImageUrl( item, imageType, fillWidth = fillWidth, fillHeight = fillHeight, ) + } else { + null } } ItemCardImage( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 5caa45bf..a6f69071 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.MaterialTheme @@ -36,15 +37,15 @@ import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.ImageType /** - * A Card for a TV Show Season, but can generically show most items + * A Card for a TV Show Season, but can generally show most items */ @Composable fun SeasonCard( item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, + imageHeight: Dp, modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, @@ -56,7 +57,7 @@ fun SeasonCard( remember(item, imageHeight, imageWidth, density) { if (item != null) { val fillHeight = - if (imageHeight != Dp.Unspecified) { + if (imageHeight.isSpecified) { with(density) { imageHeight.roundToPx() } @@ -64,7 +65,7 @@ fun SeasonCard( null } val fillWidth = - if (imageWidth != Dp.Unspecified) { + if (imageWidth.isSpecified) { with(density) { imageWidth.roundToPx() } 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 32c4a64a..7d34d0b1 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 @@ -345,7 +345,7 @@ fun <T : CardGridItem> CardGrid( items(pager.size) { index -> val item = pager[index] val details = - remember(index, item) { + remember(index, item, cardWidthPx) { val mod = if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") 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 2b54f693..135c3b74 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 @@ -84,8 +84,8 @@ import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRowTitle @@ -855,15 +855,17 @@ fun SearchPage( onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = @Composable { index, item, mod, onClick, onLongClick -> - EpisodeCard( + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, item = item, onClick = { setPosition(RowColumn(EPISODE_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, - imageHeight = 140.dp, modifier = mod.padding(horizontal = 8.dp), + cardHeight = Cards.heightEpisode, ) }, ) From bf43a41405408d74acd2f2011afd049f02928422 Mon Sep 17 00:00:00 2001 From: Steve Smith <tarkasteve@gmail.com> Date: Tue, 28 Jul 2026 02:43:47 +1000 Subject: [PATCH 181/475] Correct sort order when "Date Played" is selected in TV library. (#1746) ## Description The TV library page offers the option to sort by date played, however when selected it reverts to the default (alphabetical). This appears to be due to the use of `ItemSortBy.DATE_PLAYED` rather than the correct `ItemSortBy.SERIES_DATE_PLAYED`. See [BrowseGridFragment](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/ui/browsing/BrowseGridFragment.java#L169) for the usage in the official Android TV client. ### Related issues N/A ### Testing Emulator against personal Jellyfin server. ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/ui/data/SortAndDirection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 6044ffe0..36b4e5c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -48,7 +48,7 @@ val SeriesSortOptions = ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_LAST_CONTENT_ADDED, - ItemSortBy.DATE_PLAYED, + ItemSortBy.SERIES_DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, From 79cb93db08ee6e4382fd9520a3955271b0d069d1 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 28 Jul 2026 15:17:57 -0400 Subject: [PATCH 182/475] Fixes to artist detail page (#1762) ## Description Fixes an incorrect query for an artist's albums (thanks @StefanBS for reporting & fixing in #1761!) Additionally, another album row for "Appears on" is added. This is definitely necessary because Wholphin only has a tab for all artists which includes artists featured on albums attributed to a different artist. So those albums will now show in the "Appears on" similar to the web UI instead of an empty artist page. Finally, the long-click context menu is added for music videos on an artist's page. ### Related issues Fixes #1760 Replaced #1761 ### Testing Emulator, particularly with artists who are not album artists Also, verified the query fix against the web UI dev console ## Screenshots N/A ## AI or LLM usage None, but the fix included from #1761 did use some AI --------- Co-authored-by: Stefan Bullones <stefan.bullones@gmail.com> --- .../ui/detail/music/ArtistDetailsPage.kt | 237 +++++++++++++----- app/src/main/res/values/strings.xml | 1 + 2 files changed, 178 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index eacc0e5a..2a010148 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -59,6 +59,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText @@ -154,7 +155,8 @@ class ArtistViewModel async { val request = GetItemsRequest( - parentId = itemId, + albumArtistIds = listOf(itemId), + recursive = true, fields = DefaultItemFields, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), sortBy = @@ -166,14 +168,38 @@ class ArtistViewModel ) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() } + val appearsOnDeferred = + async { + val request = + GetItemsRequest( + contributingArtistIds = listOf(itemId), + recursive = true, + fields = DefaultItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, + ), + sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING), + ) + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + } val artist = itemDeferred.await() val albums = albumsDeferred.await() + val appearsOn = appearsOnDeferred.await() val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY) _state.update { it.copy( artist = artist, imageUrl = imageUrl, albums = albums, + appearsOnAlbums = appearsOn, loading = LoadingState.Success, ) } @@ -218,24 +244,7 @@ class ArtistViewModel _state.update { it.copy(similar = similar) } } } - viewModelScope.launchIO { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - artistIds = listOf(itemId), - parentId = null, - fields = DefaultItemFields, - recursive = true, - includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), - ) - val musicVideos = - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - if (musicVideos.isNotEmpty()) { - _state.update { - it.copy(musicVideos = musicVideos) - } - } - } + updateMusicVideos() } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -250,17 +259,52 @@ class ArtistViewModel } } + private fun updateMusicVideos() { + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + artistIds = listOf(itemId), + parentId = null, + fields = DefaultItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } + } + fun setFavorite( itemId: UUID, favorite: Boolean, ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - val artist = - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(artist = artist) } + // Refresh if artist, otherwise only could be a music video + if (itemId == this@ArtistViewModel.itemId) { + val artist = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(artist = artist) } + } else { + updateMusicVideos() + } + } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + // Only applies to music videos + updateMusicVideos() } } @@ -269,6 +313,7 @@ data class ArtistState( val imageUrl: String?, val topSongs: List<BaseItem?>, val albums: List<BaseItem?>, + val appearsOnAlbums: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, val musicVideos: List<BaseItem?>, @@ -282,6 +327,7 @@ data class ArtistState( emptyList(), emptyList(), emptyList(), + emptyList(), LoadingState.Pending, emptyList(), false, @@ -292,7 +338,8 @@ data class ArtistState( private const val HEADER_ROW = 0 private const val SONG_ROW = HEADER_ROW + 1 private const val ALBUM_ROW = SONG_ROW + 1 -private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1 +private const val APPEARS_ON_ROW = SONG_ROW + 1 +private const val MUSIC_VIDEO_ROW = APPEARS_ON_ROW + 1 private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 @Composable @@ -499,41 +546,81 @@ fun ArtistDetailsPage( } } } - item { - ItemRow( - title = stringResource(R.string.albums), - items = state.albums, - onClickItem = { index, album -> - position = RowColumn(ALBUM_ROW, index) - viewModel.navigationManager.navigateTo(album.destination()) - }, - onLongClickItem = { index, album -> - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, + if (state.albums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.albums), + items = state.albums, + onClickItem = { index, album -> + position = RowColumn(ALBUM_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), item = album, - index = index, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, ) - }, - cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = album?.name, - subtitle = album?.data?.productionYear?.toString(), - item = album, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = AspectRatios.SQUARE, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), - ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } + } + if (state.appearsOnAlbums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.appears_on), + items = state.appearsOnAlbums, + onClickItem = { index, album -> + position = RowColumn(APPEARS_ON_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), + item = album, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } } if (state.musicVideos.isNotEmpty()) { item { @@ -545,7 +632,37 @@ fun ArtistDetailsPage( viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { index, item -> - // TODO + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = + ContextMenuActions( + navigateTo = viewModel.navigationManager::navigateTo, + onShowOverview = {}, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { _, _ -> }, + onChooseTracks = { }, + onClearChosenStreams = {}, + ), + ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> BannerCardWithTitle( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e268b2b1..9d909430 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -902,4 +902,5 @@ <string name="books">Books</string> <string name="audio_books">Audio books</string> <string name="server_version_not_supported">Server version not supported</string> + <string name="appears_on">Appears on</string> </resources> From 438c9dd70473354fd03b2b05c79202cb790b5abd Mon Sep 17 00:00:00 2001 From: Stefan Bullones <stefan.bullones@gmail.com> Date: Wed, 29 Jul 2026 00:45:02 +0200 Subject: [PATCH 183/475] Selecting a song on the search page navigates to a song's album with the song focused (#1661) ## Description Selecting a song from search now resolves to its album's detail page, scrolled to and focusing that song, instead of sending you to an "unsupported item type" page. Implemented in the shared BaseItem.destination() rather than in search specifically, so any caller that navigates to a song gets the same behavior. A song with no albumId plays directly while remaining in the search results page: NavigationManager.navigateTo() intercepts a bare AUDIO destination and hands it to a new MusicService.playSong(). Handling this in the single navigation funnel means every caller gets it for free with no loading-screen flash. Adds BaseItemDestinationTest covering the new destination() logic. ### Related issues https://github.com/damontecres/Wholphin/issues/1656 ### Testing Device: Sony Bravia 4K VH21 #### Tests on a search results page, song with albumId: 1. Select a song sends you to album page with cursor on song. Another press plays it 2. Back button brings you back to search results. Tested with both songs at the start of an album and songs deep into a 100+ songs album. #### Tests on a search results page, song with no albumId: 1. Select a songs plays the song while staying in the search results page. No loading screen. ## AI or LLM usage I used Cursor and Claude Code for planning, implementation and code review. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../wholphin/data/model/BaseItem.kt | 10 + .../ui/detail/CollectionFolderMusic.kt | 8 +- .../ui/detail/music/AlbumDetailsPage.kt | 30 +- .../ui/detail/music/MusicExpandableButtons.kt | 17 +- .../ui/detail/music/SongDetailsPage.kt | 270 ++++++++++++++++++ .../wholphin/ui/nav/Destination.kt | 1 + .../wholphin/ui/nav/DestinationContent.kt | 11 + .../wholphin/test/BaseItemDestinationTest.kt | 49 ++++ .../damontecres/wholphin/test/TestUtils.kt | 18 ++ 9 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt 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 336c8d9a..5ca6bf95 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 @@ -272,6 +272,16 @@ data class BaseItem( } } + BaseItemKind.AUDIO -> { + data.albumId?.let { albumId -> + Destination.MediaItem( + itemId = albumId, + type = BaseItemKind.MUSIC_ALBUM, + initialSongId = id, + ) + } ?: Destination.MediaItem(this) + } + else -> { Destination.MediaItem(this) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index a95a1ea2..9ab7e33f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -75,13 +75,7 @@ class CollectionFolderMusicViewModel index: Int, item: BaseItem, ) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } else { - navigationManager.navigateTo(item.destination()) - } + navigationManager.navigateTo(item.destination()) } fun onClickPlayAll(shuffle: Boolean) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index f7f50bdb..c43b0fe6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -124,11 +124,15 @@ class AlbumViewModel private val backdropService: BackdropService, private val imageUrlService: ImageUrlService, private val extrasService: ExtrasService, - @Assisted itemId: UUID, + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") private val initialSongId: UUID?, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): AlbumViewModel + fun create( + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") initialSongId: UUID?, + ): AlbumViewModel } private val _state = MutableStateFlow(AlbumState.EMPTY) @@ -158,6 +162,10 @@ class AlbumViewModel val songsDeferred = async { getPagerForAlbum(api, itemId) } val album = itemDeferred.await() val songs = songsDeferred.await() + val initialSongIndex = + initialSongId?.let { id -> + songs.indexOfBlocking { it?.id == id }.takeIf { it >= 0 } + } val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) val allArtists = album.data.artists.orEmpty() + @@ -175,6 +183,7 @@ class AlbumViewModel isVariousArtists = isVariousArtists, imageUrl = imageUrl, songs = songs, + initialSongIndex = initialSongIndex, loading = LoadingState.Success, ) } @@ -289,6 +298,7 @@ data class AlbumState( val songs: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, + val initialSongIndex: Int? = null, val musicVideos: List<BaseItem?> = emptyList(), val extras: List<ExtrasItem> = emptyList(), val canDelete: Boolean = false, @@ -309,9 +319,10 @@ fun AlbumDetailsPage( itemId: UUID, preferences: UserPreferences, modifier: Modifier = Modifier, + initialSongId: UUID? = null, viewModel: AlbumViewModel = hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>( - creationCallback = { it.create(itemId) }, + creationCallback = { it.create(itemId, initialSongId) }, ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { @@ -320,7 +331,6 @@ fun AlbumDetailsPage( val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() - var position by rememberPosition(0, 0) val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusManager = LocalFocusManager.current @@ -356,12 +366,20 @@ fun AlbumDetailsPage( LoadingState.Success -> { val album = state.album!! + val itemsBefore = 2 + val initialSongIndex = state.initialSongIndex + var position by rememberPosition( + row = if (initialSongIndex != null) SONG_ROW else 0, + column = initialSongIndex ?: 0, + ) val firstFocusRequester = remember { FocusRequester() } val firstBringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } - val listState = rememberLazyListState() - val itemsBefore = 2 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, + ) val songFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt index b93483c9..96e82032 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -31,6 +31,7 @@ fun MusicExpandableButtons( canDelete: Boolean, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + showShuffle: Boolean = true, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -53,13 +54,15 @@ fun MusicExpandableButtons( .onFocusChanged(buttonOnFocusChanged), ) } - item("shuffle") { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { actions.onClickPlay.invoke(true) }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) + if (showShuffle) { + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } } item("instant_mix") { ExpandableFaButton( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt new file mode 100644 index 00000000..acc802a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.MusicContextActions +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@HiltViewModel(assistedFactory = SongViewModel.Factory::class) +class SongViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): SongViewModel + } + + private val _state = MutableStateFlow(SongState.EMPTY) + val state: StateFlow<SongState> = _state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.song }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val song = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + val imageUrl = imageUrlService.getItemImageUrl(song, ImageType.PRIMARY) + _state.update { + it.copy( + song = song, + imageUrl = imageUrl, + loading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + // The toggle returns the updated user data, so no refetch of the song is needed + val userData = favoriteWatchManager.setFavorite(itemId, favorite) + _state.update { state -> + state.copy( + song = state.song?.let { it.copy(data = it.data.copy(userData = userData)) }, + ) + } + } + } + +data class SongState( + val song: BaseItem?, + val imageUrl: String?, + val loading: LoadingState, + val canDelete: Boolean = false, +) { + companion object { + val EMPTY = SongState(null, null, LoadingState.Pending) + } +} + +/** + * Details page for a song that has no album, and therefore no album page to open. + */ +@Composable +fun SongDetailsPage( + itemId: UUID, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SongViewModel = + hiltViewModel<SongViewModel, SongViewModel.Factory>( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + val moreDialogActions = + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { _, song -> viewModel.play(song) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { id -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(id) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val song = state.song!! + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val buttonsFocusRequester = remember { FocusRequester() } + RequestOrRestoreFocus(buttonsFocusRequester) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 32.dp), + ) { + AlbumHeader( + album = song, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = song.title ?: "", + actions = + remember(song) { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(song, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(song.id) }, + onClickFavorite = { + viewModel.setFavorite(song.id, !song.favorite) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = 0, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { viewModel.deleteItem(song) }, + ) + }, + favorite = song.favorite, + canDelete = state.canDelete, + showShuffle = false, + buttonOnFocusChanged = {}, + modifier = Modifier.focusRequester(buttonsFocusRequester), + ) + } + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { songId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, songId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, songId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 2468cd78..d9d027fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -74,6 +74,7 @@ sealed class Destination( val itemId: UUID, val type: BaseItemKind, val collectionType: CollectionType? = null, + val initialSongId: UUID? = null, ) : Destination() { constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } 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 d7756161..7677c6aa 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 @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage +import com.github.damontecres.wholphin.ui.detail.music.SongDetailsPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage @@ -257,6 +258,7 @@ fun DestinationContent( AlbumDetailsPage( preferences = preferences, itemId = destination.itemId, + initialSongId = destination.initialSongId, modifier = modifier, ) } @@ -270,6 +272,15 @@ fun DestinationContent( ) } + BaseItemKind.AUDIO -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + SongDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt new file mode 100644 index 00000000..4ef8ab8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemDestinationTest { + @Test + fun audioWithAlbumId_navigatesToAlbumWithInitialSongId() { + val songId = UUID.randomUUID() + val albumId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = albumId), false) + + val dest = item.destination() + + assertEquals(Destination.MediaItem::class, dest::class) + val media = dest as Destination.MediaItem + assertEquals(albumId, media.itemId) + assertEquals(BaseItemKind.MUSIC_ALBUM, media.type) + assertEquals(songId, media.initialSongId) + } + + @Test + fun audioWithoutAlbumId_returnsSelfAsMediaItem() { + val songId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = null), false) + + val dest = item.destination() + + assertEquals(songId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.AUDIO, dest.type) + assertNull(dest.initialSongId) + } + + @Test + fun movie_usesStandardDestination() { + val movieId = UUID.randomUUID() + val item = BaseItem(movie(id = movieId), false) + + val dest = item.destination() + + assertEquals(movieId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.MOVIE, dest.type) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index a1bcb2f1..87a44416 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -42,6 +42,24 @@ fun episode( genreItems = genres, ) +/** + * Create a simple [BaseItemDto] song + */ +fun song( + id: UUID = UUID.randomUUID(), + albumId: UUID? = null, + name: String = "Test Song", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.AUDIO, + name = name, + albumId = albumId, + seriesId = null, + genreItems = genres, + ) + /** * Create a simple [BaseItemDto] playlist */ From 24638416630d989388edfdd005f45e679e1cd17f Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 10:21:29 -0400 Subject: [PATCH 184/475] Update libass-android to v0.5.0 (#1763) ## Description Updates `libass-android` to `v0.5.0`. There aren't any changes from `v0.5.0-beta01`, so this is just a formal release. ### Related issues Fixes #1554 Fixes #1561 Fixes #1678 Pr for the beta release: #1752 ### Testing N/A, I tested the beta ## Screenshots N/A ## AI or LLM usage None --- 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 24b7215a..cf4e9903 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -assMedia = "0.5.0-beta01" +assMedia = "0.5.0" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" openapi-generator = "7.23.0" From 8bae9c7f23d121a1888a3061448afdb32920603b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 10:21:49 -0400 Subject: [PATCH 185/475] Fixes for error handling, UI padding, & more (#1759) ## Description Another collection of small individual fixes that don't warrant a full PR: - Better error handling for series and collection detail pages - Better error handling on Discover search tab - Fix issues when changing view options on card grid without reloading the page - Better error handling when changing the refresh rate or resolution - Fix some nav drawer items disappearing briefly when restarting the app - Fix that the discover search row would not clear when the query is cleared And some UI tweaks: - Use consistent padding for the different customize home page sections - Convert the "Continue watching click behavior" setting from a toggle to choice dialog (the options & behaviors have not changed) ### Related issues I think the error handling when changing the refresh rate might help with #1737 ### Testing Emulator mostly ## Screenshots N/A, UI changes are minor ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 16 +++++-- .../wholphin/preferences/AppPreference.kt | 7 +-- .../wholphin/services/NavDrawerService.kt | 3 +- .../ui/components/CollectionFolderView.kt | 1 + .../wholphin/ui/detail/CardGrid.kt | 2 +- .../detail/collection/CollectionViewModel.kt | 46 +++++++++++-------- .../ui/detail/series/SeriesViewModel.kt | 15 +++++- .../ui/discover/DiscoverSearchPage.kt | 22 ++++++--- .../wholphin/ui/main/SearchPage.kt | 43 ++++++++--------- .../main/settings/HomeLibraryRowTypeList.kt | 8 +--- .../ui/main/settings/HomeRowPresets.kt | 5 +- .../ui/main/settings/HomeRowSettings.kt | 4 +- .../ui/main/settings/HomeSettingsAddRow.kt | 8 +--- .../main/settings/HomeSettingsFavoriteList.kt | 8 +--- .../ui/main/settings/HomeSettingsGlobal.kt | 6 +-- .../ui/main/settings/HomeSettingsListItem.kt | 24 ++++++++++ .../ui/main/settings/HomeSettingsRowList.kt | 22 +++++++-- app/src/main/res/values/strings.xml | 5 ++ 18 files changed, 149 insertions(+), 96 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 9c9a3eb9..f1eb3b24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import android.os.Bundle import android.view.KeyEvent import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -391,10 +392,17 @@ class MainActivity : AppCompatActivity() { fun changeDisplayMode(modeId: Int) { lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { - val attrs = window.attributes - if (attrs.preferredDisplayModeId != modeId) { - Timber.d("Switch preferredDisplayModeId to %s", modeId) - window.attributes = attrs.apply { preferredDisplayModeId = modeId } + try { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } catch (ex: Exception) { + Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId) + Toast + .makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT) + .show() } } } 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 43615ca3..a3f5b660 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 @@ -218,15 +218,16 @@ sealed interface AppPreference<Pref, T> { ) val HomeClickToPlay = - AppSwitchPreference<AppPreferences>( + AppChoicePreference<AppPreferences, Boolean>( title = R.string.continue_watching_click_behavior, defaultValue = false, getter = { it.homePagePreferences.clickToPlay }, setter = { prefs, value -> prefs.updateHomePagePreferences { clickToPlay = value } }, - summaryOn = R.string.continue_watching_click_summary_on, - summaryOff = R.string.continue_watching_click_summary_off, + displayValues = R.array.home_click_to_play_options, + indexToValue = { it != 0 }, + valueToIndex = { if (it) 1 else 0 }, ) val PlayThemeMusic = 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 a70dff71..a4716ac9 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 @@ -70,10 +70,11 @@ class NavDrawerService userDto?.id, discoverActive, ) - _state.update { NavDrawerItemState() } try { if (user != null && userDto != null && user.id == userDto.id) { updateNavDrawer(user, userDto, discoverActive) + } else { + _state.update { NavDrawerItemState() } } } catch (ex: CancellationException) { throw ex diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 56eb4bd2..4f3154f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -253,6 +253,7 @@ class CollectionFolderViewModel } fun saveViewOptions(viewOptions: ViewOptions) { + position = 0 _state.update { it.copy(viewOptions = viewOptions) } viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) 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 7d34d0b1..00713ddb 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 @@ -345,7 +345,7 @@ fun <T : CardGridItem> CardGrid( items(pager.size) { index -> val item = pager[index] val details = - remember(index, item, cardWidthPx) { + remember(index, item, cardWidthPx, columns) { val mod = if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index cab18bfa..bf100cff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -46,6 +46,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -136,26 +137,33 @@ class CollectionViewModel } } viewModelScope.launchDefault { - val collection = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } - backdropService.submit(collection) - val logoImageUrl = - if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { - imageUrlService.getItemImageUrl(collection, ImageType.LOGO) - } else { - null + try { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + backdropService.submit(collection) + val logoImageUrl = + if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { + imageUrlService.getItemImageUrl(collection, ImageType.LOGO) + } else { + null + } + _state.update { + it.copy( + collection = collection, + logoImageUrl = logoImageUrl, + ) } - _state.update { - it.copy( - collection = collection, - logoImageUrl = logoImageUrl, - ) + listenForStateUpdates() + themeSongPlayer.playThemeFor(itemId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting collection %s", itemId) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } } - listenForStateUpdates() - themeSongPlayer.playThemeFor(itemId) } } @@ -179,6 +187,8 @@ class CollectionViewModel .collectLatest { (sort, filter, separateTypes) -> try { updateData(sort, filter, separateTypes) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e( ex, 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 202c6a06..05f82a83 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 @@ -50,6 +50,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job @@ -158,8 +159,18 @@ class SeriesViewModel } else { CompletableDeferred(value = EpisodeList.Loading) } - val seasons = seasonsDeferred.await() - val episodes = episodeListDeferred.await() + val (seasons, episodes) = + try { + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + seasons to episodes + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching seasons/episodes for series %s", seriesId) + _state.update { it.copy(series = DataLoadingState.Error(ex)) } + return@launchIO + } Timber.v("Done") if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index 4179f1d9..135b9324 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -88,13 +89,20 @@ class DiscoverSearchViewModel } Timber.v("Starting seerr search") seerrResults.value = SearchResult.Searching - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - Timber.v("Seerr search complete: %s results", results.size) - seerrResults.value = SearchResult.SuccessSeerr(results) + try { + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + Timber.v("Seerr search complete: %s results", results.size) + seerrResults.value = SearchResult.SuccessSeerr(results) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error during seerr search") + seerrResults.value = SearchResult.Error(ex) + } } } } 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 135c3b74..81770c9d 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 @@ -169,21 +169,10 @@ class SearchViewModel currentQuery = query combinedMode = combined if (query.isNotNullOrBlank()) { + _state.update { SearchState.searchingState } if (combined) { - _state.update { it.copy(combinedResults = SearchResult.Searching) } searchCombined(query) } else { - _state.update { - it.copy( - movies = SearchResult.Searching, - series = SearchResult.Searching, - episodes = SearchResult.Searching, - collections = SearchResult.Searching, - albums = SearchResult.Searching, - artists = SearchResult.Searching, - songs = SearchResult.Searching, - ) - } searchInternal( query, BaseItemKind.MOVIE, @@ -219,18 +208,7 @@ class SearchViewModel } searchSeerr(query) } else { - _state.update { - it.copy( - combinedResults = SearchResult.NoQuery, - movies = SearchResult.NoQuery, - series = SearchResult.NoQuery, - episodes = SearchResult.NoQuery, - collections = SearchResult.NoQuery, - albums = SearchResult.NoQuery, - artists = SearchResult.NoQuery, - songs = SearchResult.NoQuery, - ) - } + _state.update { SearchState() } } } @@ -479,7 +457,22 @@ data class SearchState( val songs: SearchResult = SearchResult.NoQuery, val seerrResults: SearchResult = SearchResult.NoQuery, val combinedResults: SearchResult = SearchResult.NoQuery, -) +) { + companion object { + val searchingState = + SearchState( + movies = SearchResult.Searching, + series = SearchResult.Searching, + episodes = SearchResult.Searching, + collections = SearchResult.Searching, + albums = SearchResult.Searching, + artists = SearchResult.Searching, + songs = SearchResult.Searching, + seerrResults = SearchResult.Searching, + combinedResults = SearchResult.Searching, + ) + } +} private const val SEARCH_ROW = 0 private const val TAB_ROW = SEARCH_ROW + 1 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 1e5fe4ea..c72d69a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -1,12 +1,9 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,7 +13,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -36,9 +32,7 @@ fun HomeLibraryRowTypeList( LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.add_row_for, library.name)) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() 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 5714723e..383ed075 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 @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -249,9 +247,8 @@ fun HomeRowPresetsContent( LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.display_presets)) - LazyColumn( + HomeSettingsLazyColumn( contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index e9f7f6e2..48901b0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -69,11 +69,11 @@ fun HomeRowSettings( LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(title) - LazyColumn { + HomeSettingsLazyColumn { preferenceOptions.forEachIndexed { groupIndex, prefGroup -> if (preferenceOptions.size > 1) { item { - TitleText(stringResource(prefGroup.title)) + SubTitleText(stringResource(prefGroup.title)) } } itemsIndexed(prefGroup.preferences) { index, pref -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index 97249aa4..439b2505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -1,11 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable @@ -15,7 +12,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse @@ -31,9 +27,7 @@ fun HomeSettingsAddRow( // LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.add_row)) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 80b2f6f9..72208d0d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -1,10 +1,7 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,7 +11,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -31,9 +27,7 @@ fun HomeSettingsFavoriteList( TitleText( stringResource(R.string.add_row_for, stringResource(R.string.favorites)), ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index cc463b35..894980c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -18,7 +16,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -51,8 +48,7 @@ fun HomeSettingsGlobal( modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt index 4f265dbb..ec35cdb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -1,7 +1,13 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Modifier @@ -57,3 +63,21 @@ fun HomeSettingsListItem( glow = glow, interactionSource = interactionSource, ) + +@Composable +@NonRestartableComposable +fun HomeSettingsLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(8.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp), + content: LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + content = content, + ) +} 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 438e671f..dc527ed4 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -76,9 +75,8 @@ fun HomeSettingsRowList( } Column(modifier = modifier) { TitleText(stringResource(R.string.customize_home)) - LazyColumn( + HomeSettingsLazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() @@ -255,6 +253,24 @@ fun HomeRowConfigContent( fun TitleText( title: String, modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} + +@Composable +@NonRestartableComposable +fun SubTitleText( + title: String, + modifier: Modifier = Modifier, ) { Text( text = title, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d909430..bf37ea15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -888,6 +888,11 @@ <item>@string/subtitle_mode_none</item> </string-array> + <string-array name="home_click_to_play_options"> + <item>@string/continue_watching_click_summary_off</item> + <item>@string/continue_watching_click_summary_on</item> + </string-array> + <string name="preferred_subtitle_language">Preferred subtitle language</string> <string name="preferred_audio_language">Preferred audio language</string> <string name="override_user_profile_settings">More user profile settings</string> From 124b5872851d7ed25f37923901931e99c55bd348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Wed, 8 Jul 2026 16:49:27 +0000 Subject: [PATCH 186/475] Translated using Weblate (Estonian) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index cfbf2725..fa3fe3b1 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -660,4 +660,13 @@ <string name="confirm_enable_experimental_title">Kas võtad katselised seadistused kasutusele?</string> <string name="confirm_enable_experimental_body">Katselised seadistused ei pruugi olla päris töökindlad!\n\n Lisaks võib arendaja neid omal äranägemisel muuta või sootuks eemaldada.</string> <string name="video_tunneling">Video tunneldus</string> + <string name="duration_hours_minutes_seconds">%1$d t %2$d min %3$d sek</string> + <string name="duration_hours_minutes">%1$d t %2$d min</string> + <string name="duration_minutes_seconds">%1$d min %2$d sek</string> + <string name="duration_hours">%1$d t</string> + <string name="duration_minutes">%1$d min</string> + <string name="duration_seconds">%1$d sek</string> + <string name="time_left">jäänud %1$s</string> + <string name="blocklisted">Keeluloendis</string> + <string name="unavailable">Pole saadaval</string> </resources> From 2de2746f77b3c0a3cfd36e94f082bda04eaf2497 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 7 Jul 2026 23:40:20 +0000 Subject: [PATCH 187/475] Translated using Weblate (Slovak) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 1fa31795..9675c94e 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -702,4 +702,13 @@ <string name="confirm_enable_experimental_title">Povoliť experimentálne nastavenia?</string> <string name="confirm_enable_experimental_body">Experimentálne nastavenia môžu byť nestabilné!\n\n Môžu byť zmenené alebo odstránené v budúcich verziách.</string> <string name="video_tunneling">Video tunneling</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">zostáva %1$s</string> + <string name="blocklisted">Blokované</string> + <string name="unavailable">Nedostupné</string> </resources> From 613032ebfecf6b40320b08c7a487f82e1f01e65f Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 8 Jul 2026 11:41:42 +0000 Subject: [PATCH 188/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2dbaa495..3814767f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -646,4 +646,6 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">還剩 %1$s</string> + <string name="blocklisted">已列入黑名單</string> + <string name="unavailable">無法請求</string> </resources> From 781efcbb8d2ca9db7a6930a84c68d9c6f4341bf3 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Wed, 8 Jul 2026 05:55:57 +0000 Subject: [PATCH 189/475] Translated using Weblate (Polish) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 30bee7af..b0c8c84e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -709,4 +709,6 @@ <string name="duration_hours_minutes_seconds">%1$d godz. %2$d min. %3$d sek.</string> <string name="duration_hours_minutes">%1$d godz. %2$d min.</string> <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> + <string name="blocklisted">Na czarnej liście</string> + <string name="unavailable">Niedostępne</string> </resources> From 35a0804e7316ba39b10e1fc5dae6db1abc85f63a Mon Sep 17 00:00:00 2001 From: butterflyoffire <butterflyoffire@noreply.codeberg.org> Date: Wed, 8 Jul 2026 07:42:43 +0000 Subject: [PATCH 190/475] Translated using Weblate (Kabyle) Currently translated at 9.6% (59 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/kab/ --- app/src/main/res/values-kab/strings.xml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 43c74c5a..7b8e9032 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -6,8 +6,8 @@ <string name="bitrate">Aktum</string> <string name="cancel">Semmet</string> <string name="chapters">Ixfawen</string> - <string name="collection">Tagrumma</string> - <string name="collections">Tigrummiwin</string> + <string name="collection">Talkensit</string> + <string name="collections">Tilkensa</string> <string name="confirm">Serggeg</string> <string name="default_track">Amezwer</string> <string name="delete">Kkes</string> @@ -43,4 +43,14 @@ <string name="hdr10_plus">HDR10+</string> <string name="hlg">HLG</string> <string name="choose_stream">Fren %1$s</string> + <string name="more">Ugar</string> + <string name="name">Isem</string> + <string name="none">Ula yiwen</string> + <string name="subtitle">Aduzwel</string> + <string name="subtitles">Iduzwilen</string> + <string name="suggestions">Isumar</string> + <string name="settings">Iɣewwaren</string> + <string name="reset">Wennez</string> + <string name="remove">Kkes</string> + <string name="subtitle_margin">Tama</string> </resources> From 1459ac7b7739baa6456c42356fec32c7bfc3e735 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 7 Jul 2026 18:58:06 +0000 Subject: [PATCH 191/475] Translated using Weblate (Turkish) Currently translated at 100.0% (610 of 610 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 1ef29efc..3f5669bb 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -667,4 +667,6 @@ <string name="duration_minutes">%1$ddk</string> <string name="duration_seconds">%1$dsn</string> <string name="time_left">%1$s kaldı</string> + <string name="blocklisted">Engellenenler</string> + <string name="unavailable">Mevcut Değil</string> </resources> From 1f214ebf87d502bdcf575993fea5b4cac7417481 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Thu, 9 Jul 2026 09:37:17 +0000 Subject: [PATCH 192/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (610 of 610 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 3c1a4bd8..08f9ad5d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -646,4 +646,6 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">还剩%1$s</string> + <string name="blocklisted">被列入黑名单</string> + <string name="unavailable">不可用</string> </resources> From 7da1b0b51086f091996651f2dc13bb30cf0b25ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Fri, 10 Jul 2026 12:27:04 +0000 Subject: [PATCH 193/475] Translated using Weblate (Estonian) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index fa3fe3b1..7f957670 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -669,4 +669,6 @@ <string name="time_left">jäänud %1$s</string> <string name="blocklisted">Keeluloendis</string> <string name="unavailable">Pole saadaval</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> + <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> </resources> From 4739ab73eb80a528a217470103db182d48c994c1 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Fri, 10 Jul 2026 07:19:15 +0000 Subject: [PATCH 194/475] Translated using Weblate (Slovak) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 9675c94e..061e5845 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -711,4 +711,6 @@ <string name="time_left">zostáva %1$s</string> <string name="blocklisted">Blokované</string> <string name="unavailable">Nedostupné</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> </resources> From 15f8398610eedd71a3e58879bb1e54ffda6a9d29 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 10 Jul 2026 04:28:18 +0000 Subject: [PATCH 195/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3814767f..389487de 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -646,6 +646,8 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">還剩 %1$s</string> - <string name="blocklisted">已列入黑名單</string> + <string name="blocklisted">被列入黑名單</string> <string name="unavailable">無法請求</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> </resources> From 4c557f3ced7a9644ed08c27a4ef566cb62453889 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Fri, 10 Jul 2026 16:27:42 +0000 Subject: [PATCH 196/475] Translated using Weblate (Italian) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index baeb5346..1bc2a845 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -681,4 +681,15 @@ <string name="confirm_enable_experimental_title">Abilita impostazioni sperimentali?</string> <string name="confirm_enable_experimental_body">Le impostazioni sperimentali possono essere instabili!\nPossono essere rimosse o cambiate in qualsiasi aggiornamento.</string> <string name="video_tunneling">Tunneling video</string> + <string name="duration_hours_minutes_seconds">%1$do %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$do %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$do</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s rimanenti</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodifica l’audio non stereo in AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> + <string name="blocklisted">Bloccati</string> + <string name="unavailable">Non disponibile</string> </resources> From b841f5d021030c399913c895b3c251f9ff18713f Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Fri, 10 Jul 2026 14:43:09 +0000 Subject: [PATCH 197/475] Translated using Weblate (Polish) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b0c8c84e..aeeeff4d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -711,4 +711,6 @@ <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> <string name="blocklisted">Na czarnej liście</string> <string name="unavailable">Niedostępne</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> </resources> From 78291b6dd9738facae7530a1e7d720247d2c7e23 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 10 Jul 2026 01:15:56 +0000 Subject: [PATCH 198/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (612 of 612 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 08f9ad5d..034e14bb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -648,4 +648,6 @@ <string name="time_left">还剩%1$s</string> <string name="blocklisted">被列入黑名单</string> <string name="unavailable">不可用</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> </resources> From dc1e74fc840fd8edce92d91d984b27fe29650b09 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sat, 11 Jul 2026 04:16:40 +0000 Subject: [PATCH 199/475] Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bb7265dd..481321de 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -667,4 +667,8 @@ <string name="duration_seconds">%1$ds</string> <string name="time_left">%1$s verbleibend</string> <string name="video_tunneling">Video Tunneling</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound Audio nutzen</string> + <string name="blocklisted">Auf Blockliste hinzugefügt</string> + <string name="unavailable">Nicht verfügbar</string> </resources> From 96dceefcd82a27ddd95ab1513a47058f451fdf43 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Fri, 10 Jul 2026 22:10:36 +0000 Subject: [PATCH 200/475] Translated using Weblate (Spanish) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9b5616ca..e0619ffc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -669,4 +669,27 @@ <item quantity="other">Personas</item> </plurals> <string name="trailers_title">Tráileres</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar audio no estéreo a AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para audio multicanal</string> + <string name="genre_movies">%1$s Películas</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Configuración de calidad</string> + <string name="is_default">Predeterminado</string> + <string name="root_folder">Carpeta principal</string> + <string name="popularity">Popularidad</string> + <string name="production_status">Estado</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Ajustes experimentales</string> + <string name="confirm_enable_experimental_title">¿Habilitar ajustes experimentales?</string> + <string name="confirm_enable_experimental_body">¡Los ajustes experimentales pueden ser inestables!\n\n Pueden ser cambiados o eliminados en cualquier lanzamiento.</string> + <string name="video_tunneling">Tunelización de video</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">No disponible</string> </resources> From 7a44d2c553ff3c15c29a461d6c511d8c26b6d837 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Sat, 11 Jul 2026 16:37:58 +0000 Subject: [PATCH 201/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 2a541e9c..dafdcbfc 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -669,4 +669,27 @@ <string name="samples_title">דגימות</string> <string name="featurettes_title">קטעי בונוס</string> <string name="shorts_title">קטעים קצרים</string> + <string name="duration_hours_minutes_seconds">%1$d שע\' %2$d דק\' %3$d שנ\'</string> + <string name="duration_hours_minutes">%1$d שע\' %2$d דק\'</string> + <string name="duration_minutes_seconds">%1$d דק\' %2$d שנ\'</string> + <string name="duration_hours">%1$d שע\'</string> + <string name="duration_minutes">%1$d דק\'</string> + <string name="duration_seconds">%1$d שנ\'</string> + <string name="time_left">%1$s נותרו</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[לקודד מחדש שמע שאינו סטריאו ל־AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">להשתמש ב־AC3 עבור שמע סראונד</string> + <string name="genre_movies">סרטי %1$s</string> + <string name="genre_tv_shows">סדרות %1$s</string> + <string name="quality_profile">פרופיל איכות</string> + <string name="is_default">ברירת מחדל</string> + <string name="root_folder">תיקייה ראשית</string> + <string name="popularity">פופולריות</string> + <string name="production_status">מצב הפקה</string> + <string name="different_for_hdr">השתמש בסגנון שונה עבור HDR</string> + <string name="experimental_settings">הגדרות ניסיוניות</string> + <string name="confirm_enable_experimental_title">האם לאפשר הגדרות ניסיוניות?</string> + <string name="confirm_enable_experimental_body">ההגדרות הניסיוניות עשויות להיות לא יציבות!\n\nהן עשויות להימחק או להשתנות בכל גרסה.</string> + <string name="unavailable">לא זמין</string> + <string name="blocklisted">ברשימה חסומה</string> + <string name="video_tunneling">מנהור וידאו</string> </resources> From 171371fb546c8b53a8284c5a75c13bbd74f6351f Mon Sep 17 00:00:00 2001 From: streamyfinchris <streamyfinchris@noreply.codeberg.org> Date: Sun, 12 Jul 2026 00:27:11 +0000 Subject: [PATCH 202/475] Translated using Weblate (Swedish) Currently translated at 86.6% (530 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a33ef920..4705ec8a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -550,4 +550,58 @@ <string name="cinema_mode">Bioläge</string> <string name="suggestions_for">Förslag för %1$s</string> <string name="system_default">Systemstandard</string> + <string name="playback_overrides">Överskrid uppspelning</string> + <string name="processing">Processering</string> + <string name="voice_error_audio">Fel på ljudinspelning</string> + <string name="voice_error_client">Fel på röstigenkänning</string> + <string name="voice_error_permissions">Mikrofonbehörighet krävs</string> + <string name="media_information">Information om Media</string> + <string name="other_extras_title">Annat</string> + <string name="behind_the_scenes_title">Bakom kulisserna</string> + <string name="theme_songs_title">Temasånger</string> + <string name="theme_videos_title">Temavideor</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Borttagna scener</string> + <string name="interviews_title">Intervjuer</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Extramaterial</string> + <string name="shorts_title">Korta klipp</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">FIlmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV Serie</item> + <item quantity="other">TV Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="incorrect">Felaktigt</string> + <string name="title">Titel</string> + <string name="trending">Trendande</string> + <string name="stop_slideshow">Stoppa bildspelet</string> + <string name="slideshow_at_beginning">I början</string> + <string name="no_more_images">Inga fler bilder</string> + <string name="go_to_artist">Gå till artist</string> + <string name="load_from_web_client">Ladda från web client</string> + <string name="for_episodes">För episoder</string> + <string name="increase_all_cards_size">Öka storleken för alla kort</string> + <string name="decrease_all_cards_size">Minska storleken för alla kort</string> + <string name="animate">Animera</string> + <string name="max_age_rating">Maximal åldersgräns</string> + <string name="include_types_summary">Vilka typer av objekt ska bilder visas för</string> + <string name="song_has_lyrics">Låten har text</string> + <string name="show_album_cover">Visa omslag till album</string> + <string name="updated_toast">Wholphin har uppdaterats till %s</string> + <string name="last_played">Senast visat</string> + <string name="root_folder">Huvudkatalog</string> + <string name="popularity">Populäritet</string> + <string name="production_status">Produktionsstatus</string> + <string name="experimental_settings">Experimentella inställningar</string> + <string name="confirm_enable_experimental_title">Slå på experimentella inställningar?</string> + <string name="video_tunneling">Videotunnling</string> + <string name="blocklisted">Svartlistad</string> + <string name="unavailable">Otillgänglig</string> </resources> From 7c22ce7ce7c970070fdb5bd8464d00decb23dbf5 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sun, 12 Jul 2026 01:34:45 +0000 Subject: [PATCH 203/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (612 of 612 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, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 389487de..b5629780 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -639,9 +639,9 @@ <string name="experimental_settings">實驗性設定</string> <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> - <string name="duration_hours_minutes_seconds">%1$d小時 %2$d分 %3$d秒</string> - <string name="duration_hours_minutes">%1$d小時 %2$d分</string> - <string name="duration_minutes_seconds">%1$d分 %2$d秒</string> + <string name="duration_hours_minutes_seconds">%1$d小時%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小時%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> <string name="duration_hours">%1$d小時</string> <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> From ea96feca74b0225d85ecc66b3b49c6acf6a49353 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sun, 12 Jul 2026 22:00:30 +0000 Subject: [PATCH 204/475] Translated using Weblate (Turkish) Currently translated at 100.0% (612 of 612 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 3f5669bb..256fb4c0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -669,4 +669,6 @@ <string name="time_left">%1$s kaldı</string> <string name="blocklisted">Engellenenler</string> <string name="unavailable">Mevcut Değil</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> + <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> </resources> From 15592f87aa023b096ef04919b0ed8f0690817217 Mon Sep 17 00:00:00 2001 From: viretius <viretius@noreply.codeberg.org> Date: Mon, 13 Jul 2026 05:33:05 +0000 Subject: [PATCH 205/475] Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 481321de..ccc1a809 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -470,7 +470,7 @@ <string name="for_all_ages">Ohne Altersbeschränkung</string> <string name="up_to_age">Bis %s Jahre</string> <string name="screensaver">Bildschirmschoner</string> - <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> <string name="start_screensaver">Bildschirmschoner starten</string> <string name="duration">Dauer</string> <string name="photos">Fotos</string> @@ -668,7 +668,7 @@ <string name="time_left">%1$s verbleibend</string> <string name="video_tunneling">Video Tunneling</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> - <string name="prefer_ac3_for_surround">AC3 für Surround Sound Audio nutzen</string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> <string name="blocklisted">Auf Blockliste hinzugefügt</string> <string name="unavailable">Nicht verfügbar</string> </resources> From 92a17f2b842199987da26e3aeb72b23060c61860 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Tue, 14 Jul 2026 01:43:23 +0000 Subject: [PATCH 206/475] Translated using Weblate (Indonesian) Currently translated at 99.8% (611 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2bdc1d68..05614872 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -639,4 +639,14 @@ <plurals name="people"> <item quantity="other">Orang</item> </plurals> + <string name="prefer_ac3_for_surround">Gunakan AC3 untuk surround sound</string> + <string name="duration_hours_minutes_seconds">%1$dj %2$dm %3$dd</string> + <string name="duration_hours_minutes">%1$dj %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$dd</string> + <string name="duration_hours">%1$dj</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$dd</string> + <string name="time_left">Sisa %1$s</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> + <string name="unavailable">Tidak tersedia</string> </resources> From 0cacb4e4edaa88138103dcfee751ced50dc9ad34 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 13 Jul 2026 10:28:14 +0000 Subject: [PATCH 207/475] Translated using Weblate (French) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 421c1b0e..4e8a0782 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -681,4 +681,15 @@ <string name="confirm_enable_experimental_title">Activer les paramètres expérimentaux ?</string> <string name="confirm_enable_experimental_body">Les paramètres expérimentaux peuvent être instables !\n\nIls sont susceptibles d\'être supprimés ou modifiés dans n\'importe quelle version.</string> <string name="video_tunneling">Tunneling vidéo</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restant</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcoder l\'audio non stéréo vers AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> + <string name="blocklisted">sur liste noire</string> + <string name="unavailable">Indisponible</string> </resources> From c906431b0db2d5d6de627b596f09a5a68c9cc646 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 14 Jul 2026 20:31:28 +0000 Subject: [PATCH 208/475] Translated using Weblate (Spanish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e0619ffc..faff78c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -692,4 +692,17 @@ <string name="video_tunneling">Tunelización de video</string> <string name="blocklisted">Bloqueado</string> <string name="unavailable">No disponible</string> + <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> + <string name="subtitle_mode">Modo de subtítulos</string> + <string name="use_user_profile">Usar tu configuración de usuario</string> + <string name="subtitle_mode_default">Predeterminado</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Solo forzados</string> + <string name="subtitle_mode_always">Siempre</string> + <string name="subtitle_mode_none">Ninguno</string> + <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> + <string name="preferred_audio_language">Idioma de audio preferido</string> + <string name="override_user_profile_settings">Más ajustes de usuario</string> + <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> + <string name="any_language">Cualquier idioma</string> </resources> From 49c14aaa6cac1bb85fbbd3f2033b43d91a432b16 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Wed, 15 Jul 2026 14:43:28 +0000 Subject: [PATCH 209/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4c06ad97..bfc97e08 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -669,4 +669,40 @@ <item quantity="other">Pessoas</item> </plurals> <string name="featurettes_title">Videos promocionais</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Definições do perfil do utilizador a partir do servidor</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilize o AC3 para áudio surround</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries TV</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Passa raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Estado da produção</string> + <string name="different_for_hdr">Utilizar um estilo diferente para HDR</string> + <string name="experimental_settings">Definições experimentais</string> + <string name="confirm_enable_experimental_title">Ativar definições experimentais?</string> + <string name="confirm_enable_experimental_body">As definições experimentais podem ser instáveis!\n\nPodem ser removidas ou alteradas em qualquer atualização.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="blocklisted">Lista de bloqueio</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legendas</string> + <string name="use_user_profile">Utilize as definições do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Apenas forçado</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhum</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma de áudio preferido</string> + <string name="override_user_profile_settings">Mais definições do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> + <string name="any_language">Qualquer idioma</string> </resources> From a71b40feab642dbaba26af4accf15cc54b29a734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Tue, 14 Jul 2026 22:27:47 +0000 Subject: [PATCH 210/475] Translated using Weblate (Estonian) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7f957670..6aa53fea 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -671,4 +671,17 @@ <string name="unavailable">Pole saadaval</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> + <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> + <string name="subtitle_mode">Subtiitrite režiim</string> + <string name="use_user_profile">Kasuta oma profiili seadistust</string> + <string name="subtitle_mode_default">Vaikimisi</string> + <string name="subtitle_mode_smart">Nutikas</string> + <string name="subtitle_mode_only_forced">Ainult sundkorras</string> + <string name="subtitle_mode_always">Alati</string> + <string name="subtitle_mode_none">Määramata</string> + <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> + <string name="preferred_audio_language">Heliriba eelistatud keel</string> + <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> + <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> + <string name="any_language">Iga keel</string> </resources> From f0ab44be88d3c33b0f6cf51d5788dffc5d52f805 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 14 Jul 2026 21:04:08 +0000 Subject: [PATCH 211/475] Translated using Weblate (Slovak) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 061e5845..7ae899c2 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -713,4 +713,17 @@ <string name="unavailable">Nedostupné</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> + <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> + <string name="subtitle_mode">Režim titulkov</string> + <string name="use_user_profile">Použiť nastavenie profilu</string> + <string name="subtitle_mode_default">Predvolené</string> + <string name="subtitle_mode_smart">Inteligentné</string> + <string name="subtitle_mode_only_forced">Len vynútené</string> + <string name="subtitle_mode_always">Vždy</string> + <string name="subtitle_mode_none">Žiadne</string> + <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> + <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> + <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> + <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> + <string name="any_language">Akýkoľvek jazyk</string> </resources> From 527c9e37054389779adfdf8d5f990db938e280ac Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 15 Jul 2026 02:58:56 +0000 Subject: [PATCH 212/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 034e14bb..6bef3fb1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -650,4 +650,17 @@ <string name="unavailable">不可用</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> + <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用您的配置设置</string> + <string name="subtitle_mode_default">默认</string> + <string name="subtitle_mode_smart">智能</string> + <string name="subtitle_mode_only_forced">仅强制</string> + <string name="subtitle_mode_always">始终</string> + <string name="subtitle_mode_none">无</string> + <string name="preferred_subtitle_language">首选字幕语言</string> + <string name="preferred_audio_language">首选音频语言</string> + <string name="override_user_profile_settings">更多用户配置设置</string> + <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> + <string name="any_language">任何语言</string> </resources> From d301cde016b259bf5f251066a43240ffb8949617 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Wed, 15 Jul 2026 11:14:27 +0000 Subject: [PATCH 213/475] Translated using Weblate (Turkish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 256fb4c0..05f0679a 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -671,4 +671,17 @@ <string name="unavailable">Mevcut Değil</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> + <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> + <string name="subtitle_mode">Altyazı Modu</string> + <string name="use_user_profile">Profil ayarlarını kullan</string> + <string name="subtitle_mode_default">Varsayılan</string> + <string name="subtitle_mode_smart">Akıllı</string> + <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> + <string name="subtitle_mode_always">Her zaman</string> + <string name="subtitle_mode_none">Hiçbiri</string> + <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> + <string name="preferred_audio_language">Tercih edilen ses dili</string> + <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> + <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> + <string name="any_language">Herhangi bir dil</string> </resources> From 07592b15b42c624e1e85291fd949d79faf5d0d95 Mon Sep 17 00:00:00 2001 From: damontecres <damontecres@noreply.codeberg.org> Date: Wed, 15 Jul 2026 15:03:03 +0000 Subject: [PATCH 214/475] Added translation using Weblate (English (United Kingdom)) --- app/src/main/res/values-en-rGB/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-en-rGB/strings.xml diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> \ No newline at end of file From 920af648be58c3d781f8c0196196ab39de8018fc Mon Sep 17 00:00:00 2001 From: unikitty <unikitty@noreply.codeberg.org> Date: Wed, 15 Jul 2026 16:01:08 +0000 Subject: [PATCH 215/475] Translated using Weblate (English (United Kingdom)) Currently translated at 6.7% (42 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/en_GB/ --- app/src/main/res/values-en-rGB/strings.xml | 38 +++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 55344e51..417bac6b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -1,3 +1,39 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file + <string name="add_favorite">Favourite</string> + <string name="favorites">Favourites</string> + <string name="license_info">Licence information</string> + <string name="record_program">Record Programme</string> + <string name="remove_favorite">Unfavourite</string> + <string name="voice_error_no_match">No speech recognised</string> + <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> + <string name="italic_font">Italicise font</string> + <string name="favorite_items_title">Favourite %s</string> + <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> + <string name="skip_commercials_behavior">Skip commercials behaviour</string> + <string name="skip_intro_behavior">Skip intro behaviour</string> + <string name="skip_outro_behavior">Skip outro behaviour</string> + <string name="skip_previews_behavior">Skip previews behaviour</string> + <string name="skip_recap_behavior">Skip recap behaviour</string> + <string name="font_color">Font colour</string> + <string name="edge_color">Edge colour</string> + <string name="background_color">Background colour</string> + <string name="press_enter_to_confirm">Press centre to confirm</string> + <string name="color_space">Colour space</string> + <string name="color_transfer">Colour transfer</string> + <string name="color_primaries">Colour primaries</string> + <string name="favorite_channels_at_beginning">Show favourite channels first</string> + <string name="color_code_programs">Colour-code programmes</string> + <string name="quick_connect_summary">Authorise another device to log in to your account</string> + <string name="quick_connect_success">Device authorised successfully</string> + <string name="contrast">Contrast</string> + <string name="customize_home">Customise home page</string> + <string name="light_gray">Light grey</string> + <string name="dark_gray">Dark grey</string> + <string name="backdrop_style_dynamic">Image with dynamic colour</string> + <string name="show_visualizer">Show visualiser</string> + <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> + <string name="skip_behavior">Skip behaviour</string> + <string name="display_toggles_summary">Customise which to display</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> +</resources> From ab993b0d96264fed276abba72335801e6b833a00 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Thu, 16 Jul 2026 02:32:14 +0000 Subject: [PATCH 216/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (625 of 625 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 b5629780..01838686 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -650,4 +650,17 @@ <string name="unavailable">無法請求</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> + <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用伺服器設定</string> + <string name="subtitle_mode_default">預設</string> + <string name="subtitle_mode_smart">智慧模式</string> + <string name="subtitle_mode_only_forced">僅強制字幕</string> + <string name="subtitle_mode_always">總是顯示</string> + <string name="subtitle_mode_none">不顯示</string> + <string name="preferred_subtitle_language">偏好字幕語言</string> + <string name="preferred_audio_language">偏好音軌語言</string> + <string name="override_user_profile_settings">更多個人化設定</string> + <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> + <string name="any_language">任何語言</string> </resources> From 7ba5c83f33ad378a2f943f726a72452b6742b118 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Thu, 16 Jul 2026 16:37:43 +0000 Subject: [PATCH 217/475] Translated using Weblate (Slovak) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 7ae899c2..6e2e3262 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -318,7 +318,7 @@ </plurals> <string name="auto_play_next_delay">Oneskorenie pred prehrávaním nasledujúceho</string> <string name="auto_play_next">Automatické prehrávanie nasledujúceho</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Spojiť sekcie Pokračovať v pozeraní a Nasleduje]]></string> <string name="playback_overrides">Prepísanie prehrávania</string> <string name="rewatch_next_up">Povoliť opätovné prehrávanie v nasledujúcich</string> <string name="skip_back_on_resume_preference">Pri obnovení prehrávania preskočiť späť</string> From 75f1f0634571fca323ada4a6708bb8a8d5a19a38 Mon Sep 17 00:00:00 2001 From: outopik <outopik@noreply.codeberg.org> Date: Sat, 18 Jul 2026 20:39:43 +0000 Subject: [PATCH 218/475] Translated using Weblate (French) Currently translated at 98.5% (616 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4e8a0782..d0355a1f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -692,4 +692,8 @@ <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> <string name="blocklisted">sur liste noire</string> <string name="unavailable">Indisponible</string> + <string name="subtitle_mode_only_forced">forcés uniquement</string> + <string name="subtitle_mode_none">Aucun</string> + <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> + <string name="preferred_audio_language">Langue audio préférée</string> </resources> From f7348a6c7a9a8791122dbb37a176db640bcb4f57 Mon Sep 17 00:00:00 2001 From: Codeberg Translate <translate@codeberg.org> Date: Sat, 18 Jul 2026 21:50:44 +0000 Subject: [PATCH 219/475] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-pl/strings.xml | 3 --- app/src/main/res/values-ru/strings.xml | 3 --- app/src/main/res/values-uk/strings.xml | 3 --- 3 files changed, 9 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index aeeeff4d..7854f3f5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -263,9 +263,6 @@ <string name="people_title">Obsada</string> <string name="none">Brak</string> <string name="unknown">Nieznany</string> - <plurals name="years_old"> - <item quantity="other">%1$d lat temu</item> - </plurals> <string name="success">Powodzenie</string> <string name="official_rating">Ocena rodzicielska</string> <string name="extras_title">Dodatki</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1c93f25..e83ae0a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,9 +111,6 @@ <string name="video_scale">Масштаб видео</string> <string name="video">Видео</string> <string name="videos">Видео</string> - <plurals name="years_old"> - <item quantity="other">%1$d возраст</item> - </plurals> <string name="play_with_transcoding">Воспроизвести с перекодированием</string> <string name="show_clock">Показывать часы</string> <string name="create_playlist">Создать новый плейлист</string> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ccbb47a4..6b518809 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -177,9 +177,6 @@ <string name="video">Відео</string> <string name="videos">Відоси</string> <string name="watch_live">Дивитись онлайн</string> - <plurals name="years_old"> - <item quantity="other">%1$d років</item> - </plurals> <string name="play_with_transcoding">Відтворити з перекодуванням</string> <string name="media_information">Інформація</string> <string name="show_clock">Показати годиник</string> From 8abbbabcd5c4ac5ac712875b6a1805e8af621df4 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sun, 19 Jul 2026 09:35:58 +0000 Subject: [PATCH 220/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 01838686..c7797553 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -583,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">轉碼</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">合併搜尋結果</string> <string name="combined_search_results_on">在單一列表中顯示所有結果</string> <string name="combined_search_results_off">按類別顯示結果</string> From 63893894d0528566ba08d06415adfa9d55739cc4 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Sun, 19 Jul 2026 00:46:15 +0000 Subject: [PATCH 221/475] Translated using Weblate (Italian) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1bc2a845..0500e3aa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -184,6 +184,8 @@ <string name="video">Video</string> <string name="videos">I video</string> <plurals name="years_old"> + <item quantity="one">%1$d anno</item> + <item quantity="many">%1$d anni</item> <item quantity="other">%1$d anni</item> </plurals> <string name="play_with_transcoding">Riproduci con transcodifica</string> @@ -619,7 +621,7 @@ <string name="skip_behavior_summary">Per intro, titoli di coda e altro</string> <string name="play_with">Riproduci con</string> <string name="transcoding">Trascodifica</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="results">Risultati</string> <string name="require_login">Richiedi login per il profilo</string> <string name="combined_search_results">Risultati di ricerca combinati</string> @@ -692,4 +694,17 @@ <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> <string name="blocklisted">Bloccati</string> <string name="unavailable">Non disponibile</string> + <string name="subtitle_mode_default">Predefinito</string> + <string name="subtitle_mode_none">Nessuno</string> + <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> + <string name="subtitle_mode">Modalità sottotitoli</string> + <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> + <string name="subtitle_mode_smart">Intelligente</string> + <string name="subtitle_mode_only_forced">Solo forzati</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> + <string name="preferred_audio_language">Lingua preferita per l\'audio</string> + <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> + <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> + <string name="any_language">Qualsiasi lingua</string> </resources> From 320c9acc8acfaeb65ecaa2e9e817435a34ec827c Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Sun, 19 Jul 2026 01:09:11 +0000 Subject: [PATCH 222/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (625 of 625 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 6bef3fb1..7b73cb69 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -583,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">转码</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">综合搜索结果</string> <string name="combined_search_results_on">在单个列表中显示所有结果</string> <string name="combined_search_results_off">按类别显示结果</string> From aa83ee138276890f638771be76eeb54e8bd32b89 Mon Sep 17 00:00:00 2001 From: nofkclue <nofkclue@noreply.codeberg.org> Date: Mon, 20 Jul 2026 13:44:26 +0000 Subject: [PATCH 223/475] Translated using Weblate (German) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ccc1a809..0351863a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -59,6 +59,7 @@ <string name="version">Version</string> <string name="watch_live">Live ansehen</string> <plurals name="years_old"> + <item quantity="one">%1$d Jahr alt</item> <item quantity="other">%1$d Jahre alt</item> </plurals> <string name="extras_title">Extras</string> @@ -181,7 +182,7 @@ <string name="installed_version">Installierte Version</string> <string name="max_homepage_items">Max. Anzahl Objekte je Startseiten-Reihe</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ</string> - <string name="live_tv">Live TV</string> + <string name="live_tv">Live-TV</string> <string name="outro">Abspann</string> <string name="play_count">Wiedergabeanzahl</string> <string name="playback_debug_info">Wiedergabe Debug-Infos anzeigen</string> @@ -601,7 +602,7 @@ <string name="cinema_mode">Kinomodus</string> <string name="play_with">Abspielen mit</string> <string name="transcoding">Transkodieren</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Zusammengefasste Suchergebnisse</string> <string name="combined_search_results_on">Alle Ergebnisse in einer gemeinsamen Liste anzeigen</string> <string name="combined_search_results_off">Ergebnisse nach Kategorie anzeigen</string> @@ -671,4 +672,17 @@ <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> <string name="blocklisted">Auf Blockliste hinzugefügt</string> <string name="unavailable">Nicht verfügbar</string> + <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> + <string name="subtitle_mode">Modus Untertitel</string> + <string name="use_user_profile">Nutze deine Profileinstellungen</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_only_forced">Nur Forced</string> + <string name="subtitle_mode_always">Immer</string> + <string name="subtitle_mode_none">Keine</string> + <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> + <string name="preferred_audio_language">Bevorzugte Audiosprache</string> + <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> + <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> + <string name="any_language">Beliebige Sprache</string> </resources> From 08444c2509cd3416195b592e75d3e4a965ca2cf8 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Mon, 20 Jul 2026 06:51:23 +0000 Subject: [PATCH 224/475] Translated using Weblate (Indonesian) Currently translated at 99.8% (624 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 05614872..4635cc76 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -583,7 +583,7 @@ <string name="play_with">Putar dengan</string> <string name="skip_behavior_summary">Untuk intro, kredit, dll</string> <string name="skip_behavior">Perilaku lewati</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Hasil pencarian gabungan</string> <string name="combined_search_results_on">Tampilkan semua hasil dalam satu daftar</string> <string name="combined_search_results_off">Tampilkan hasil berdasarkan kategori</string> @@ -649,4 +649,17 @@ <string name="time_left">Sisa %1$s</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> <string name="unavailable">Tidak tersedia</string> + <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> + <string name="subtitle_mode">Mode subtitel</string> + <string name="use_user_profile">Gunakan pengaturan profil kamu</string> + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Pintar</string> + <string name="subtitle_mode_only_forced">Hanya Paksaan</string> + <string name="subtitle_mode_always">Selalu</string> + <string name="subtitle_mode_none">Tidak ada</string> + <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> + <string name="preferred_audio_language">Bahasa audio preferensi</string> + <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> + <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> + <string name="any_language">Semua bahasa</string> </resources> From b96d50d166db90c3699952dcfc4401b826d43e6b Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 20 Jul 2026 09:30:08 +0000 Subject: [PATCH 225/475] Translated using Weblate (French) Currently translated at 99.8% (624 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d0355a1f..09d9f248 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -696,4 +696,13 @@ <string name="subtitle_mode_none">Aucun</string> <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> <string name="preferred_audio_language">Langue audio préférée</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur provenant du serveur</string> + <string name="subtitle_mode">Mode sous-titres</string> + <string name="use_user_profile">Accédez aux paramètres de votre profil</string> + <string name="subtitle_mode_default">Par défaut</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_always">Toujours</string> + <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> + <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> + <string name="any_language">N\'importe quelle langue</string> </resources> From c7a5ec61a31533c3794bda72dc09fd7ecdefa7ee Mon Sep 17 00:00:00 2001 From: nofkclue <nofkclue@noreply.codeberg.org> Date: Mon, 20 Jul 2026 14:06:32 +0000 Subject: [PATCH 226/475] Translated using Weblate (German) Currently translated at 100.0% (625 of 625 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 0351863a..a9ca187a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -32,7 +32,7 @@ <string name="no_scheduled_recordings">Keine geplanten Aufnahmen</string> <string name="no_update_available">Kein Update verfügbar</string> <string name="path">Pfad</string> - <string name="people_title">Darsteller</string> + <string name="people_title">Personen</string> <string name="recently_added_in">Zuletzt hinzugefügt in %1$s</string> <string name="recently_added">Zuletzt hinzugefügt</string> <string name="recently_recorded">Zuletzt aufgenommen</string> From 043dfb61bd644a1e96248b8c942ff7306b55cb3d Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Mon, 20 Jul 2026 14:38:21 +0000 Subject: [PATCH 227/475] Translated using Weblate (Turkish) Currently translated at 100.0% (625 of 625 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 05f0679a..dfe62eff 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -170,6 +170,7 @@ <string name="videos">Videolar</string> <string name="watch_live">Canlı izle</string> <plurals name="years_old"> + <item quantity="one">%1$d yaşında</item> <item quantity="other">%1$d yaşında</item> </plurals> <string name="play_with_transcoding">Kod dönüştürerek oynat</string> From c198f5c2b6521638548bfaa566e22c0b615ee2b2 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 21 Jul 2026 00:01:38 +0000 Subject: [PATCH 228/475] Translated using Weblate (Slovak) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 6e2e3262..dddbfc2b 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -636,7 +636,7 @@ <item quantity="other">Osôb</item> </plurals> <string name="require_login">Vyžadovať prihlásenie pre profil</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Kombinované výsledky vyhľadávania</string> <string name="combined_search_results_on">Zobraziť všetky výsledky v jednom zozname</string> <string name="combined_search_results_off">Zobraziť výsledky podľa kategórie</string> From 6827201bdc986c4b9ab308da3ec138b793442e5b Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Tue, 21 Jul 2026 10:47:24 +0000 Subject: [PATCH 229/475] Translated using Weblate (Polish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7854f3f5..e137422b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -710,4 +710,23 @@ <string name="unavailable">Niedostępne</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> + <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> + <plurals name="years_old"> + <item quantity="one">%1$d rok</item> + <item quantity="few">%1$d lata</item> + <item quantity="many">%1$d lat</item> + <item quantity="other">%1$d lat</item> + </plurals> + <string name="subtitle_mode">Tryb napisów</string> + <string name="use_user_profile">Użyj ustawień użytkownika</string> + <string name="subtitle_mode_default">Domyślny</string> + <string name="subtitle_mode_smart">Inteligentny</string> + <string name="subtitle_mode_only_forced">Tylko wymuszone</string> + <string name="subtitle_mode_always">Wszystkie</string> + <string name="subtitle_mode_none">Brak</string> + <string name="preferred_subtitle_language">Preferowany język napisów</string> + <string name="preferred_audio_language">Preferowany język dźwięku</string> + <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> + <string name="any_language">Dowolny język</string> + <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> </resources> From b3cc7c424d9721053336e64e126db2d12bcacf65 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Thu, 23 Jul 2026 18:44:21 +0000 Subject: [PATCH 230/475] Translated using Weblate (Swedish) Currently translated at 85.7% (543 of 633 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4705ec8a..d313d931 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -604,4 +604,6 @@ <string name="video_tunneling">Videotunnling</string> <string name="blocklisted">Svartlistad</string> <string name="unavailable">Otillgänglig</string> + <string name="books">Böcker</string> + <string name="audio_books">Ljudböcker</string> </resources> From a4e62fb532b8338d4317eb0e029b97fdc776b617 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Thu, 23 Jul 2026 22:18:39 +0000 Subject: [PATCH 231/475] Translated using Weblate (Spanish) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index faff78c2..b1a382a5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -705,4 +705,15 @@ <string name="override_user_profile_settings">Más ajustes de usuario</string> <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> <string name="any_language">Cualquier idioma</string> + <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> + <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> + <string name="dpad_seek_mode_trickplay">Miniaturas de previsualización</string> + <string name="hash_matches">Coincidencias de valores hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de televisión</string> + <string name="recordings">Grabaciones</string> + <string name="folders_title">Carpetas</string> + <string name="user_views">Vistas de usuario</string> + <string name="books">Libros</string> + <string name="audio_books">Audiolibros</string> </resources> From 0964322ab910ab5b1af9a785957cd1c4ef3208b4 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Thu, 23 Jul 2026 20:31:50 +0000 Subject: [PATCH 232/475] Translated using Weblate (Swedish) Currently translated at 87.4% (556 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d313d931..8339e0ce 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -91,6 +91,7 @@ <string name="videos">Videor</string> <string name="watch_live">Titta live</string> <plurals name="years_old"> + <item quantity="one">%1$d år gammal</item> <item quantity="other">%1$d år gammal</item> </plurals> <string name="play_with_transcoding">Spela med transkodning</string> @@ -179,7 +180,7 @@ <string name="disabled">Inaktiverad</string> <string name="discovered_servers">Upptäckta servrar</string> <string name="enabled">Aktiverad</string> - <string name="enter_server_url">Ange serverns IP eller URL inklusive port</string> + <string name="enter_server_url">Ange serverns IP eller URL</string> <string name="error_loading_collection">Fel vid inläsning av samling %1$s</string> <string name="external_track">Extern</string> <string name="forced_track">Tvingad</string> @@ -606,4 +607,17 @@ <string name="unavailable">Otillgänglig</string> <string name="books">Böcker</string> <string name="audio_books">Ljudböcker</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s kvar</string> + <string name="trailers_title"></string> + <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> + <string name="interlaced">Sammanflätad</string> + <string name="level">Nivå</string> + <string name="mpv_conf">Redigera mpv.conf</string> + <string name="profile_specific_settings_from_server">Profilinställningar från server</string> </resources> From e7a54c80124ea533c2e84e771a1dc04e53da75f9 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Fri, 24 Jul 2026 09:31:58 +0000 Subject: [PATCH 233/475] Translated using Weblate (Slovak) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index dddbfc2b..dfd58267 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -168,7 +168,10 @@ <string name="videos">Videá</string> <string name="watch_live">Sledovať naživo</string> <plurals name="years_old"> - <item quantity="other">%1$d rokov</item> + <item quantity="one">vo veku %1$d roka</item> + <item quantity="few">vo veku %1$d rokov</item> + <item quantity="many">vo veku %1$d rokov</item> + <item quantity="other">vo veku %1$d rokov</item> </plurals> <string name="play_with_transcoding">Prehrávať s prekódovaním</string> <string name="media_information">Informácie o médiu</string> @@ -726,4 +729,15 @@ <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> + <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> + <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> + <string name="hash_matches">Hash zhody</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programy</string> + <string name="recordings">Nahrávky</string> + <string name="folders_title">Priečinky</string> + <string name="user_views">Používateľské zobrazenia</string> + <string name="books">Knihy</string> + <string name="audio_books">Audioknihy</string> </resources> From 09e835a3746468b78050a9ad294181de6ab2a9b4 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 24 Jul 2026 13:16:36 +0000 Subject: [PATCH 234/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7b73cb69..946c2ab2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -663,4 +663,15 @@ <string name="override_user_profile_settings">更多用户配置设置</string> <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> + <string name="d_pad_seek_mode_title">方向键跳转模式</string> + <string name="dpad_seek_mode_skip_time">跳过时间</string> + <string name="dpad_seek_mode_trickplay">特技播放预览</string> + <string name="hash_matches">哈希匹配</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">电视节目</string> + <string name="recordings">录制内容</string> + <string name="folders_title">文件夹</string> + <string name="user_views">用户视图</string> + <string name="books">书籍</string> + <string name="audio_books">有声读物</string> </resources> From eedca7331ac96a5a96a02d40fbd116656e679997 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Fri, 24 Jul 2026 13:21:18 +0000 Subject: [PATCH 235/475] Translated using Weblate (Turkish) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dfe62eff..bad54df5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -685,4 +685,15 @@ <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> + <string name="d_pad_seek_mode_title">D-Pad İlerleme Modu</string> + <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> + <string name="dpad_seek_mode_trickplay">Trickplay Önizlemeleri</string> + <string name="hash_matches">Hash Eşleşmeleri</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programları</string> + <string name="recordings">Kayıtlar</string> + <string name="folders_title">Klasörler</string> + <string name="user_views">Kullanıcı Görünümleri</string> + <string name="books">Kitaplar</string> + <string name="audio_books">Sesli Kitaplar</string> </resources> From 25bbc7c418b1b43f674aa8f55d17c62f2deb32d5 Mon Sep 17 00:00:00 2001 From: Codeberg Translate <translate@codeberg.org> Date: Fri, 24 Jul 2026 15:19:06 +0000 Subject: [PATCH 236/475] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-sv/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 8339e0ce..fd450127 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -614,7 +614,6 @@ <string name="duration_minutes">%1$dm</string> <string name="duration_seconds">%1$ds</string> <string name="time_left">%1$s kvar</string> - <string name="trailers_title"></string> <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> <string name="interlaced">Sammanflätad</string> <string name="level">Nivå</string> From 6c8be55c291bc0c3c65b1228df9ad6c0b887c439 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sat, 25 Jul 2026 04:00:47 +0000 Subject: [PATCH 237/475] Translated using Weblate (German) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9ca187a..0ce7f5dc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -685,4 +685,15 @@ <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> <string name="any_language">Beliebige Sprache</string> + <string name="hash_matches">Hash Übereinstimmungen</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programme</string> + <string name="recordings">Aufnahmen</string> + <string name="folders_title">Ordner</string> + <string name="books">Bücher</string> + <string name="audio_books">Hörbücher</string> + <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> + <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> + <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> + <string name="user_views">Benutzeransicht</string> </resources> From 5820398b4abc3dd1e80939feac696e53a834e070 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sat, 25 Jul 2026 08:25:45 +0000 Subject: [PATCH 238/475] Translated using Weblate (Swedish) Currently translated at 87.7% (558 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index fd450127..3f99f04d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -586,7 +586,7 @@ <string name="slideshow_at_beginning">I början</string> <string name="no_more_images">Inga fler bilder</string> <string name="go_to_artist">Gå till artist</string> - <string name="load_from_web_client">Ladda från web client</string> + <string name="load_from_web_client">Ladda från webbklient</string> <string name="for_episodes">För episoder</string> <string name="increase_all_cards_size">Öka storleken för alla kort</string> <string name="decrease_all_cards_size">Minska storleken för alla kort</string> @@ -619,4 +619,6 @@ <string name="level">Nivå</string> <string name="mpv_conf">Redigera mpv.conf</string> <string name="profile_specific_settings_from_server">Profilinställningar från server</string> + <string name="no_max">Ingen övre gräns</string> + <string name="include_types">Inkludera typer</string> </resources> From 9a6749a0d32cf597d8027034e15be1f3347d5630 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sat, 25 Jul 2026 08:28:52 +0000 Subject: [PATCH 239/475] Translated using Weblate (Swedish) Currently translated at 87.8% (559 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3f99f04d..93c86111 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -621,4 +621,5 @@ <string name="profile_specific_settings_from_server">Profilinställningar från server</string> <string name="no_max">Ingen övre gräns</string> <string name="include_types">Inkludera typer</string> + <string name="load_from_server">Ladda från serverns användarprofil</string> </resources> From 50f2afc5c431de0413878176cd0b6c51eacdf7d1 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Sat, 25 Jul 2026 22:53:30 +0000 Subject: [PATCH 240/475] Translated using Weblate (Spanish) Currently translated at 100.0% (639 of 639 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b1a382a5..794ec38b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -707,7 +707,7 @@ <string name="any_language">Cualquier idioma</string> <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> - <string name="dpad_seek_mode_trickplay">Miniaturas de previsualización</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> <string name="hash_matches">Coincidencias de valores hash</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">Programas de televisión</string> @@ -716,4 +716,8 @@ <string name="user_views">Vistas de usuario</string> <string name="books">Libros</string> <string name="audio_books">Audiolibros</string> + <string name="dpad_seek_mode_minimal">Mínima</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> </resources> From 4dde8b80ccfa723b7076364c26f425bb571b4c1c Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sat, 25 Jul 2026 22:00:36 +0000 Subject: [PATCH 241/475] Translated using Weblate (Turkish) Currently translated at 100.0% (639 of 639 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index bad54df5..65ccdc0f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -685,9 +685,9 @@ <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> - <string name="d_pad_seek_mode_title">D-Pad İlerleme Modu</string> + <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> - <string name="dpad_seek_mode_trickplay">Trickplay Önizlemeleri</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash Eşleşmeleri</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">TV Programları</string> @@ -696,4 +696,8 @@ <string name="user_views">Kullanıcı Görünümleri</string> <string name="books">Kitaplar</string> <string name="audio_books">Sesli Kitaplar</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> + <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> + <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> </resources> From 36369afb707a1618924b1effb63e43d3c767adb5 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Sun, 26 Jul 2026 17:20:24 +0000 Subject: [PATCH 242/475] Translated using Weblate (Spanish) Currently translated at 100.0% (641 of 641 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 794ec38b..a231db57 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -720,4 +720,6 @@ <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> + <string name="brown">Marrón</string> + <string name="server_version_not_supported">Versión de servidor no soportada</string> </resources> From e41d0032f5801189acfa7d5269f68445ac9689ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Sun, 26 Jul 2026 19:20:53 +0000 Subject: [PATCH 243/475] Translated using Weblate (Estonian) Currently translated at 98.7% (633 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 6aa53fea..e72bc82f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -232,7 +232,8 @@ <string name="videos">Videod</string> <string name="watch_live">Vaata otse-eetris</string> <plurals name="years_old"> - <item quantity="other">%1$d aasta(t) vana</item> + <item quantity="one">%1$d aasta vana</item> + <item quantity="other">%1$d aastat vana</item> </plurals> <string name="play_with_transcoding">Esita koos teisendamisega</string> <string name="show_clock">Näita kella</string> @@ -684,4 +685,12 @@ <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> <string name="any_language">Iga keel</string> + <string name="brown">Pruun</string> + <string name="programs">Teleprogrammid</string> + <string name="recordings">Salvestused</string> + <string name="folders_title">Kaustad</string> + <string name="user_views">Kasutajate vaatamisi</string> + <string name="books">Raamatud</string> + <string name="audio_books">Heliraamatud</string> + <string name="server_version_not_supported">Serveri versioon pole toetatud</string> </resources> From a1b5182e6abdbb6b9165684cbe1c3b58b1351c70 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sun, 26 Jul 2026 15:44:37 +0000 Subject: [PATCH 244/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.8% (640 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c7797553..1bf1f897 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -663,4 +663,19 @@ <string name="override_user_profile_settings">更多個人化設定</string> <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> <string name="any_language">任何語言</string> + <string name="brown">棕色</string> + <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> + <string name="dpad_seek_mode_minimal">精簡</string> + <string name="dpad_seek_mode_trickplay">縮圖預覽</string> + <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> + <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> + <string name="dpad_seek_mode_none_summary">不顯示進度條</string> + <string name="hash_matches">雜湊值相符</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">電視劇</string> + <string name="recordings">錄影節目</string> + <string name="folders_title">資料夾</string> + <string name="books">書籍</string> + <string name="audio_books">有聲書</string> + <string name="server_version_not_supported">不支援此伺服器版本</string> </resources> From 4633209fb325c5dd8aa3ac74bed4a571b23883f2 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Sun, 26 Jul 2026 06:03:39 +0000 Subject: [PATCH 245/475] Translated using Weblate (Italian) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0500e3aa..64160937 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -707,4 +707,20 @@ <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> <string name="any_language">Qualsiasi lingua</string> + <string name="brown">Marrone</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programmi TV</string> + <string name="recordings">Registrazioni</string> + <string name="folders_title">Cartelle</string> + <string name="books">Libri</string> + <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimale</string> + <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> + <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> + <string name="hash_matches">Corrispondenze hash</string> + <string name="user_views">Viste utente</string> + <string name="audio_books">Audiolibri</string> + <string name="server_version_not_supported">Versione del server non supportata</string> </resources> From d969e73bd0a9023853db9c364958010a05822b08 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Mon, 27 Jul 2026 13:10:39 +0000 Subject: [PATCH 246/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index bfc97e08..1a54331f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -138,6 +138,8 @@ <string name="videos">Vídeos</string> <string name="watch_live">Ver em directo</string> <plurals name="years_old"> + <item quantity="one">%1$d ano</item> + <item quantity="many">%1$d anos</item> <item quantity="other">%1$d anos</item> </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> @@ -619,7 +621,7 @@ <string name="skip_behavior_summary">Para intros, créditos, etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificar</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Combinar resultados da pesquisa</string> <string name="combined_search_results_on">Mostrar todos os resultados numa única lista</string> <string name="combined_search_results_off">Mostrar resultados por categoria</string> @@ -705,4 +707,20 @@ <string name="override_user_profile_settings">Mais definições do perfil</string> <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> <string name="any_language">Qualquer idioma</string> + <string name="brown">Castanho</string> + <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> + <string name="user_views">Visualizações do utilizador</string> + <string name="books">Livros</string> + <string name="audio_books">Audiolivros</string> + <string name="server_version_not_supported">Versão do servidor não suportada</string> </resources> From dcb304641d082f51b1d2e0b283eae6786a923ec6 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:56:27 +0000 Subject: [PATCH 247/475] Translated using Weblate (French) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 09d9f248..d129132c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -705,4 +705,20 @@ <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> <string name="any_language">N\'importe quelle langue</string> + <string name="brown">Marron</string> + <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimum</string> + <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> + <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> + <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> + <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> + <string name="hash_matches">Correspondances de hachage</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Émissions de télévision</string> + <string name="recordings">Enregistrements</string> + <string name="folders_title">Dossiers</string> + <string name="user_views">Avis des utilisateurs</string> + <string name="books">Livres</string> + <string name="audio_books">Livre audio</string> + <string name="server_version_not_supported">Version du serveur non prise en charge</string> </resources> From 4ed689064e1fdc96aa2ee057e154273b3a913713 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:49:39 +0000 Subject: [PATCH 248/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index dafdcbfc..a098fbf1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -216,6 +216,8 @@ <string name="video">וידאו</string> <string name="watch_live">צפה בשידור חי</string> <plurals name="years_old"> + <item quantity="one">בגיל שנה</item> + <item quantity="two">בגיל שנתיים</item> <item quantity="other">בגיל %1$d</item> </plurals> <string name="play_with_transcoding">נגן עם המרת קידוד</string> @@ -631,7 +633,7 @@ <string name="live_tv_repeat">שידור חוזר</string> <string name="color_code_programs">קידוד צבע לתוכניות</string> <string name="slideshow_at_beginning">מההתחלה</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">תוצאות חיפוש ממוזגות</string> <string name="combined_search_results_on">הצג את כל התוצאות ברשימה אחת</string> <string name="combined_search_results_off">הצג תוצאות לפי קטגוריה</string> @@ -692,4 +694,33 @@ <string name="unavailable">לא זמין</string> <string name="blocklisted">ברשימה חסומה</string> <string name="video_tunneling">מנהור וידאו</string> + <string name="brown">חום</string> + <string name="dpad_seek_mode_minimal">מינימלי</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitle_mode">מצב כתוביות</string> + <string name="subtitle_mode_default">ברירת מחדל</string> + <string name="subtitle_mode_smart">חכם</string> + <string name="subtitle_mode_only_forced">כפויות בלבד</string> + <string name="subtitle_mode_always">תמיד</string> + <string name="subtitle_mode_none">ללא</string> + <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> + <string name="preferred_audio_language">שפה מועדפת לשמע</string> + <string name="programs">תוכניות טלוויזיה</string> + <string name="recordings">הקלטות</string> + <string name="folders_title">תיקיות</string> + <string name="user_views">צפיות משתמש</string> + <string name="books">ספרים</string> + <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> + <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> + <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> + <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> + <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> + <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> + <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> + <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> + <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> + <string name="any_language">כל שפה</string> + <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> + <string name="audio_books">ספרי שמע</string> + <string name="hash_matches">התאמות של פונקציית־גיבוב</string> </resources> From 477e4c921d808951d8dee788192e4aa0fc7b93f2 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:43:07 +0000 Subject: [PATCH 249/475] Translated using Weblate (Polish) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e137422b..b2d934ce 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -729,4 +729,20 @@ <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> <string name="any_language">Dowolny język</string> <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> + <string name="brown">Brązowy</string> + <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimalistyczny</string> + <string name="dpad_seek_mode_trickplay">Podgląd scen</string> + <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> + <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> + <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> + <string name="hash_matches">Dopasowania hashy</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programy TV</string> + <string name="recordings">Nagrania</string> + <string name="folders_title">Foldery</string> + <string name="user_views">Widoki użytkownika</string> + <string name="books">Książki</string> + <string name="audio_books">Audiobooki</string> + <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> </resources> From 4f05b6ffab989dda53fca228c6188528fc5a8b1e Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Mon, 27 Jul 2026 09:34:50 +0000 Subject: [PATCH 250/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 10 ++++++++-- 1 file changed, 8 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 946c2ab2..67750ece 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -663,9 +663,9 @@ <string name="override_user_profile_settings">更多用户配置设置</string> <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> - <string name="d_pad_seek_mode_title">方向键跳转模式</string> + <string name="d_pad_seek_mode_title">方向键进度条模式</string> <string name="dpad_seek_mode_skip_time">跳过时间</string> - <string name="dpad_seek_mode_trickplay">特技播放预览</string> + <string name="dpad_seek_mode_trickplay">特技播放</string> <string name="hash_matches">哈希匹配</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">电视节目</string> @@ -674,4 +674,10 @@ <string name="user_views">用户视图</string> <string name="books">书籍</string> <string name="audio_books">有声读物</string> + <string name="brown">棕色</string> + <string name="dpad_seek_mode_minimal">极简</string> + <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> + <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> + <string name="dpad_seek_mode_none_summary">无进度条</string> + <string name="server_version_not_supported">服务器版本不支持</string> </resources> From 05f84561775d1a8dcb1788bee9f1c102471b3717 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sun, 26 Jul 2026 21:58:04 +0000 Subject: [PATCH 251/475] Translated using Weblate (Turkish) Currently translated at 100.0% (641 of 641 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 65ccdc0f..0216e5a7 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -700,4 +700,6 @@ <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> + <string name="brown">Kahverengi</string> + <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> </resources> From 42155bdab00870c8c07de0765941bbe912e0ac6b Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Mon, 27 Jul 2026 22:02:09 +0000 Subject: [PATCH 252/475] Translated using Weblate (Slovak) Currently translated at 98.9% (634 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index dfd58267..999c4e51 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -740,4 +740,5 @@ <string name="user_views">Používateľské zobrazenia</string> <string name="books">Knihy</string> <string name="audio_books">Audioknihy</string> + <string name="brown">Hnedá</string> </resources> From d0d996fda7a8ea83c189209d095e9885773efbf2 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Wed, 29 Jul 2026 03:55:12 +0000 Subject: [PATCH 253/475] Translated using Weblate (Indonesian) Currently translated at 97.5% (626 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 4635cc76..fbd7ed5d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -662,4 +662,6 @@ <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> <string name="any_language">Semua bahasa</string> + <string name="brown">Cokelat</string> + <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> </resources> From fbeb192ad7fd73b48af9db453533f12cc502cf0f Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 29 Jul 2026 10:23:51 +0000 Subject: [PATCH 254/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (642 of 642 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 67750ece..f7d39669 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -680,4 +680,5 @@ <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> <string name="dpad_seek_mode_none_summary">无进度条</string> <string name="server_version_not_supported">服务器版本不支持</string> + <string name="appears_on">出现在</string> </resources> From 7074123bc1e5ef834da210da420eb8952ea3af59 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 16:25:24 -0400 Subject: [PATCH 255/475] Translations 2026-07-29 (#1764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update translations as of 2026-07-29 Thank you to all of the contributors! --------- Co-authored-by: Priit Jõerüüt <jrtcdbrg@noreply.codeberg.org> Co-authored-by: n8llcaster <n8llcaster@noreply.codeberg.org> Co-authored-by: SimonHung <simonhung@noreply.codeberg.org> Co-authored-by: TheMatrixan <thematrixan@noreply.codeberg.org> Co-authored-by: butterflyoffire <butterflyoffire@noreply.codeberg.org> Co-authored-by: idezentas <idezentas@noreply.codeberg.org> Co-authored-by: Outbreak2096 <outbreak2096@noreply.codeberg.org> Co-authored-by: arcker95 <arcker95@noreply.codeberg.org> Co-authored-by: North-DaCoder <north-dacoder@noreply.codeberg.org> Co-authored-by: danpergal84 <danpergal84@noreply.codeberg.org> Co-authored-by: tal_sarid <tal_sarid@noreply.codeberg.org> Co-authored-by: streamyfinchris <streamyfinchris@noreply.codeberg.org> Co-authored-by: viretius <viretius@noreply.codeberg.org> Co-authored-by: opakholis <opakholis@noreply.codeberg.org> Co-authored-by: ymir <ymir@noreply.codeberg.org> Co-authored-by: American_Jesus <american_jesus@noreply.codeberg.org> Co-authored-by: damontecres <damontecres@noreply.codeberg.org> Co-authored-by: unikitty <unikitty@noreply.codeberg.org> Co-authored-by: outopik <outopik@noreply.codeberg.org> Co-authored-by: Codeberg Translate <translate@codeberg.org> Co-authored-by: nofkclue <nofkclue@noreply.codeberg.org> Co-authored-by: Prahlis <prahlis@noreply.codeberg.org> --- app/src/main/res/values-de/strings.xml | 37 +++++++++-- app/src/main/res/values-en-rGB/strings.xml | 39 ++++++++++++ app/src/main/res/values-es/strings.xml | 53 ++++++++++++++++ app/src/main/res/values-et/strings.xml | 35 +++++++++- app/src/main/res/values-fr/strings.xml | 40 ++++++++++++ app/src/main/res/values-in/strings.xml | 27 +++++++- app/src/main/res/values-it/strings.xml | 44 ++++++++++++- app/src/main/res/values-iw/strings.xml | 56 +++++++++++++++- app/src/main/res/values-kab/strings.xml | 14 +++- app/src/main/res/values-pl/strings.xml | 42 +++++++++++- app/src/main/res/values-pt/strings.xml | 56 +++++++++++++++- app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-sk/strings.xml | 45 ++++++++++++- app/src/main/res/values-sv/strings.xml | 74 +++++++++++++++++++++- app/src/main/res/values-tr/strings.xml | 35 ++++++++++ app/src/main/res/values-uk/strings.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 37 ++++++++++- app/src/main/res/values-zh-rTW/strings.xml | 40 ++++++++++-- 18 files changed, 651 insertions(+), 29 deletions(-) create mode 100644 app/src/main/res/values-en-rGB/strings.xml diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bb7265dd..0ce7f5dc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -32,7 +32,7 @@ <string name="no_scheduled_recordings">Keine geplanten Aufnahmen</string> <string name="no_update_available">Kein Update verfügbar</string> <string name="path">Pfad</string> - <string name="people_title">Darsteller</string> + <string name="people_title">Personen</string> <string name="recently_added_in">Zuletzt hinzugefügt in %1$s</string> <string name="recently_added">Zuletzt hinzugefügt</string> <string name="recently_recorded">Zuletzt aufgenommen</string> @@ -59,6 +59,7 @@ <string name="version">Version</string> <string name="watch_live">Live ansehen</string> <plurals name="years_old"> + <item quantity="one">%1$d Jahr alt</item> <item quantity="other">%1$d Jahre alt</item> </plurals> <string name="extras_title">Extras</string> @@ -181,7 +182,7 @@ <string name="installed_version">Installierte Version</string> <string name="max_homepage_items">Max. Anzahl Objekte je Startseiten-Reihe</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ</string> - <string name="live_tv">Live TV</string> + <string name="live_tv">Live-TV</string> <string name="outro">Abspann</string> <string name="play_count">Wiedergabeanzahl</string> <string name="playback_debug_info">Wiedergabe Debug-Infos anzeigen</string> @@ -470,7 +471,7 @@ <string name="for_all_ages">Ohne Altersbeschränkung</string> <string name="up_to_age">Bis %s Jahre</string> <string name="screensaver">Bildschirmschoner</string> - <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> <string name="start_screensaver">Bildschirmschoner starten</string> <string name="duration">Dauer</string> <string name="photos">Fotos</string> @@ -601,7 +602,7 @@ <string name="cinema_mode">Kinomodus</string> <string name="play_with">Abspielen mit</string> <string name="transcoding">Transkodieren</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Zusammengefasste Suchergebnisse</string> <string name="combined_search_results_on">Alle Ergebnisse in einer gemeinsamen Liste anzeigen</string> <string name="combined_search_results_off">Ergebnisse nach Kategorie anzeigen</string> @@ -667,4 +668,32 @@ <string name="duration_seconds">%1$ds</string> <string name="time_left">%1$s verbleibend</string> <string name="video_tunneling">Video Tunneling</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> + <string name="blocklisted">Auf Blockliste hinzugefügt</string> + <string name="unavailable">Nicht verfügbar</string> + <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> + <string name="subtitle_mode">Modus Untertitel</string> + <string name="use_user_profile">Nutze deine Profileinstellungen</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_only_forced">Nur Forced</string> + <string name="subtitle_mode_always">Immer</string> + <string name="subtitle_mode_none">Keine</string> + <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> + <string name="preferred_audio_language">Bevorzugte Audiosprache</string> + <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> + <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> + <string name="any_language">Beliebige Sprache</string> + <string name="hash_matches">Hash Übereinstimmungen</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programme</string> + <string name="recordings">Aufnahmen</string> + <string name="folders_title">Ordner</string> + <string name="books">Bücher</string> + <string name="audio_books">Hörbücher</string> + <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> + <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> + <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> + <string name="user_views">Benutzeransicht</string> </resources> diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 00000000..417bac6b --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="add_favorite">Favourite</string> + <string name="favorites">Favourites</string> + <string name="license_info">Licence information</string> + <string name="record_program">Record Programme</string> + <string name="remove_favorite">Unfavourite</string> + <string name="voice_error_no_match">No speech recognised</string> + <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> + <string name="italic_font">Italicise font</string> + <string name="favorite_items_title">Favourite %s</string> + <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> + <string name="skip_commercials_behavior">Skip commercials behaviour</string> + <string name="skip_intro_behavior">Skip intro behaviour</string> + <string name="skip_outro_behavior">Skip outro behaviour</string> + <string name="skip_previews_behavior">Skip previews behaviour</string> + <string name="skip_recap_behavior">Skip recap behaviour</string> + <string name="font_color">Font colour</string> + <string name="edge_color">Edge colour</string> + <string name="background_color">Background colour</string> + <string name="press_enter_to_confirm">Press centre to confirm</string> + <string name="color_space">Colour space</string> + <string name="color_transfer">Colour transfer</string> + <string name="color_primaries">Colour primaries</string> + <string name="favorite_channels_at_beginning">Show favourite channels first</string> + <string name="color_code_programs">Colour-code programmes</string> + <string name="quick_connect_summary">Authorise another device to log in to your account</string> + <string name="quick_connect_success">Device authorised successfully</string> + <string name="contrast">Contrast</string> + <string name="customize_home">Customise home page</string> + <string name="light_gray">Light grey</string> + <string name="dark_gray">Dark grey</string> + <string name="backdrop_style_dynamic">Image with dynamic colour</string> + <string name="show_visualizer">Show visualiser</string> + <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> + <string name="skip_behavior">Skip behaviour</string> + <string name="display_toggles_summary">Customise which to display</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> +</resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9b5616ca..a231db57 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -669,4 +669,57 @@ <item quantity="other">Personas</item> </plurals> <string name="trailers_title">Tráileres</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar audio no estéreo a AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para audio multicanal</string> + <string name="genre_movies">%1$s Películas</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Configuración de calidad</string> + <string name="is_default">Predeterminado</string> + <string name="root_folder">Carpeta principal</string> + <string name="popularity">Popularidad</string> + <string name="production_status">Estado</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Ajustes experimentales</string> + <string name="confirm_enable_experimental_title">¿Habilitar ajustes experimentales?</string> + <string name="confirm_enable_experimental_body">¡Los ajustes experimentales pueden ser inestables!\n\n Pueden ser cambiados o eliminados en cualquier lanzamiento.</string> + <string name="video_tunneling">Tunelización de video</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">No disponible</string> + <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> + <string name="subtitle_mode">Modo de subtítulos</string> + <string name="use_user_profile">Usar tu configuración de usuario</string> + <string name="subtitle_mode_default">Predeterminado</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Solo forzados</string> + <string name="subtitle_mode_always">Siempre</string> + <string name="subtitle_mode_none">Ninguno</string> + <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> + <string name="preferred_audio_language">Idioma de audio preferido</string> + <string name="override_user_profile_settings">Más ajustes de usuario</string> + <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> + <string name="any_language">Cualquier idioma</string> + <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> + <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="hash_matches">Coincidencias de valores hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de televisión</string> + <string name="recordings">Grabaciones</string> + <string name="folders_title">Carpetas</string> + <string name="user_views">Vistas de usuario</string> + <string name="books">Libros</string> + <string name="audio_books">Audiolibros</string> + <string name="dpad_seek_mode_minimal">Mínima</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> + <string name="brown">Marrón</string> + <string name="server_version_not_supported">Versión de servidor no soportada</string> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index cfbf2725..e72bc82f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -232,7 +232,8 @@ <string name="videos">Videod</string> <string name="watch_live">Vaata otse-eetris</string> <plurals name="years_old"> - <item quantity="other">%1$d aasta(t) vana</item> + <item quantity="one">%1$d aasta vana</item> + <item quantity="other">%1$d aastat vana</item> </plurals> <string name="play_with_transcoding">Esita koos teisendamisega</string> <string name="show_clock">Näita kella</string> @@ -660,4 +661,36 @@ <string name="confirm_enable_experimental_title">Kas võtad katselised seadistused kasutusele?</string> <string name="confirm_enable_experimental_body">Katselised seadistused ei pruugi olla päris töökindlad!\n\n Lisaks võib arendaja neid omal äranägemisel muuta või sootuks eemaldada.</string> <string name="video_tunneling">Video tunneldus</string> + <string name="duration_hours_minutes_seconds">%1$d t %2$d min %3$d sek</string> + <string name="duration_hours_minutes">%1$d t %2$d min</string> + <string name="duration_minutes_seconds">%1$d min %2$d sek</string> + <string name="duration_hours">%1$d t</string> + <string name="duration_minutes">%1$d min</string> + <string name="duration_seconds">%1$d sek</string> + <string name="time_left">jäänud %1$s</string> + <string name="blocklisted">Keeluloendis</string> + <string name="unavailable">Pole saadaval</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> + <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> + <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> + <string name="subtitle_mode">Subtiitrite režiim</string> + <string name="use_user_profile">Kasuta oma profiili seadistust</string> + <string name="subtitle_mode_default">Vaikimisi</string> + <string name="subtitle_mode_smart">Nutikas</string> + <string name="subtitle_mode_only_forced">Ainult sundkorras</string> + <string name="subtitle_mode_always">Alati</string> + <string name="subtitle_mode_none">Määramata</string> + <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> + <string name="preferred_audio_language">Heliriba eelistatud keel</string> + <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> + <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> + <string name="any_language">Iga keel</string> + <string name="brown">Pruun</string> + <string name="programs">Teleprogrammid</string> + <string name="recordings">Salvestused</string> + <string name="folders_title">Kaustad</string> + <string name="user_views">Kasutajate vaatamisi</string> + <string name="books">Raamatud</string> + <string name="audio_books">Heliraamatud</string> + <string name="server_version_not_supported">Serveri versioon pole toetatud</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 421c1b0e..d129132c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -681,4 +681,44 @@ <string name="confirm_enable_experimental_title">Activer les paramètres expérimentaux ?</string> <string name="confirm_enable_experimental_body">Les paramètres expérimentaux peuvent être instables !\n\nIls sont susceptibles d\'être supprimés ou modifiés dans n\'importe quelle version.</string> <string name="video_tunneling">Tunneling vidéo</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restant</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcoder l\'audio non stéréo vers AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> + <string name="blocklisted">sur liste noire</string> + <string name="unavailable">Indisponible</string> + <string name="subtitle_mode_only_forced">forcés uniquement</string> + <string name="subtitle_mode_none">Aucun</string> + <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> + <string name="preferred_audio_language">Langue audio préférée</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur provenant du serveur</string> + <string name="subtitle_mode">Mode sous-titres</string> + <string name="use_user_profile">Accédez aux paramètres de votre profil</string> + <string name="subtitle_mode_default">Par défaut</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_always">Toujours</string> + <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> + <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> + <string name="any_language">N\'importe quelle langue</string> + <string name="brown">Marron</string> + <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimum</string> + <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> + <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> + <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> + <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> + <string name="hash_matches">Correspondances de hachage</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Émissions de télévision</string> + <string name="recordings">Enregistrements</string> + <string name="folders_title">Dossiers</string> + <string name="user_views">Avis des utilisateurs</string> + <string name="books">Livres</string> + <string name="audio_books">Livre audio</string> + <string name="server_version_not_supported">Version du serveur non prise en charge</string> </resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2bdc1d68..fbd7ed5d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -583,7 +583,7 @@ <string name="play_with">Putar dengan</string> <string name="skip_behavior_summary">Untuk intro, kredit, dll</string> <string name="skip_behavior">Perilaku lewati</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Hasil pencarian gabungan</string> <string name="combined_search_results_on">Tampilkan semua hasil dalam satu daftar</string> <string name="combined_search_results_off">Tampilkan hasil berdasarkan kategori</string> @@ -639,4 +639,29 @@ <plurals name="people"> <item quantity="other">Orang</item> </plurals> + <string name="prefer_ac3_for_surround">Gunakan AC3 untuk surround sound</string> + <string name="duration_hours_minutes_seconds">%1$dj %2$dm %3$dd</string> + <string name="duration_hours_minutes">%1$dj %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$dd</string> + <string name="duration_hours">%1$dj</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$dd</string> + <string name="time_left">Sisa %1$s</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> + <string name="unavailable">Tidak tersedia</string> + <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> + <string name="subtitle_mode">Mode subtitel</string> + <string name="use_user_profile">Gunakan pengaturan profil kamu</string> + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Pintar</string> + <string name="subtitle_mode_only_forced">Hanya Paksaan</string> + <string name="subtitle_mode_always">Selalu</string> + <string name="subtitle_mode_none">Tidak ada</string> + <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> + <string name="preferred_audio_language">Bahasa audio preferensi</string> + <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> + <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> + <string name="any_language">Semua bahasa</string> + <string name="brown">Cokelat</string> + <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index baeb5346..64160937 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -184,6 +184,8 @@ <string name="video">Video</string> <string name="videos">I video</string> <plurals name="years_old"> + <item quantity="one">%1$d anno</item> + <item quantity="many">%1$d anni</item> <item quantity="other">%1$d anni</item> </plurals> <string name="play_with_transcoding">Riproduci con transcodifica</string> @@ -619,7 +621,7 @@ <string name="skip_behavior_summary">Per intro, titoli di coda e altro</string> <string name="play_with">Riproduci con</string> <string name="transcoding">Trascodifica</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="results">Risultati</string> <string name="require_login">Richiedi login per il profilo</string> <string name="combined_search_results">Risultati di ricerca combinati</string> @@ -681,4 +683,44 @@ <string name="confirm_enable_experimental_title">Abilita impostazioni sperimentali?</string> <string name="confirm_enable_experimental_body">Le impostazioni sperimentali possono essere instabili!\nPossono essere rimosse o cambiate in qualsiasi aggiornamento.</string> <string name="video_tunneling">Tunneling video</string> + <string name="duration_hours_minutes_seconds">%1$do %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$do %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$do</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s rimanenti</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodifica l’audio non stereo in AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> + <string name="blocklisted">Bloccati</string> + <string name="unavailable">Non disponibile</string> + <string name="subtitle_mode_default">Predefinito</string> + <string name="subtitle_mode_none">Nessuno</string> + <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> + <string name="subtitle_mode">Modalità sottotitoli</string> + <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> + <string name="subtitle_mode_smart">Intelligente</string> + <string name="subtitle_mode_only_forced">Solo forzati</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> + <string name="preferred_audio_language">Lingua preferita per l\'audio</string> + <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> + <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> + <string name="any_language">Qualsiasi lingua</string> + <string name="brown">Marrone</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programmi TV</string> + <string name="recordings">Registrazioni</string> + <string name="folders_title">Cartelle</string> + <string name="books">Libri</string> + <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimale</string> + <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> + <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> + <string name="hash_matches">Corrispondenze hash</string> + <string name="user_views">Viste utente</string> + <string name="audio_books">Audiolibri</string> + <string name="server_version_not_supported">Versione del server non supportata</string> </resources> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 2a541e9c..a098fbf1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -216,6 +216,8 @@ <string name="video">וידאו</string> <string name="watch_live">צפה בשידור חי</string> <plurals name="years_old"> + <item quantity="one">בגיל שנה</item> + <item quantity="two">בגיל שנתיים</item> <item quantity="other">בגיל %1$d</item> </plurals> <string name="play_with_transcoding">נגן עם המרת קידוד</string> @@ -631,7 +633,7 @@ <string name="live_tv_repeat">שידור חוזר</string> <string name="color_code_programs">קידוד צבע לתוכניות</string> <string name="slideshow_at_beginning">מההתחלה</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">תוצאות חיפוש ממוזגות</string> <string name="combined_search_results_on">הצג את כל התוצאות ברשימה אחת</string> <string name="combined_search_results_off">הצג תוצאות לפי קטגוריה</string> @@ -669,4 +671,56 @@ <string name="samples_title">דגימות</string> <string name="featurettes_title">קטעי בונוס</string> <string name="shorts_title">קטעים קצרים</string> + <string name="duration_hours_minutes_seconds">%1$d שע\' %2$d דק\' %3$d שנ\'</string> + <string name="duration_hours_minutes">%1$d שע\' %2$d דק\'</string> + <string name="duration_minutes_seconds">%1$d דק\' %2$d שנ\'</string> + <string name="duration_hours">%1$d שע\'</string> + <string name="duration_minutes">%1$d דק\'</string> + <string name="duration_seconds">%1$d שנ\'</string> + <string name="time_left">%1$s נותרו</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[לקודד מחדש שמע שאינו סטריאו ל־AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">להשתמש ב־AC3 עבור שמע סראונד</string> + <string name="genre_movies">סרטי %1$s</string> + <string name="genre_tv_shows">סדרות %1$s</string> + <string name="quality_profile">פרופיל איכות</string> + <string name="is_default">ברירת מחדל</string> + <string name="root_folder">תיקייה ראשית</string> + <string name="popularity">פופולריות</string> + <string name="production_status">מצב הפקה</string> + <string name="different_for_hdr">השתמש בסגנון שונה עבור HDR</string> + <string name="experimental_settings">הגדרות ניסיוניות</string> + <string name="confirm_enable_experimental_title">האם לאפשר הגדרות ניסיוניות?</string> + <string name="confirm_enable_experimental_body">ההגדרות הניסיוניות עשויות להיות לא יציבות!\n\nהן עשויות להימחק או להשתנות בכל גרסה.</string> + <string name="unavailable">לא זמין</string> + <string name="blocklisted">ברשימה חסומה</string> + <string name="video_tunneling">מנהור וידאו</string> + <string name="brown">חום</string> + <string name="dpad_seek_mode_minimal">מינימלי</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitle_mode">מצב כתוביות</string> + <string name="subtitle_mode_default">ברירת מחדל</string> + <string name="subtitle_mode_smart">חכם</string> + <string name="subtitle_mode_only_forced">כפויות בלבד</string> + <string name="subtitle_mode_always">תמיד</string> + <string name="subtitle_mode_none">ללא</string> + <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> + <string name="preferred_audio_language">שפה מועדפת לשמע</string> + <string name="programs">תוכניות טלוויזיה</string> + <string name="recordings">הקלטות</string> + <string name="folders_title">תיקיות</string> + <string name="user_views">צפיות משתמש</string> + <string name="books">ספרים</string> + <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> + <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> + <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> + <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> + <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> + <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> + <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> + <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> + <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> + <string name="any_language">כל שפה</string> + <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> + <string name="audio_books">ספרי שמע</string> + <string name="hash_matches">התאמות של פונקציית־גיבוב</string> </resources> diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 43c74c5a..7b8e9032 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -6,8 +6,8 @@ <string name="bitrate">Aktum</string> <string name="cancel">Semmet</string> <string name="chapters">Ixfawen</string> - <string name="collection">Tagrumma</string> - <string name="collections">Tigrummiwin</string> + <string name="collection">Talkensit</string> + <string name="collections">Tilkensa</string> <string name="confirm">Serggeg</string> <string name="default_track">Amezwer</string> <string name="delete">Kkes</string> @@ -43,4 +43,14 @@ <string name="hdr10_plus">HDR10+</string> <string name="hlg">HLG</string> <string name="choose_stream">Fren %1$s</string> + <string name="more">Ugar</string> + <string name="name">Isem</string> + <string name="none">Ula yiwen</string> + <string name="subtitle">Aduzwel</string> + <string name="subtitles">Iduzwilen</string> + <string name="suggestions">Isumar</string> + <string name="settings">Iɣewwaren</string> + <string name="reset">Wennez</string> + <string name="remove">Kkes</string> + <string name="subtitle_margin">Tama</string> </resources> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 30bee7af..b2d934ce 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -263,9 +263,6 @@ <string name="people_title">Obsada</string> <string name="none">Brak</string> <string name="unknown">Nieznany</string> - <plurals name="years_old"> - <item quantity="other">%1$d lat temu</item> - </plurals> <string name="success">Powodzenie</string> <string name="official_rating">Ocena rodzicielska</string> <string name="extras_title">Dodatki</string> @@ -709,4 +706,43 @@ <string name="duration_hours_minutes_seconds">%1$d godz. %2$d min. %3$d sek.</string> <string name="duration_hours_minutes">%1$d godz. %2$d min.</string> <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> + <string name="blocklisted">Na czarnej liście</string> + <string name="unavailable">Niedostępne</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> + <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> + <plurals name="years_old"> + <item quantity="one">%1$d rok</item> + <item quantity="few">%1$d lata</item> + <item quantity="many">%1$d lat</item> + <item quantity="other">%1$d lat</item> + </plurals> + <string name="subtitle_mode">Tryb napisów</string> + <string name="use_user_profile">Użyj ustawień użytkownika</string> + <string name="subtitle_mode_default">Domyślny</string> + <string name="subtitle_mode_smart">Inteligentny</string> + <string name="subtitle_mode_only_forced">Tylko wymuszone</string> + <string name="subtitle_mode_always">Wszystkie</string> + <string name="subtitle_mode_none">Brak</string> + <string name="preferred_subtitle_language">Preferowany język napisów</string> + <string name="preferred_audio_language">Preferowany język dźwięku</string> + <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> + <string name="any_language">Dowolny język</string> + <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> + <string name="brown">Brązowy</string> + <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimalistyczny</string> + <string name="dpad_seek_mode_trickplay">Podgląd scen</string> + <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> + <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> + <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> + <string name="hash_matches">Dopasowania hashy</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programy TV</string> + <string name="recordings">Nagrania</string> + <string name="folders_title">Foldery</string> + <string name="user_views">Widoki użytkownika</string> + <string name="books">Książki</string> + <string name="audio_books">Audiobooki</string> + <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> </resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4c06ad97..1a54331f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -138,6 +138,8 @@ <string name="videos">Vídeos</string> <string name="watch_live">Ver em directo</string> <plurals name="years_old"> + <item quantity="one">%1$d ano</item> + <item quantity="many">%1$d anos</item> <item quantity="other">%1$d anos</item> </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> @@ -619,7 +621,7 @@ <string name="skip_behavior_summary">Para intros, créditos, etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificar</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Combinar resultados da pesquisa</string> <string name="combined_search_results_on">Mostrar todos os resultados numa única lista</string> <string name="combined_search_results_off">Mostrar resultados por categoria</string> @@ -669,4 +671,56 @@ <item quantity="other">Pessoas</item> </plurals> <string name="featurettes_title">Videos promocionais</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Definições do perfil do utilizador a partir do servidor</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilize o AC3 para áudio surround</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries TV</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Passa raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Estado da produção</string> + <string name="different_for_hdr">Utilizar um estilo diferente para HDR</string> + <string name="experimental_settings">Definições experimentais</string> + <string name="confirm_enable_experimental_title">Ativar definições experimentais?</string> + <string name="confirm_enable_experimental_body">As definições experimentais podem ser instáveis!\n\nPodem ser removidas ou alteradas em qualquer atualização.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="blocklisted">Lista de bloqueio</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legendas</string> + <string name="use_user_profile">Utilize as definições do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Apenas forçado</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhum</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma de áudio preferido</string> + <string name="override_user_profile_settings">Mais definições do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="brown">Castanho</string> + <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> + <string name="user_views">Visualizações do utilizador</string> + <string name="books">Livros</string> + <string name="audio_books">Audiolivros</string> + <string name="server_version_not_supported">Versão do servidor não suportada</string> </resources> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1c93f25..e83ae0a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,9 +111,6 @@ <string name="video_scale">Масштаб видео</string> <string name="video">Видео</string> <string name="videos">Видео</string> - <plurals name="years_old"> - <item quantity="other">%1$d возраст</item> - </plurals> <string name="play_with_transcoding">Воспроизвести с перекодированием</string> <string name="show_clock">Показывать часы</string> <string name="create_playlist">Создать новый плейлист</string> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 1fa31795..999c4e51 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -168,7 +168,10 @@ <string name="videos">Videá</string> <string name="watch_live">Sledovať naživo</string> <plurals name="years_old"> - <item quantity="other">%1$d rokov</item> + <item quantity="one">vo veku %1$d roka</item> + <item quantity="few">vo veku %1$d rokov</item> + <item quantity="many">vo veku %1$d rokov</item> + <item quantity="other">vo veku %1$d rokov</item> </plurals> <string name="play_with_transcoding">Prehrávať s prekódovaním</string> <string name="media_information">Informácie o médiu</string> @@ -318,7 +321,7 @@ </plurals> <string name="auto_play_next_delay">Oneskorenie pred prehrávaním nasledujúceho</string> <string name="auto_play_next">Automatické prehrávanie nasledujúceho</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Spojiť sekcie Pokračovať v pozeraní a Nasleduje]]></string> <string name="playback_overrides">Prepísanie prehrávania</string> <string name="rewatch_next_up">Povoliť opätovné prehrávanie v nasledujúcich</string> <string name="skip_back_on_resume_preference">Pri obnovení prehrávania preskočiť späť</string> @@ -636,7 +639,7 @@ <item quantity="other">Osôb</item> </plurals> <string name="require_login">Vyžadovať prihlásenie pre profil</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Kombinované výsledky vyhľadávania</string> <string name="combined_search_results_on">Zobraziť všetky výsledky v jednom zozname</string> <string name="combined_search_results_off">Zobraziť výsledky podľa kategórie</string> @@ -702,4 +705,40 @@ <string name="confirm_enable_experimental_title">Povoliť experimentálne nastavenia?</string> <string name="confirm_enable_experimental_body">Experimentálne nastavenia môžu byť nestabilné!\n\n Môžu byť zmenené alebo odstránené v budúcich verziách.</string> <string name="video_tunneling">Video tunneling</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">zostáva %1$s</string> + <string name="blocklisted">Blokované</string> + <string name="unavailable">Nedostupné</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> + <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> + <string name="subtitle_mode">Režim titulkov</string> + <string name="use_user_profile">Použiť nastavenie profilu</string> + <string name="subtitle_mode_default">Predvolené</string> + <string name="subtitle_mode_smart">Inteligentné</string> + <string name="subtitle_mode_only_forced">Len vynútené</string> + <string name="subtitle_mode_always">Vždy</string> + <string name="subtitle_mode_none">Žiadne</string> + <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> + <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> + <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> + <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> + <string name="any_language">Akýkoľvek jazyk</string> + <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> + <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> + <string name="hash_matches">Hash zhody</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programy</string> + <string name="recordings">Nahrávky</string> + <string name="folders_title">Priečinky</string> + <string name="user_views">Používateľské zobrazenia</string> + <string name="books">Knihy</string> + <string name="audio_books">Audioknihy</string> + <string name="brown">Hnedá</string> </resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a33ef920..93c86111 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -91,6 +91,7 @@ <string name="videos">Videor</string> <string name="watch_live">Titta live</string> <plurals name="years_old"> + <item quantity="one">%1$d år gammal</item> <item quantity="other">%1$d år gammal</item> </plurals> <string name="play_with_transcoding">Spela med transkodning</string> @@ -179,7 +180,7 @@ <string name="disabled">Inaktiverad</string> <string name="discovered_servers">Upptäckta servrar</string> <string name="enabled">Aktiverad</string> - <string name="enter_server_url">Ange serverns IP eller URL inklusive port</string> + <string name="enter_server_url">Ange serverns IP eller URL</string> <string name="error_loading_collection">Fel vid inläsning av samling %1$s</string> <string name="external_track">Extern</string> <string name="forced_track">Tvingad</string> @@ -550,4 +551,75 @@ <string name="cinema_mode">Bioläge</string> <string name="suggestions_for">Förslag för %1$s</string> <string name="system_default">Systemstandard</string> + <string name="playback_overrides">Överskrid uppspelning</string> + <string name="processing">Processering</string> + <string name="voice_error_audio">Fel på ljudinspelning</string> + <string name="voice_error_client">Fel på röstigenkänning</string> + <string name="voice_error_permissions">Mikrofonbehörighet krävs</string> + <string name="media_information">Information om Media</string> + <string name="other_extras_title">Annat</string> + <string name="behind_the_scenes_title">Bakom kulisserna</string> + <string name="theme_songs_title">Temasånger</string> + <string name="theme_videos_title">Temavideor</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Borttagna scener</string> + <string name="interviews_title">Intervjuer</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Extramaterial</string> + <string name="shorts_title">Korta klipp</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">FIlmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV Serie</item> + <item quantity="other">TV Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="incorrect">Felaktigt</string> + <string name="title">Titel</string> + <string name="trending">Trendande</string> + <string name="stop_slideshow">Stoppa bildspelet</string> + <string name="slideshow_at_beginning">I början</string> + <string name="no_more_images">Inga fler bilder</string> + <string name="go_to_artist">Gå till artist</string> + <string name="load_from_web_client">Ladda från webbklient</string> + <string name="for_episodes">För episoder</string> + <string name="increase_all_cards_size">Öka storleken för alla kort</string> + <string name="decrease_all_cards_size">Minska storleken för alla kort</string> + <string name="animate">Animera</string> + <string name="max_age_rating">Maximal åldersgräns</string> + <string name="include_types_summary">Vilka typer av objekt ska bilder visas för</string> + <string name="song_has_lyrics">Låten har text</string> + <string name="show_album_cover">Visa omslag till album</string> + <string name="updated_toast">Wholphin har uppdaterats till %s</string> + <string name="last_played">Senast visat</string> + <string name="root_folder">Huvudkatalog</string> + <string name="popularity">Populäritet</string> + <string name="production_status">Produktionsstatus</string> + <string name="experimental_settings">Experimentella inställningar</string> + <string name="confirm_enable_experimental_title">Slå på experimentella inställningar?</string> + <string name="video_tunneling">Videotunnling</string> + <string name="blocklisted">Svartlistad</string> + <string name="unavailable">Otillgänglig</string> + <string name="books">Böcker</string> + <string name="audio_books">Ljudböcker</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s kvar</string> + <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> + <string name="interlaced">Sammanflätad</string> + <string name="level">Nivå</string> + <string name="mpv_conf">Redigera mpv.conf</string> + <string name="profile_specific_settings_from_server">Profilinställningar från server</string> + <string name="no_max">Ingen övre gräns</string> + <string name="include_types">Inkludera typer</string> + <string name="load_from_server">Ladda från serverns användarprofil</string> </resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1ef29efc..0216e5a7 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -170,6 +170,7 @@ <string name="videos">Videolar</string> <string name="watch_live">Canlı izle</string> <plurals name="years_old"> + <item quantity="one">%1$d yaşında</item> <item quantity="other">%1$d yaşında</item> </plurals> <string name="play_with_transcoding">Kod dönüştürerek oynat</string> @@ -667,4 +668,38 @@ <string name="duration_minutes">%1$ddk</string> <string name="duration_seconds">%1$dsn</string> <string name="time_left">%1$s kaldı</string> + <string name="blocklisted">Engellenenler</string> + <string name="unavailable">Mevcut Değil</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> + <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> + <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> + <string name="subtitle_mode">Altyazı Modu</string> + <string name="use_user_profile">Profil ayarlarını kullan</string> + <string name="subtitle_mode_default">Varsayılan</string> + <string name="subtitle_mode_smart">Akıllı</string> + <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> + <string name="subtitle_mode_always">Her zaman</string> + <string name="subtitle_mode_none">Hiçbiri</string> + <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> + <string name="preferred_audio_language">Tercih edilen ses dili</string> + <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> + <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> + <string name="any_language">Herhangi bir dil</string> + <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> + <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="hash_matches">Hash Eşleşmeleri</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programları</string> + <string name="recordings">Kayıtlar</string> + <string name="folders_title">Klasörler</string> + <string name="user_views">Kullanıcı Görünümleri</string> + <string name="books">Kitaplar</string> + <string name="audio_books">Sesli Kitaplar</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> + <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> + <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> + <string name="brown">Kahverengi</string> + <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> </resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ccbb47a4..6b518809 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -177,9 +177,6 @@ <string name="video">Відео</string> <string name="videos">Відоси</string> <string name="watch_live">Дивитись онлайн</string> - <plurals name="years_old"> - <item quantity="other">%1$d років</item> - </plurals> <string name="play_with_transcoding">Відтворити з перекодуванням</string> <string name="media_information">Інформація</string> <string name="show_clock">Показати годиник</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3c1a4bd8..f7d39669 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -583,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">转码</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">综合搜索结果</string> <string name="combined_search_results_on">在单个列表中显示所有结果</string> <string name="combined_search_results_off">按类别显示结果</string> @@ -646,4 +646,39 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">还剩%1$s</string> + <string name="blocklisted">被列入黑名单</string> + <string name="unavailable">不可用</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> + <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用您的配置设置</string> + <string name="subtitle_mode_default">默认</string> + <string name="subtitle_mode_smart">智能</string> + <string name="subtitle_mode_only_forced">仅强制</string> + <string name="subtitle_mode_always">始终</string> + <string name="subtitle_mode_none">无</string> + <string name="preferred_subtitle_language">首选字幕语言</string> + <string name="preferred_audio_language">首选音频语言</string> + <string name="override_user_profile_settings">更多用户配置设置</string> + <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> + <string name="any_language">任何语言</string> + <string name="d_pad_seek_mode_title">方向键进度条模式</string> + <string name="dpad_seek_mode_skip_time">跳过时间</string> + <string name="dpad_seek_mode_trickplay">特技播放</string> + <string name="hash_matches">哈希匹配</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">电视节目</string> + <string name="recordings">录制内容</string> + <string name="folders_title">文件夹</string> + <string name="user_views">用户视图</string> + <string name="books">书籍</string> + <string name="audio_books">有声读物</string> + <string name="brown">棕色</string> + <string name="dpad_seek_mode_minimal">极简</string> + <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> + <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> + <string name="dpad_seek_mode_none_summary">无进度条</string> + <string name="server_version_not_supported">服务器版本不支持</string> + <string name="appears_on">出现在</string> </resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2dbaa495..1bf1f897 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -583,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">轉碼</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">合併搜尋結果</string> <string name="combined_search_results_on">在單一列表中顯示所有結果</string> <string name="combined_search_results_off">按類別顯示結果</string> @@ -639,11 +639,43 @@ <string name="experimental_settings">實驗性設定</string> <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> - <string name="duration_hours_minutes_seconds">%1$d小時 %2$d分 %3$d秒</string> - <string name="duration_hours_minutes">%1$d小時 %2$d分</string> - <string name="duration_minutes_seconds">%1$d分 %2$d秒</string> + <string name="duration_hours_minutes_seconds">%1$d小時%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小時%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> <string name="duration_hours">%1$d小時</string> <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">還剩 %1$s</string> + <string name="blocklisted">被列入黑名單</string> + <string name="unavailable">無法請求</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> + <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用伺服器設定</string> + <string name="subtitle_mode_default">預設</string> + <string name="subtitle_mode_smart">智慧模式</string> + <string name="subtitle_mode_only_forced">僅強制字幕</string> + <string name="subtitle_mode_always">總是顯示</string> + <string name="subtitle_mode_none">不顯示</string> + <string name="preferred_subtitle_language">偏好字幕語言</string> + <string name="preferred_audio_language">偏好音軌語言</string> + <string name="override_user_profile_settings">更多個人化設定</string> + <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> + <string name="any_language">任何語言</string> + <string name="brown">棕色</string> + <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> + <string name="dpad_seek_mode_minimal">精簡</string> + <string name="dpad_seek_mode_trickplay">縮圖預覽</string> + <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> + <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> + <string name="dpad_seek_mode_none_summary">不顯示進度條</string> + <string name="hash_matches">雜湊值相符</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">電視劇</string> + <string name="recordings">錄影節目</string> + <string name="folders_title">資料夾</string> + <string name="books">書籍</string> + <string name="audio_books">有聲書</string> + <string name="server_version_not_supported">不支援此伺服器版本</string> </resources> From a1082bd732f703f275a8a5765c3b7341d958816b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 16:26:38 -0400 Subject: [PATCH 256/475] Release v1.0.4 From ad7b53ec1584cdcf9c67670303d523cd8de134f3 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 16:57:38 -0400 Subject: [PATCH 257/475] Fix ExtraTranslation vital lint errors --- app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-sk/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - 5 files changed, 5 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0ce7f5dc..b764f319 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -693,7 +693,6 @@ <string name="books">Bücher</string> <string name="audio_books">Hörbücher</string> <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> - <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> <string name="user_views">Benutzeransicht</string> </resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a231db57..4737ad1c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -706,7 +706,6 @@ <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> <string name="any_language">Cualquier idioma</string> <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> - <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> <string name="dpad_seek_mode_trickplay">Fotograma</string> <string name="hash_matches">Coincidencias de valores hash</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 999c4e51..27aa6dda 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -730,7 +730,6 @@ <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> - <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> <string name="hash_matches">Hash zhody</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0216e5a7..133ba3b1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -686,7 +686,6 @@ <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> - <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash Eşleşmeleri</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f7d39669..30ea7470 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -664,7 +664,6 @@ <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> <string name="d_pad_seek_mode_title">方向键进度条模式</string> - <string name="dpad_seek_mode_skip_time">跳过时间</string> <string name="dpad_seek_mode_trickplay">特技播放</string> <string name="hash_matches">哈希匹配</string> <string name="subtitles_hearing_impaired">SDH</string> From b98dab8945d0a337b3fafbe0552465ce87340855 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Wed, 29 Jul 2026 15:19:28 +0000 Subject: [PATCH 258/475] Translated using Weblate (Italian) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 64160937..38e47565 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -723,4 +723,5 @@ <string name="user_views">Viste utente</string> <string name="audio_books">Audiolibri</string> <string name="server_version_not_supported">Versione del server non supportata</string> + <string name="appears_on">Appare in</string> </resources> From 7cc7ecb61ef5f3fc90bdd3df015e3b65a33b516a Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 29 Jul 2026 16:58:19 -0400 Subject: [PATCH 259/475] Release v1.0.4 From 8747dd30b7f9da76b899e8ac60981fdc30e7541d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Wed, 8 Jul 2026 16:49:27 +0000 Subject: [PATCH 260/475] Translated using Weblate (Estonian) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e72bc82f..9d15e7f5 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -670,27 +670,4 @@ <string name="time_left">jäänud %1$s</string> <string name="blocklisted">Keeluloendis</string> <string name="unavailable">Pole saadaval</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> - <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> - <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> - <string name="subtitle_mode">Subtiitrite režiim</string> - <string name="use_user_profile">Kasuta oma profiili seadistust</string> - <string name="subtitle_mode_default">Vaikimisi</string> - <string name="subtitle_mode_smart">Nutikas</string> - <string name="subtitle_mode_only_forced">Ainult sundkorras</string> - <string name="subtitle_mode_always">Alati</string> - <string name="subtitle_mode_none">Määramata</string> - <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> - <string name="preferred_audio_language">Heliriba eelistatud keel</string> - <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> - <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> - <string name="any_language">Iga keel</string> - <string name="brown">Pruun</string> - <string name="programs">Teleprogrammid</string> - <string name="recordings">Salvestused</string> - <string name="folders_title">Kaustad</string> - <string name="user_views">Kasutajate vaatamisi</string> - <string name="books">Raamatud</string> - <string name="audio_books">Heliraamatud</string> - <string name="server_version_not_supported">Serveri versioon pole toetatud</string> </resources> From c03abe45114a836381daef93155cef17ad7e5126 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 7 Jul 2026 23:40:20 +0000 Subject: [PATCH 261/475] Translated using Weblate (Slovak) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 27aa6dda..ac7e0bfb 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -714,30 +714,4 @@ <string name="time_left">zostáva %1$s</string> <string name="blocklisted">Blokované</string> <string name="unavailable">Nedostupné</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> - <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> - <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> - <string name="subtitle_mode">Režim titulkov</string> - <string name="use_user_profile">Použiť nastavenie profilu</string> - <string name="subtitle_mode_default">Predvolené</string> - <string name="subtitle_mode_smart">Inteligentné</string> - <string name="subtitle_mode_only_forced">Len vynútené</string> - <string name="subtitle_mode_always">Vždy</string> - <string name="subtitle_mode_none">Žiadne</string> - <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> - <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> - <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> - <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> - <string name="any_language">Akýkoľvek jazyk</string> - <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> - <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> - <string name="hash_matches">Hash zhody</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">TV programy</string> - <string name="recordings">Nahrávky</string> - <string name="folders_title">Priečinky</string> - <string name="user_views">Používateľské zobrazenia</string> - <string name="books">Knihy</string> - <string name="audio_books">Audioknihy</string> - <string name="brown">Hnedá</string> </resources> From b86e454db62dc7ec9f76681ffa87c1a690a85d58 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 8 Jul 2026 11:41:42 +0000 Subject: [PATCH 262/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 32 +--------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1bf1f897..ee6738df 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -646,36 +646,6 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">還剩 %1$s</string> - <string name="blocklisted">被列入黑名單</string> + <string name="blocklisted">已列入黑名單</string> <string name="unavailable">無法請求</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> - <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> - <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> - <string name="subtitle_mode">字幕模式</string> - <string name="use_user_profile">使用伺服器設定</string> - <string name="subtitle_mode_default">預設</string> - <string name="subtitle_mode_smart">智慧模式</string> - <string name="subtitle_mode_only_forced">僅強制字幕</string> - <string name="subtitle_mode_always">總是顯示</string> - <string name="subtitle_mode_none">不顯示</string> - <string name="preferred_subtitle_language">偏好字幕語言</string> - <string name="preferred_audio_language">偏好音軌語言</string> - <string name="override_user_profile_settings">更多個人化設定</string> - <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> - <string name="any_language">任何語言</string> - <string name="brown">棕色</string> - <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> - <string name="dpad_seek_mode_minimal">精簡</string> - <string name="dpad_seek_mode_trickplay">縮圖預覽</string> - <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> - <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> - <string name="dpad_seek_mode_none_summary">不顯示進度條</string> - <string name="hash_matches">雜湊值相符</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">電視劇</string> - <string name="recordings">錄影節目</string> - <string name="folders_title">資料夾</string> - <string name="books">書籍</string> - <string name="audio_books">有聲書</string> - <string name="server_version_not_supported">不支援此伺服器版本</string> </resources> From 48455bc13f599874288fe6ecc2c888359790a96b Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Wed, 8 Jul 2026 05:55:57 +0000 Subject: [PATCH 263/475] Translated using Weblate (Polish) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 37 -------------------------- 1 file changed, 37 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b2d934ce..4a5d3dc7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -708,41 +708,4 @@ <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> <string name="blocklisted">Na czarnej liście</string> <string name="unavailable">Niedostępne</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> - <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> - <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> - <plurals name="years_old"> - <item quantity="one">%1$d rok</item> - <item quantity="few">%1$d lata</item> - <item quantity="many">%1$d lat</item> - <item quantity="other">%1$d lat</item> - </plurals> - <string name="subtitle_mode">Tryb napisów</string> - <string name="use_user_profile">Użyj ustawień użytkownika</string> - <string name="subtitle_mode_default">Domyślny</string> - <string name="subtitle_mode_smart">Inteligentny</string> - <string name="subtitle_mode_only_forced">Tylko wymuszone</string> - <string name="subtitle_mode_always">Wszystkie</string> - <string name="subtitle_mode_none">Brak</string> - <string name="preferred_subtitle_language">Preferowany język napisów</string> - <string name="preferred_audio_language">Preferowany język dźwięku</string> - <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> - <string name="any_language">Dowolny język</string> - <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> - <string name="brown">Brązowy</string> - <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> - <string name="dpad_seek_mode_minimal">Minimalistyczny</string> - <string name="dpad_seek_mode_trickplay">Podgląd scen</string> - <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> - <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> - <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> - <string name="hash_matches">Dopasowania hashy</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">Programy TV</string> - <string name="recordings">Nagrania</string> - <string name="folders_title">Foldery</string> - <string name="user_views">Widoki użytkownika</string> - <string name="books">Książki</string> - <string name="audio_books">Audiobooki</string> - <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> </resources> From 12640a9fd1c261cad30c6d1639113e28d0e45216 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Tue, 7 Jul 2026 18:58:06 +0000 Subject: [PATCH 264/475] Translated using Weblate (Turkish) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 31 -------------------------- 1 file changed, 31 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 133ba3b1..a646cb27 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -670,35 +670,4 @@ <string name="time_left">%1$s kaldı</string> <string name="blocklisted">Engellenenler</string> <string name="unavailable">Mevcut Değil</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> - <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> - <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> - <string name="subtitle_mode">Altyazı Modu</string> - <string name="use_user_profile">Profil ayarlarını kullan</string> - <string name="subtitle_mode_default">Varsayılan</string> - <string name="subtitle_mode_smart">Akıllı</string> - <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> - <string name="subtitle_mode_always">Her zaman</string> - <string name="subtitle_mode_none">Hiçbiri</string> - <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> - <string name="preferred_audio_language">Tercih edilen ses dili</string> - <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> - <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> - <string name="any_language">Herhangi bir dil</string> - <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> - <string name="dpad_seek_mode_trickplay">Trickplay</string> - <string name="hash_matches">Hash Eşleşmeleri</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">TV Programları</string> - <string name="recordings">Kayıtlar</string> - <string name="folders_title">Klasörler</string> - <string name="user_views">Kullanıcı Görünümleri</string> - <string name="books">Kitaplar</string> - <string name="audio_books">Sesli Kitaplar</string> - <string name="dpad_seek_mode_minimal">Minimal</string> - <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> - <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> - <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> - <string name="brown">Kahverengi</string> - <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> </resources> From 7e837b4d455d39b47237b9bd63629ad741c0c4ff Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Thu, 9 Jul 2026 09:37:17 +0000 Subject: [PATCH 265/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (610 of 610 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 32 ---------------------- 1 file changed, 32 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 30ea7470..eb6e33d3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -648,36 +648,4 @@ <string name="time_left">还剩%1$s</string> <string name="blocklisted">被列入黑名单</string> <string name="unavailable">不可用</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> - <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> - <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> - <string name="subtitle_mode">字幕模式</string> - <string name="use_user_profile">使用您的配置设置</string> - <string name="subtitle_mode_default">默认</string> - <string name="subtitle_mode_smart">智能</string> - <string name="subtitle_mode_only_forced">仅强制</string> - <string name="subtitle_mode_always">始终</string> - <string name="subtitle_mode_none">无</string> - <string name="preferred_subtitle_language">首选字幕语言</string> - <string name="preferred_audio_language">首选音频语言</string> - <string name="override_user_profile_settings">更多用户配置设置</string> - <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> - <string name="any_language">任何语言</string> - <string name="d_pad_seek_mode_title">方向键进度条模式</string> - <string name="dpad_seek_mode_trickplay">特技播放</string> - <string name="hash_matches">哈希匹配</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">电视节目</string> - <string name="recordings">录制内容</string> - <string name="folders_title">文件夹</string> - <string name="user_views">用户视图</string> - <string name="books">书籍</string> - <string name="audio_books">有声读物</string> - <string name="brown">棕色</string> - <string name="dpad_seek_mode_minimal">极简</string> - <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> - <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> - <string name="dpad_seek_mode_none_summary">无进度条</string> - <string name="server_version_not_supported">服务器版本不支持</string> - <string name="appears_on">出现在</string> </resources> From 8ac955c44d2a544d1ae6901ee58609a2cd32ff3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Fri, 10 Jul 2026 12:27:04 +0000 Subject: [PATCH 266/475] Translated using Weblate (Estonian) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9d15e7f5..9065f54e 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -670,4 +670,6 @@ <string name="time_left">jäänud %1$s</string> <string name="blocklisted">Keeluloendis</string> <string name="unavailable">Pole saadaval</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> + <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> </resources> From ca27e94d33f70658c523a8918e43ad3e84d1f949 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Fri, 10 Jul 2026 07:19:15 +0000 Subject: [PATCH 267/475] Translated using Weblate (Slovak) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ac7e0bfb..e799058b 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -714,4 +714,6 @@ <string name="time_left">zostáva %1$s</string> <string name="blocklisted">Blokované</string> <string name="unavailable">Nedostupné</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> </resources> From 76b5aa12ab925c71221842eb22d4a45b51738456 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 10 Jul 2026 04:28:18 +0000 Subject: [PATCH 268/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ee6738df..ec46930c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -646,6 +646,8 @@ <string name="duration_minutes">%1$d分</string> <string name="duration_seconds">%1$d秒</string> <string name="time_left">還剩 %1$s</string> - <string name="blocklisted">已列入黑名單</string> + <string name="blocklisted">被列入黑名單</string> <string name="unavailable">無法請求</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> </resources> From 0d2524b359f0c056d2484c579baf1feb615ec73b Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Fri, 10 Jul 2026 16:27:42 +0000 Subject: [PATCH 269/475] Translated using Weblate (Italian) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 29 -------------------------- 1 file changed, 29 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 64160937..ef935957 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -694,33 +694,4 @@ <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> <string name="blocklisted">Bloccati</string> <string name="unavailable">Non disponibile</string> - <string name="subtitle_mode_default">Predefinito</string> - <string name="subtitle_mode_none">Nessuno</string> - <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> - <string name="subtitle_mode">Modalità sottotitoli</string> - <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> - <string name="subtitle_mode_smart">Intelligente</string> - <string name="subtitle_mode_only_forced">Solo forzati</string> - <string name="subtitle_mode_always">Sempre</string> - <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> - <string name="preferred_audio_language">Lingua preferita per l\'audio</string> - <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> - <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> - <string name="any_language">Qualsiasi lingua</string> - <string name="brown">Marrone</string> - <string name="dpad_seek_mode_trickplay">Trickplay</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">Programmi TV</string> - <string name="recordings">Registrazioni</string> - <string name="folders_title">Cartelle</string> - <string name="books">Libri</string> - <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> - <string name="dpad_seek_mode_minimal">Minimale</string> - <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> - <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> - <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> - <string name="hash_matches">Corrispondenze hash</string> - <string name="user_views">Viste utente</string> - <string name="audio_books">Audiolibri</string> - <string name="server_version_not_supported">Versione del server non supportata</string> </resources> From ad241ca7e80ba83ead507e65be6dba934e9f2bbb Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Fri, 10 Jul 2026 14:43:09 +0000 Subject: [PATCH 270/475] Translated using Weblate (Polish) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4a5d3dc7..7854f3f5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -708,4 +708,6 @@ <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> <string name="blocklisted">Na czarnej liście</string> <string name="unavailable">Niedostępne</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> </resources> From d61cba41011bd1f505df10855fc3f3a54a7dddd0 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 10 Jul 2026 01:15:56 +0000 Subject: [PATCH 271/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (612 of 612 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 eb6e33d3..5bf620c3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -648,4 +648,6 @@ <string name="time_left">还剩%1$s</string> <string name="blocklisted">被列入黑名单</string> <string name="unavailable">不可用</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> </resources> From e539551e70c92bd4ffc2fd3af42172d29c0b3cdc Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sat, 11 Jul 2026 04:16:40 +0000 Subject: [PATCH 272/475] Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b764f319..8c124726 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -669,30 +669,7 @@ <string name="time_left">%1$s verbleibend</string> <string name="video_tunneling">Video Tunneling</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> - <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound Audio nutzen</string> <string name="blocklisted">Auf Blockliste hinzugefügt</string> <string name="unavailable">Nicht verfügbar</string> - <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> - <string name="subtitle_mode">Modus Untertitel</string> - <string name="use_user_profile">Nutze deine Profileinstellungen</string> - <string name="subtitle_mode_default">Standard</string> - <string name="subtitle_mode_smart">Intelligent</string> - <string name="subtitle_mode_only_forced">Nur Forced</string> - <string name="subtitle_mode_always">Immer</string> - <string name="subtitle_mode_none">Keine</string> - <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> - <string name="preferred_audio_language">Bevorzugte Audiosprache</string> - <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> - <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> - <string name="any_language">Beliebige Sprache</string> - <string name="hash_matches">Hash Übereinstimmungen</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">TV Programme</string> - <string name="recordings">Aufnahmen</string> - <string name="folders_title">Ordner</string> - <string name="books">Bücher</string> - <string name="audio_books">Hörbücher</string> - <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> - <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> - <string name="user_views">Benutzeransicht</string> </resources> From 964b3b5068587cb86ecd121b78736eef944c671f Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Fri, 10 Jul 2026 22:10:36 +0000 Subject: [PATCH 273/475] Translated using Weblate (Spanish) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 29 -------------------------- 1 file changed, 29 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4737ad1c..e0619ffc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -692,33 +692,4 @@ <string name="video_tunneling">Tunelización de video</string> <string name="blocklisted">Bloqueado</string> <string name="unavailable">No disponible</string> - <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> - <string name="subtitle_mode">Modo de subtítulos</string> - <string name="use_user_profile">Usar tu configuración de usuario</string> - <string name="subtitle_mode_default">Predeterminado</string> - <string name="subtitle_mode_smart">Inteligente</string> - <string name="subtitle_mode_only_forced">Solo forzados</string> - <string name="subtitle_mode_always">Siempre</string> - <string name="subtitle_mode_none">Ninguno</string> - <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> - <string name="preferred_audio_language">Idioma de audio preferido</string> - <string name="override_user_profile_settings">Más ajustes de usuario</string> - <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> - <string name="any_language">Cualquier idioma</string> - <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> - <string name="dpad_seek_mode_trickplay">Fotograma</string> - <string name="hash_matches">Coincidencias de valores hash</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">Programas de televisión</string> - <string name="recordings">Grabaciones</string> - <string name="folders_title">Carpetas</string> - <string name="user_views">Vistas de usuario</string> - <string name="books">Libros</string> - <string name="audio_books">Audiolibros</string> - <string name="dpad_seek_mode_minimal">Mínima</string> - <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> - <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> - <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> - <string name="brown">Marrón</string> - <string name="server_version_not_supported">Versión de servidor no soportada</string> </resources> From 912551e74bec3451e52f2b13cb96b3520eebcf33 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Sat, 11 Jul 2026 16:37:58 +0000 Subject: [PATCH 274/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 29 -------------------------- 1 file changed, 29 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index a098fbf1..6385dfe6 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -694,33 +694,4 @@ <string name="unavailable">לא זמין</string> <string name="blocklisted">ברשימה חסומה</string> <string name="video_tunneling">מנהור וידאו</string> - <string name="brown">חום</string> - <string name="dpad_seek_mode_minimal">מינימלי</string> - <string name="dpad_seek_mode_trickplay">Trickplay</string> - <string name="subtitle_mode">מצב כתוביות</string> - <string name="subtitle_mode_default">ברירת מחדל</string> - <string name="subtitle_mode_smart">חכם</string> - <string name="subtitle_mode_only_forced">כפויות בלבד</string> - <string name="subtitle_mode_always">תמיד</string> - <string name="subtitle_mode_none">ללא</string> - <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> - <string name="preferred_audio_language">שפה מועדפת לשמע</string> - <string name="programs">תוכניות טלוויזיה</string> - <string name="recordings">הקלטות</string> - <string name="folders_title">תיקיות</string> - <string name="user_views">צפיות משתמש</string> - <string name="books">ספרים</string> - <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> - <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> - <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> - <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> - <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> - <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> - <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> - <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> - <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> - <string name="any_language">כל שפה</string> - <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> - <string name="audio_books">ספרי שמע</string> - <string name="hash_matches">התאמות של פונקציית־גיבוב</string> </resources> From 28b909ed7922446a14b0fd1fd58ca442fd729754 Mon Sep 17 00:00:00 2001 From: streamyfinchris <streamyfinchris@noreply.codeberg.org> Date: Sun, 12 Jul 2026 00:27:11 +0000 Subject: [PATCH 275/475] Translated using Weblate (Swedish) Currently translated at 86.6% (530 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 93c86111..d0594c21 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -586,7 +586,7 @@ <string name="slideshow_at_beginning">I början</string> <string name="no_more_images">Inga fler bilder</string> <string name="go_to_artist">Gå till artist</string> - <string name="load_from_web_client">Ladda från webbklient</string> + <string name="load_from_web_client">Ladda från web client</string> <string name="for_episodes">För episoder</string> <string name="increase_all_cards_size">Öka storleken för alla kort</string> <string name="decrease_all_cards_size">Minska storleken för alla kort</string> @@ -605,21 +605,4 @@ <string name="video_tunneling">Videotunnling</string> <string name="blocklisted">Svartlistad</string> <string name="unavailable">Otillgänglig</string> - <string name="books">Böcker</string> - <string name="audio_books">Ljudböcker</string> - <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> - <string name="duration_hours_minutes">%1$dt %2$dm</string> - <string name="duration_minutes_seconds">%1$dm %2$ds</string> - <string name="duration_hours">%1$dt</string> - <string name="duration_minutes">%1$dm</string> - <string name="duration_seconds">%1$ds</string> - <string name="time_left">%1$s kvar</string> - <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> - <string name="interlaced">Sammanflätad</string> - <string name="level">Nivå</string> - <string name="mpv_conf">Redigera mpv.conf</string> - <string name="profile_specific_settings_from_server">Profilinställningar från server</string> - <string name="no_max">Ingen övre gräns</string> - <string name="include_types">Inkludera typer</string> - <string name="load_from_server">Ladda från serverns användarprofil</string> </resources> From 5eddaacf4eb6e2bdabfc646801896e651e188de5 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sun, 12 Jul 2026 22:00:30 +0000 Subject: [PATCH 276/475] Translated using Weblate (Turkish) Currently translated at 100.0% (612 of 612 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 a646cb27..adfffa63 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -670,4 +670,6 @@ <string name="time_left">%1$s kaldı</string> <string name="blocklisted">Engellenenler</string> <string name="unavailable">Mevcut Değil</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> + <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> </resources> From 28cf4c644348c01c689c48de577faf6dbc574bdd Mon Sep 17 00:00:00 2001 From: viretius <viretius@noreply.codeberg.org> Date: Mon, 13 Jul 2026 05:33:05 +0000 Subject: [PATCH 277/475] Translated using Weblate (German) Currently translated at 100.0% (612 of 612 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 8c124726..b5f92e63 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -669,7 +669,7 @@ <string name="time_left">%1$s verbleibend</string> <string name="video_tunneling">Video Tunneling</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> - <string name="prefer_ac3_for_surround">AC3 für Surround Sound Audio nutzen</string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> <string name="blocklisted">Auf Blockliste hinzugefügt</string> <string name="unavailable">Nicht verfügbar</string> </resources> From f80bfdc886e57dbea628558e95786a591cdc9201 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Tue, 14 Jul 2026 01:43:23 +0000 Subject: [PATCH 278/475] Translated using Weblate (Indonesian) Currently translated at 99.8% (611 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index fbd7ed5d..723ba9f8 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -649,19 +649,4 @@ <string name="time_left">Sisa %1$s</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> <string name="unavailable">Tidak tersedia</string> - <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> - <string name="subtitle_mode">Mode subtitel</string> - <string name="use_user_profile">Gunakan pengaturan profil kamu</string> - <string name="subtitle_mode_default">Default</string> - <string name="subtitle_mode_smart">Pintar</string> - <string name="subtitle_mode_only_forced">Hanya Paksaan</string> - <string name="subtitle_mode_always">Selalu</string> - <string name="subtitle_mode_none">Tidak ada</string> - <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> - <string name="preferred_audio_language">Bahasa audio preferensi</string> - <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> - <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> - <string name="any_language">Semua bahasa</string> - <string name="brown">Cokelat</string> - <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> </resources> From a959049c77170102a7c73ad2c4c5fb3f9488337c Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 13 Jul 2026 10:28:14 +0000 Subject: [PATCH 279/475] Translated using Weblate (French) Currently translated at 100.0% (612 of 612 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 29 -------------------------- 1 file changed, 29 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d129132c..4e8a0782 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -692,33 +692,4 @@ <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> <string name="blocklisted">sur liste noire</string> <string name="unavailable">Indisponible</string> - <string name="subtitle_mode_only_forced">forcés uniquement</string> - <string name="subtitle_mode_none">Aucun</string> - <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> - <string name="preferred_audio_language">Langue audio préférée</string> - <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur provenant du serveur</string> - <string name="subtitle_mode">Mode sous-titres</string> - <string name="use_user_profile">Accédez aux paramètres de votre profil</string> - <string name="subtitle_mode_default">Par défaut</string> - <string name="subtitle_mode_smart">Intelligent</string> - <string name="subtitle_mode_always">Toujours</string> - <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> - <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> - <string name="any_language">N\'importe quelle langue</string> - <string name="brown">Marron</string> - <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> - <string name="dpad_seek_mode_minimal">Minimum</string> - <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> - <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> - <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> - <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> - <string name="hash_matches">Correspondances de hachage</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">Émissions de télévision</string> - <string name="recordings">Enregistrements</string> - <string name="folders_title">Dossiers</string> - <string name="user_views">Avis des utilisateurs</string> - <string name="books">Livres</string> - <string name="audio_books">Livre audio</string> - <string name="server_version_not_supported">Version du serveur non prise en charge</string> </resources> From fbe0458dad0397290d604ec728ca8f7f0895d8ed Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 14 Jul 2026 20:31:28 +0000 Subject: [PATCH 280/475] Translated using Weblate (Spanish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e0619ffc..faff78c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -692,4 +692,17 @@ <string name="video_tunneling">Tunelización de video</string> <string name="blocklisted">Bloqueado</string> <string name="unavailable">No disponible</string> + <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> + <string name="subtitle_mode">Modo de subtítulos</string> + <string name="use_user_profile">Usar tu configuración de usuario</string> + <string name="subtitle_mode_default">Predeterminado</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Solo forzados</string> + <string name="subtitle_mode_always">Siempre</string> + <string name="subtitle_mode_none">Ninguno</string> + <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> + <string name="preferred_audio_language">Idioma de audio preferido</string> + <string name="override_user_profile_settings">Más ajustes de usuario</string> + <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> + <string name="any_language">Cualquier idioma</string> </resources> From 4eba02654195ff4a2e7c2ef308926a4364059ef0 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Wed, 15 Jul 2026 14:43:28 +0000 Subject: [PATCH 281/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1a54331f..34ae2691 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -707,20 +707,4 @@ <string name="override_user_profile_settings">Mais definições do perfil</string> <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> <string name="any_language">Qualquer idioma</string> - <string name="brown">Castanho</string> - <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> - <string name="dpad_seek_mode_minimal">Mínimo</string> - <string name="dpad_seek_mode_trickplay">Fotograma</string> - <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> - <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> - <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> - <string name="hash_matches">Correspondências de hash</string> - <string name="subtitles_hearing_impaired">SDH</string> - <string name="programs">Programas de TV</string> - <string name="recordings">Gravações</string> - <string name="folders_title">Pastas</string> - <string name="user_views">Visualizações do utilizador</string> - <string name="books">Livros</string> - <string name="audio_books">Audiolivros</string> - <string name="server_version_not_supported">Versão do servidor não suportada</string> </resources> From 00d37eb72677b3695b69d19c04042c8e277e9e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Tue, 14 Jul 2026 22:27:47 +0000 Subject: [PATCH 282/475] Translated using Weblate (Estonian) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9065f54e..6a74a29f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -672,4 +672,17 @@ <string name="unavailable">Pole saadaval</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> + <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> + <string name="subtitle_mode">Subtiitrite režiim</string> + <string name="use_user_profile">Kasuta oma profiili seadistust</string> + <string name="subtitle_mode_default">Vaikimisi</string> + <string name="subtitle_mode_smart">Nutikas</string> + <string name="subtitle_mode_only_forced">Ainult sundkorras</string> + <string name="subtitle_mode_always">Alati</string> + <string name="subtitle_mode_none">Määramata</string> + <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> + <string name="preferred_audio_language">Heliriba eelistatud keel</string> + <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> + <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> + <string name="any_language">Iga keel</string> </resources> From 2807c92a350acfba04a796503a5e17f5ed65e645 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 14 Jul 2026 21:04:08 +0000 Subject: [PATCH 283/475] Translated using Weblate (Slovak) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index e799058b..47ec74bc 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -716,4 +716,17 @@ <string name="unavailable">Nedostupné</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> + <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> + <string name="subtitle_mode">Režim titulkov</string> + <string name="use_user_profile">Použiť nastavenie profilu</string> + <string name="subtitle_mode_default">Predvolené</string> + <string name="subtitle_mode_smart">Inteligentné</string> + <string name="subtitle_mode_only_forced">Len vynútené</string> + <string name="subtitle_mode_always">Vždy</string> + <string name="subtitle_mode_none">Žiadne</string> + <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> + <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> + <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> + <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> + <string name="any_language">Akýkoľvek jazyk</string> </resources> From 857d1505eda84cc3522a4f42420d36a38af01aff Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 15 Jul 2026 02:58:56 +0000 Subject: [PATCH 284/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5bf620c3..7b73cb69 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -650,4 +650,17 @@ <string name="unavailable">不可用</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> + <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用您的配置设置</string> + <string name="subtitle_mode_default">默认</string> + <string name="subtitle_mode_smart">智能</string> + <string name="subtitle_mode_only_forced">仅强制</string> + <string name="subtitle_mode_always">始终</string> + <string name="subtitle_mode_none">无</string> + <string name="preferred_subtitle_language">首选字幕语言</string> + <string name="preferred_audio_language">首选音频语言</string> + <string name="override_user_profile_settings">更多用户配置设置</string> + <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> + <string name="any_language">任何语言</string> </resources> From aa5c65b4a26728598132827ccdb1712a6f1c8c9c Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Wed, 15 Jul 2026 11:14:27 +0000 Subject: [PATCH 285/475] Translated using Weblate (Turkish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index adfffa63..dfe62eff 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -672,4 +672,17 @@ <string name="unavailable">Mevcut Değil</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> + <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> + <string name="subtitle_mode">Altyazı Modu</string> + <string name="use_user_profile">Profil ayarlarını kullan</string> + <string name="subtitle_mode_default">Varsayılan</string> + <string name="subtitle_mode_smart">Akıllı</string> + <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> + <string name="subtitle_mode_always">Her zaman</string> + <string name="subtitle_mode_none">Hiçbiri</string> + <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> + <string name="preferred_audio_language">Tercih edilen ses dili</string> + <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> + <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> + <string name="any_language">Herhangi bir dil</string> </resources> From e7efe473c837dc54bcf8931f720e8ec84345caa8 Mon Sep 17 00:00:00 2001 From: damontecres <damontecres@noreply.codeberg.org> Date: Wed, 15 Jul 2026 15:03:03 +0000 Subject: [PATCH 286/475] Added translation using Weblate (English (United Kingdom)) --- app/src/main/res/values-en-rGB/strings.xml | 38 +--------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 417bac6b..55344e51 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -1,39 +1,3 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="add_favorite">Favourite</string> - <string name="favorites">Favourites</string> - <string name="license_info">Licence information</string> - <string name="record_program">Record Programme</string> - <string name="remove_favorite">Unfavourite</string> - <string name="voice_error_no_match">No speech recognised</string> - <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> - <string name="italic_font">Italicise font</string> - <string name="favorite_items_title">Favourite %s</string> - <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> - <string name="skip_commercials_behavior">Skip commercials behaviour</string> - <string name="skip_intro_behavior">Skip intro behaviour</string> - <string name="skip_outro_behavior">Skip outro behaviour</string> - <string name="skip_previews_behavior">Skip previews behaviour</string> - <string name="skip_recap_behavior">Skip recap behaviour</string> - <string name="font_color">Font colour</string> - <string name="edge_color">Edge colour</string> - <string name="background_color">Background colour</string> - <string name="press_enter_to_confirm">Press centre to confirm</string> - <string name="color_space">Colour space</string> - <string name="color_transfer">Colour transfer</string> - <string name="color_primaries">Colour primaries</string> - <string name="favorite_channels_at_beginning">Show favourite channels first</string> - <string name="color_code_programs">Colour-code programmes</string> - <string name="quick_connect_summary">Authorise another device to log in to your account</string> - <string name="quick_connect_success">Device authorised successfully</string> - <string name="contrast">Contrast</string> - <string name="customize_home">Customise home page</string> - <string name="light_gray">Light grey</string> - <string name="dark_gray">Dark grey</string> - <string name="backdrop_style_dynamic">Image with dynamic colour</string> - <string name="show_visualizer">Show visualiser</string> - <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> - <string name="skip_behavior">Skip behaviour</string> - <string name="display_toggles_summary">Customise which to display</string> - <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> -</resources> +</resources> \ No newline at end of file From 327a49f1d727b7b5a07ec0d5fde590408f94cbee Mon Sep 17 00:00:00 2001 From: unikitty <unikitty@noreply.codeberg.org> Date: Wed, 15 Jul 2026 16:01:08 +0000 Subject: [PATCH 287/475] Translated using Weblate (English (United Kingdom)) Currently translated at 6.7% (42 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/en_GB/ --- app/src/main/res/values-en-rGB/strings.xml | 38 +++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 55344e51..417bac6b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -1,3 +1,39 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file + <string name="add_favorite">Favourite</string> + <string name="favorites">Favourites</string> + <string name="license_info">Licence information</string> + <string name="record_program">Record Programme</string> + <string name="remove_favorite">Unfavourite</string> + <string name="voice_error_no_match">No speech recognised</string> + <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> + <string name="italic_font">Italicise font</string> + <string name="favorite_items_title">Favourite %s</string> + <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> + <string name="skip_commercials_behavior">Skip commercials behaviour</string> + <string name="skip_intro_behavior">Skip intro behaviour</string> + <string name="skip_outro_behavior">Skip outro behaviour</string> + <string name="skip_previews_behavior">Skip previews behaviour</string> + <string name="skip_recap_behavior">Skip recap behaviour</string> + <string name="font_color">Font colour</string> + <string name="edge_color">Edge colour</string> + <string name="background_color">Background colour</string> + <string name="press_enter_to_confirm">Press centre to confirm</string> + <string name="color_space">Colour space</string> + <string name="color_transfer">Colour transfer</string> + <string name="color_primaries">Colour primaries</string> + <string name="favorite_channels_at_beginning">Show favourite channels first</string> + <string name="color_code_programs">Colour-code programmes</string> + <string name="quick_connect_summary">Authorise another device to log in to your account</string> + <string name="quick_connect_success">Device authorised successfully</string> + <string name="contrast">Contrast</string> + <string name="customize_home">Customise home page</string> + <string name="light_gray">Light grey</string> + <string name="dark_gray">Dark grey</string> + <string name="backdrop_style_dynamic">Image with dynamic colour</string> + <string name="show_visualizer">Show visualiser</string> + <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> + <string name="skip_behavior">Skip behaviour</string> + <string name="display_toggles_summary">Customise which to display</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> +</resources> From 4d7ac8f7597e94dcbb5376aa48552cdbe91eeffe Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Thu, 16 Jul 2026 02:32:14 +0000 Subject: [PATCH 288/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (625 of 625 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 ec46930c..c7797553 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -650,4 +650,17 @@ <string name="unavailable">無法請求</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> + <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用伺服器設定</string> + <string name="subtitle_mode_default">預設</string> + <string name="subtitle_mode_smart">智慧模式</string> + <string name="subtitle_mode_only_forced">僅強制字幕</string> + <string name="subtitle_mode_always">總是顯示</string> + <string name="subtitle_mode_none">不顯示</string> + <string name="preferred_subtitle_language">偏好字幕語言</string> + <string name="preferred_audio_language">偏好音軌語言</string> + <string name="override_user_profile_settings">更多個人化設定</string> + <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> + <string name="any_language">任何語言</string> </resources> From a335dbcbc5907c24ea56029cf1f8c78140d6dc59 Mon Sep 17 00:00:00 2001 From: outopik <outopik@noreply.codeberg.org> Date: Sat, 18 Jul 2026 20:39:43 +0000 Subject: [PATCH 289/475] Translated using Weblate (French) Currently translated at 98.5% (616 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4e8a0782..d0355a1f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -692,4 +692,8 @@ <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> <string name="blocklisted">sur liste noire</string> <string name="unavailable">Indisponible</string> + <string name="subtitle_mode_only_forced">forcés uniquement</string> + <string name="subtitle_mode_none">Aucun</string> + <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> + <string name="preferred_audio_language">Langue audio préférée</string> </resources> From a6887c0826c17ee9e43c8a13fec03136bbbd8900 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Sun, 19 Jul 2026 00:46:15 +0000 Subject: [PATCH 290/475] Translated using Weblate (Italian) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ef935957..0500e3aa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -694,4 +694,17 @@ <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> <string name="blocklisted">Bloccati</string> <string name="unavailable">Non disponibile</string> + <string name="subtitle_mode_default">Predefinito</string> + <string name="subtitle_mode_none">Nessuno</string> + <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> + <string name="subtitle_mode">Modalità sottotitoli</string> + <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> + <string name="subtitle_mode_smart">Intelligente</string> + <string name="subtitle_mode_only_forced">Solo forzati</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> + <string name="preferred_audio_language">Lingua preferita per l\'audio</string> + <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> + <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> + <string name="any_language">Qualsiasi lingua</string> </resources> From c393bc969741e20974d63894732ede52d8c28d91 Mon Sep 17 00:00:00 2001 From: nofkclue <nofkclue@noreply.codeberg.org> Date: Mon, 20 Jul 2026 13:44:26 +0000 Subject: [PATCH 291/475] Translated using Weblate (German) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b5f92e63..a9ca187a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -672,4 +672,17 @@ <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> <string name="blocklisted">Auf Blockliste hinzugefügt</string> <string name="unavailable">Nicht verfügbar</string> + <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> + <string name="subtitle_mode">Modus Untertitel</string> + <string name="use_user_profile">Nutze deine Profileinstellungen</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_only_forced">Nur Forced</string> + <string name="subtitle_mode_always">Immer</string> + <string name="subtitle_mode_none">Keine</string> + <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> + <string name="preferred_audio_language">Bevorzugte Audiosprache</string> + <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> + <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> + <string name="any_language">Beliebige Sprache</string> </resources> From 43e86e4769c5b1d5e6b6055b91811c81c6732738 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Mon, 20 Jul 2026 06:51:23 +0000 Subject: [PATCH 292/475] Translated using Weblate (Indonesian) Currently translated at 99.8% (624 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 723ba9f8..4635cc76 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -649,4 +649,17 @@ <string name="time_left">Sisa %1$s</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> <string name="unavailable">Tidak tersedia</string> + <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> + <string name="subtitle_mode">Mode subtitel</string> + <string name="use_user_profile">Gunakan pengaturan profil kamu</string> + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Pintar</string> + <string name="subtitle_mode_only_forced">Hanya Paksaan</string> + <string name="subtitle_mode_always">Selalu</string> + <string name="subtitle_mode_none">Tidak ada</string> + <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> + <string name="preferred_audio_language">Bahasa audio preferensi</string> + <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> + <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> + <string name="any_language">Semua bahasa</string> </resources> From 9988974001ecf215435ed8f90b3d54be79e9fe25 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 20 Jul 2026 09:30:08 +0000 Subject: [PATCH 293/475] Translated using Weblate (French) Currently translated at 99.8% (624 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d0355a1f..09d9f248 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -696,4 +696,13 @@ <string name="subtitle_mode_none">Aucun</string> <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> <string name="preferred_audio_language">Langue audio préférée</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur provenant du serveur</string> + <string name="subtitle_mode">Mode sous-titres</string> + <string name="use_user_profile">Accédez aux paramètres de votre profil</string> + <string name="subtitle_mode_default">Par défaut</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_always">Toujours</string> + <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> + <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> + <string name="any_language">N\'importe quelle langue</string> </resources> From 1c3e2697181cfc5fa6c9b371eb112b325d5308c9 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Tue, 21 Jul 2026 10:47:24 +0000 Subject: [PATCH 294/475] Translated using Weblate (Polish) Currently translated at 100.0% (625 of 625 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7854f3f5..e137422b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -710,4 +710,23 @@ <string name="unavailable">Niedostępne</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> + <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> + <plurals name="years_old"> + <item quantity="one">%1$d rok</item> + <item quantity="few">%1$d lata</item> + <item quantity="many">%1$d lat</item> + <item quantity="other">%1$d lat</item> + </plurals> + <string name="subtitle_mode">Tryb napisów</string> + <string name="use_user_profile">Użyj ustawień użytkownika</string> + <string name="subtitle_mode_default">Domyślny</string> + <string name="subtitle_mode_smart">Inteligentny</string> + <string name="subtitle_mode_only_forced">Tylko wymuszone</string> + <string name="subtitle_mode_always">Wszystkie</string> + <string name="subtitle_mode_none">Brak</string> + <string name="preferred_subtitle_language">Preferowany język napisów</string> + <string name="preferred_audio_language">Preferowany język dźwięku</string> + <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> + <string name="any_language">Dowolny język</string> + <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> </resources> From c4ac8b6a65c3663a63af0ad01a14de6973dced50 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Thu, 23 Jul 2026 18:44:21 +0000 Subject: [PATCH 295/475] Translated using Weblate (Swedish) Currently translated at 85.7% (543 of 633 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d0594c21..6987b433 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -605,4 +605,6 @@ <string name="video_tunneling">Videotunnling</string> <string name="blocklisted">Svartlistad</string> <string name="unavailable">Otillgänglig</string> + <string name="books">Böcker</string> + <string name="audio_books">Ljudböcker</string> </resources> From 10a7008ad2c9a1d8f5d474120acab0749144006e Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Thu, 23 Jul 2026 22:18:39 +0000 Subject: [PATCH 296/475] Translated using Weblate (Spanish) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index faff78c2..b1a382a5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -705,4 +705,15 @@ <string name="override_user_profile_settings">Más ajustes de usuario</string> <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> <string name="any_language">Cualquier idioma</string> + <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> + <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> + <string name="dpad_seek_mode_trickplay">Miniaturas de previsualización</string> + <string name="hash_matches">Coincidencias de valores hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de televisión</string> + <string name="recordings">Grabaciones</string> + <string name="folders_title">Carpetas</string> + <string name="user_views">Vistas de usuario</string> + <string name="books">Libros</string> + <string name="audio_books">Audiolibros</string> </resources> From 05560697a3a95522055b1058f7e67ae9aea503ed Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Thu, 23 Jul 2026 20:31:50 +0000 Subject: [PATCH 297/475] Translated using Weblate (Swedish) Currently translated at 87.4% (556 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6987b433..8339e0ce 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -607,4 +607,17 @@ <string name="unavailable">Otillgänglig</string> <string name="books">Böcker</string> <string name="audio_books">Ljudböcker</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s kvar</string> + <string name="trailers_title"></string> + <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> + <string name="interlaced">Sammanflätad</string> + <string name="level">Nivå</string> + <string name="mpv_conf">Redigera mpv.conf</string> + <string name="profile_specific_settings_from_server">Profilinställningar från server</string> </resources> From ec97a52ffc28d522b19826983d56f545f7a67251 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Fri, 24 Jul 2026 09:31:58 +0000 Subject: [PATCH 298/475] Translated using Weblate (Slovak) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 47ec74bc..dfd58267 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -729,4 +729,15 @@ <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> + <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> + <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> + <string name="hash_matches">Hash zhody</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programy</string> + <string name="recordings">Nahrávky</string> + <string name="folders_title">Priečinky</string> + <string name="user_views">Používateľské zobrazenia</string> + <string name="books">Knihy</string> + <string name="audio_books">Audioknihy</string> </resources> From 22b04ff855bbdbf740cb5d5c16ae1b8cdbddcee7 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Fri, 24 Jul 2026 13:16:36 +0000 Subject: [PATCH 299/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7b73cb69..946c2ab2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -663,4 +663,15 @@ <string name="override_user_profile_settings">更多用户配置设置</string> <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> + <string name="d_pad_seek_mode_title">方向键跳转模式</string> + <string name="dpad_seek_mode_skip_time">跳过时间</string> + <string name="dpad_seek_mode_trickplay">特技播放预览</string> + <string name="hash_matches">哈希匹配</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">电视节目</string> + <string name="recordings">录制内容</string> + <string name="folders_title">文件夹</string> + <string name="user_views">用户视图</string> + <string name="books">书籍</string> + <string name="audio_books">有声读物</string> </resources> From 291ac6c50cc40efc94a187f1bb7e470e7974a11b Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Fri, 24 Jul 2026 13:21:18 +0000 Subject: [PATCH 300/475] Translated using Weblate (Turkish) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dfe62eff..bad54df5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -685,4 +685,15 @@ <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> + <string name="d_pad_seek_mode_title">D-Pad İlerleme Modu</string> + <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> + <string name="dpad_seek_mode_trickplay">Trickplay Önizlemeleri</string> + <string name="hash_matches">Hash Eşleşmeleri</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programları</string> + <string name="recordings">Kayıtlar</string> + <string name="folders_title">Klasörler</string> + <string name="user_views">Kullanıcı Görünümleri</string> + <string name="books">Kitaplar</string> + <string name="audio_books">Sesli Kitaplar</string> </resources> From a26d0932b511cf72f32071eec956b175f0450632 Mon Sep 17 00:00:00 2001 From: Codeberg Translate <translate@codeberg.org> Date: Fri, 24 Jul 2026 15:19:06 +0000 Subject: [PATCH 301/475] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-sv/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 8339e0ce..fd450127 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -614,7 +614,6 @@ <string name="duration_minutes">%1$dm</string> <string name="duration_seconds">%1$ds</string> <string name="time_left">%1$s kvar</string> - <string name="trailers_title"></string> <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> <string name="interlaced">Sammanflätad</string> <string name="level">Nivå</string> From 3830a9eb016c4949cb0f9dfa027114169068365e Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sat, 25 Jul 2026 04:00:47 +0000 Subject: [PATCH 302/475] Translated using Weblate (German) Currently translated at 100.0% (636 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9ca187a..0ce7f5dc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -685,4 +685,15 @@ <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> <string name="any_language">Beliebige Sprache</string> + <string name="hash_matches">Hash Übereinstimmungen</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programme</string> + <string name="recordings">Aufnahmen</string> + <string name="folders_title">Ordner</string> + <string name="books">Bücher</string> + <string name="audio_books">Hörbücher</string> + <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> + <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> + <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> + <string name="user_views">Benutzeransicht</string> </resources> From 0b94cdb50967ffa36a7371f03ed372211533e81a Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sat, 25 Jul 2026 08:25:45 +0000 Subject: [PATCH 303/475] Translated using Weblate (Swedish) Currently translated at 87.7% (558 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index fd450127..3f99f04d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -586,7 +586,7 @@ <string name="slideshow_at_beginning">I början</string> <string name="no_more_images">Inga fler bilder</string> <string name="go_to_artist">Gå till artist</string> - <string name="load_from_web_client">Ladda från web client</string> + <string name="load_from_web_client">Ladda från webbklient</string> <string name="for_episodes">För episoder</string> <string name="increase_all_cards_size">Öka storleken för alla kort</string> <string name="decrease_all_cards_size">Minska storleken för alla kort</string> @@ -619,4 +619,6 @@ <string name="level">Nivå</string> <string name="mpv_conf">Redigera mpv.conf</string> <string name="profile_specific_settings_from_server">Profilinställningar från server</string> + <string name="no_max">Ingen övre gräns</string> + <string name="include_types">Inkludera typer</string> </resources> From 50e306baa122b83903f595a7c45e9a0699cecec0 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sat, 25 Jul 2026 08:28:52 +0000 Subject: [PATCH 304/475] Translated using Weblate (Swedish) Currently translated at 87.8% (559 of 636 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3f99f04d..93c86111 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -621,4 +621,5 @@ <string name="profile_specific_settings_from_server">Profilinställningar från server</string> <string name="no_max">Ingen övre gräns</string> <string name="include_types">Inkludera typer</string> + <string name="load_from_server">Ladda från serverns användarprofil</string> </resources> From ea13c5c5ada23260cb3954cf7a74280c4fa22b52 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Sat, 25 Jul 2026 22:53:30 +0000 Subject: [PATCH 305/475] Translated using Weblate (Spanish) Currently translated at 100.0% (639 of 639 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b1a382a5..794ec38b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -707,7 +707,7 @@ <string name="any_language">Cualquier idioma</string> <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> - <string name="dpad_seek_mode_trickplay">Miniaturas de previsualización</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> <string name="hash_matches">Coincidencias de valores hash</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">Programas de televisión</string> @@ -716,4 +716,8 @@ <string name="user_views">Vistas de usuario</string> <string name="books">Libros</string> <string name="audio_books">Audiolibros</string> + <string name="dpad_seek_mode_minimal">Mínima</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> </resources> From fe39318e04d4a86187691b6111462eb57cb8bb9c Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sat, 25 Jul 2026 22:00:36 +0000 Subject: [PATCH 306/475] Translated using Weblate (Turkish) Currently translated at 100.0% (639 of 639 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index bad54df5..65ccdc0f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -685,9 +685,9 @@ <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> - <string name="d_pad_seek_mode_title">D-Pad İlerleme Modu</string> + <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> - <string name="dpad_seek_mode_trickplay">Trickplay Önizlemeleri</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash Eşleşmeleri</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">TV Programları</string> @@ -696,4 +696,8 @@ <string name="user_views">Kullanıcı Görünümleri</string> <string name="books">Kitaplar</string> <string name="audio_books">Sesli Kitaplar</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> + <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> + <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> </resources> From 24792279ccb02aa6a9ba770e604a15f8e027869d Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Sun, 26 Jul 2026 17:20:24 +0000 Subject: [PATCH 307/475] Translated using Weblate (Spanish) Currently translated at 100.0% (641 of 641 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 794ec38b..a231db57 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -720,4 +720,6 @@ <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> + <string name="brown">Marrón</string> + <string name="server_version_not_supported">Versión de servidor no soportada</string> </resources> From 45f05fbc693666dc34b3d7f1e749687abcb188a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Sun, 26 Jul 2026 19:20:53 +0000 Subject: [PATCH 308/475] Translated using Weblate (Estonian) Currently translated at 98.7% (633 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 6a74a29f..e72bc82f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -685,4 +685,12 @@ <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> <string name="any_language">Iga keel</string> + <string name="brown">Pruun</string> + <string name="programs">Teleprogrammid</string> + <string name="recordings">Salvestused</string> + <string name="folders_title">Kaustad</string> + <string name="user_views">Kasutajate vaatamisi</string> + <string name="books">Raamatud</string> + <string name="audio_books">Heliraamatud</string> + <string name="server_version_not_supported">Serveri versioon pole toetatud</string> </resources> From 46347abbc78b2bbf95ece188ae46f093045e3671 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sun, 26 Jul 2026 15:44:37 +0000 Subject: [PATCH 309/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.8% (640 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c7797553..1bf1f897 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -663,4 +663,19 @@ <string name="override_user_profile_settings">更多個人化設定</string> <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> <string name="any_language">任何語言</string> + <string name="brown">棕色</string> + <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> + <string name="dpad_seek_mode_minimal">精簡</string> + <string name="dpad_seek_mode_trickplay">縮圖預覽</string> + <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> + <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> + <string name="dpad_seek_mode_none_summary">不顯示進度條</string> + <string name="hash_matches">雜湊值相符</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">電視劇</string> + <string name="recordings">錄影節目</string> + <string name="folders_title">資料夾</string> + <string name="books">書籍</string> + <string name="audio_books">有聲書</string> + <string name="server_version_not_supported">不支援此伺服器版本</string> </resources> From d73cf5e9e8ce54c7dcef2159983696dae8a38590 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Sun, 26 Jul 2026 06:03:39 +0000 Subject: [PATCH 310/475] Translated using Weblate (Italian) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0500e3aa..64160937 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -707,4 +707,20 @@ <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> <string name="any_language">Qualsiasi lingua</string> + <string name="brown">Marrone</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programmi TV</string> + <string name="recordings">Registrazioni</string> + <string name="folders_title">Cartelle</string> + <string name="books">Libri</string> + <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimale</string> + <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> + <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> + <string name="hash_matches">Corrispondenze hash</string> + <string name="user_views">Viste utente</string> + <string name="audio_books">Audiolibri</string> + <string name="server_version_not_supported">Versione del server non supportata</string> </resources> From e3afc3c58379f83974e2e9d1e9b96a9fe843db8d Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Mon, 27 Jul 2026 13:10:39 +0000 Subject: [PATCH 311/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 34ae2691..1a54331f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -707,4 +707,20 @@ <string name="override_user_profile_settings">Mais definições do perfil</string> <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> <string name="any_language">Qualquer idioma</string> + <string name="brown">Castanho</string> + <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> + <string name="user_views">Visualizações do utilizador</string> + <string name="books">Livros</string> + <string name="audio_books">Audiolivros</string> + <string name="server_version_not_supported">Versão do servidor não suportada</string> </resources> From 712fbcc9b870c91d9d4489ef413af946a80e8e8e Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:56:27 +0000 Subject: [PATCH 312/475] Translated using Weblate (French) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 09d9f248..d129132c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -705,4 +705,20 @@ <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> <string name="any_language">N\'importe quelle langue</string> + <string name="brown">Marron</string> + <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimum</string> + <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> + <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> + <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> + <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> + <string name="hash_matches">Correspondances de hachage</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Émissions de télévision</string> + <string name="recordings">Enregistrements</string> + <string name="folders_title">Dossiers</string> + <string name="user_views">Avis des utilisateurs</string> + <string name="books">Livres</string> + <string name="audio_books">Livre audio</string> + <string name="server_version_not_supported">Version du serveur non prise en charge</string> </resources> From 708ba9dfabfe4c677d662a942d23ebe15998705a Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:49:39 +0000 Subject: [PATCH 313/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 6385dfe6..a098fbf1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -694,4 +694,33 @@ <string name="unavailable">לא זמין</string> <string name="blocklisted">ברשימה חסומה</string> <string name="video_tunneling">מנהור וידאו</string> + <string name="brown">חום</string> + <string name="dpad_seek_mode_minimal">מינימלי</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitle_mode">מצב כתוביות</string> + <string name="subtitle_mode_default">ברירת מחדל</string> + <string name="subtitle_mode_smart">חכם</string> + <string name="subtitle_mode_only_forced">כפויות בלבד</string> + <string name="subtitle_mode_always">תמיד</string> + <string name="subtitle_mode_none">ללא</string> + <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> + <string name="preferred_audio_language">שפה מועדפת לשמע</string> + <string name="programs">תוכניות טלוויזיה</string> + <string name="recordings">הקלטות</string> + <string name="folders_title">תיקיות</string> + <string name="user_views">צפיות משתמש</string> + <string name="books">ספרים</string> + <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> + <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> + <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> + <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> + <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> + <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> + <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> + <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> + <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> + <string name="any_language">כל שפה</string> + <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> + <string name="audio_books">ספרי שמע</string> + <string name="hash_matches">התאמות של פונקציית־גיבוב</string> </resources> From ab400fd7fbf5086bfcbf3e4b1c7733de40add5ee Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Mon, 27 Jul 2026 05:43:07 +0000 Subject: [PATCH 314/475] Translated using Weblate (Polish) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e137422b..b2d934ce 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -729,4 +729,20 @@ <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> <string name="any_language">Dowolny język</string> <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> + <string name="brown">Brązowy</string> + <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimalistyczny</string> + <string name="dpad_seek_mode_trickplay">Podgląd scen</string> + <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> + <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> + <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> + <string name="hash_matches">Dopasowania hashy</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programy TV</string> + <string name="recordings">Nagrania</string> + <string name="folders_title">Foldery</string> + <string name="user_views">Widoki użytkownika</string> + <string name="books">Książki</string> + <string name="audio_books">Audiobooki</string> + <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> </resources> From 99c5ce2fdfcb91f44ab847fe331d0129d5b2f4c7 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Mon, 27 Jul 2026 09:34:50 +0000 Subject: [PATCH 315/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (641 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 10 ++++++++-- 1 file changed, 8 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 946c2ab2..67750ece 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -663,9 +663,9 @@ <string name="override_user_profile_settings">更多用户配置设置</string> <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> - <string name="d_pad_seek_mode_title">方向键跳转模式</string> + <string name="d_pad_seek_mode_title">方向键进度条模式</string> <string name="dpad_seek_mode_skip_time">跳过时间</string> - <string name="dpad_seek_mode_trickplay">特技播放预览</string> + <string name="dpad_seek_mode_trickplay">特技播放</string> <string name="hash_matches">哈希匹配</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">电视节目</string> @@ -674,4 +674,10 @@ <string name="user_views">用户视图</string> <string name="books">书籍</string> <string name="audio_books">有声读物</string> + <string name="brown">棕色</string> + <string name="dpad_seek_mode_minimal">极简</string> + <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> + <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> + <string name="dpad_seek_mode_none_summary">无进度条</string> + <string name="server_version_not_supported">服务器版本不支持</string> </resources> From 45f3cc7665a073053fa10a2196578269f5f63685 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Sun, 26 Jul 2026 21:58:04 +0000 Subject: [PATCH 316/475] Translated using Weblate (Turkish) Currently translated at 100.0% (641 of 641 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 65ccdc0f..0216e5a7 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -700,4 +700,6 @@ <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> + <string name="brown">Kahverengi</string> + <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> </resources> From e24ef7783e79a063b7638b3269d7f3f5aafc7119 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Mon, 27 Jul 2026 22:02:09 +0000 Subject: [PATCH 317/475] Translated using Weblate (Slovak) Currently translated at 98.9% (634 of 641 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index dfd58267..999c4e51 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -740,4 +740,5 @@ <string name="user_views">Používateľské zobrazenia</string> <string name="books">Knihy</string> <string name="audio_books">Audioknihy</string> + <string name="brown">Hnedá</string> </resources> From 2ac7a3d83f9df2a92065797189ade9cb83ab4eef Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Wed, 29 Jul 2026 03:55:12 +0000 Subject: [PATCH 318/475] Translated using Weblate (Indonesian) Currently translated at 97.5% (626 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 4635cc76..fbd7ed5d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -662,4 +662,6 @@ <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> <string name="any_language">Semua bahasa</string> + <string name="brown">Cokelat</string> + <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> </resources> From 836706fffaa08af0bfce3502eecdb8df67cd572d Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 29 Jul 2026 10:23:51 +0000 Subject: [PATCH 319/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (642 of 642 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 67750ece..f7d39669 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -680,4 +680,5 @@ <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> <string name="dpad_seek_mode_none_summary">无进度条</string> <string name="server_version_not_supported">服务器版本不支持</string> + <string name="appears_on">出现在</string> </resources> From 145f716da4169fe14e8834a93f3e69b201e6cba7 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Wed, 29 Jul 2026 15:19:28 +0000 Subject: [PATCH 320/475] Translated using Weblate (Italian) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 64160937..38e47565 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -723,4 +723,5 @@ <string name="user_views">Viste utente</string> <string name="audio_books">Audiolibri</string> <string name="server_version_not_supported">Versione del server non supportata</string> + <string name="appears_on">Appare in</string> </resources> From c1b055db3a753f4f2d73f50057b59c9d2579d49e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 30 Jul 2026 20:14:13 -0400 Subject: [PATCH 321/475] Remove outdated dpad_seek_mode_skip_time entries --- app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-sk/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - 5 files changed, 5 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0ce7f5dc..b764f319 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -693,7 +693,6 @@ <string name="books">Bücher</string> <string name="audio_books">Hörbücher</string> <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> - <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> <string name="user_views">Benutzeransicht</string> </resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a231db57..4737ad1c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -706,7 +706,6 @@ <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> <string name="any_language">Cualquier idioma</string> <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> - <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> <string name="dpad_seek_mode_trickplay">Fotograma</string> <string name="hash_matches">Coincidencias de valores hash</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 999c4e51..27aa6dda 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -730,7 +730,6 @@ <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> - <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> <string name="hash_matches">Hash zhody</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0216e5a7..133ba3b1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -686,7 +686,6 @@ <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> - <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash Eşleşmeleri</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f7d39669..30ea7470 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -664,7 +664,6 @@ <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> <string name="d_pad_seek_mode_title">方向键进度条模式</string> - <string name="dpad_seek_mode_skip_time">跳过时间</string> <string name="dpad_seek_mode_trickplay">特技播放</string> <string name="hash_matches">哈希匹配</string> <string name="subtitles_hearing_impaired">SDH</string> From 7c6ab39436b64b0c581d9a495e71c98298e68911 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 2 Aug 2026 10:31:56 -0400 Subject: [PATCH 322/475] Fix translations again (#1776) ## Description Another follow up to https://github.com/damontecres/Wholphin/pull/1764 & #1772 Also adds `lint.xml` to mark the extra translations as just a warning since they don't cause a problem in the final build. ### Related issues #1764 #1772 ### Testing N/A ## Screenshots N/A ## AI or LLM usage None --- app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-sk/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - lint.xml | 4 ++++ 6 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 lint.xml diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0ce7f5dc..b764f319 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -693,7 +693,6 @@ <string name="books">Bücher</string> <string name="audio_books">Hörbücher</string> <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> - <string name="dpad_seek_mode_skip_time">Zeit überspringen</string> <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> <string name="user_views">Benutzeransicht</string> </resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a231db57..4737ad1c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -706,7 +706,6 @@ <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> <string name="any_language">Cualquier idioma</string> <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> - <string name="dpad_seek_mode_skip_time">Tiempo de salto</string> <string name="dpad_seek_mode_trickplay">Fotograma</string> <string name="hash_matches">Coincidencias de valores hash</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 999c4e51..27aa6dda 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -730,7 +730,6 @@ <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> - <string name="dpad_seek_mode_skip_time">Čas preskočenia</string> <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> <string name="hash_matches">Hash zhody</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0216e5a7..133ba3b1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -686,7 +686,6 @@ <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> <string name="any_language">Herhangi bir dil</string> <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> - <string name="dpad_seek_mode_skip_time">Atlama Süresi</string> <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash Eşleşmeleri</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f7d39669..30ea7470 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -664,7 +664,6 @@ <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> <string name="any_language">任何语言</string> <string name="d_pad_seek_mode_title">方向键进度条模式</string> - <string name="dpad_seek_mode_skip_time">跳过时间</string> <string name="dpad_seek_mode_trickplay">特技播放</string> <string name="hash_matches">哈希匹配</string> <string name="subtitles_hearing_impaired">SDH</string> diff --git a/lint.xml b/lint.xml new file mode 100644 index 00000000..49e825b2 --- /dev/null +++ b/lint.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<lint> + <issue id="ExtraTranslation" severity="warning" /> +</lint> From 6ad96865e7d7f3111d2eaf6b9bf2dcb2ee854f19 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 2 Aug 2026 11:11:32 -0400 Subject: [PATCH 323/475] Fix live tv & recorded tv playback (#1770) ## Description #1654 broke playback for both live TV and recorded programs in `.ts` format. This PR fixes both issues! Jellyfin returns `NoCompatibleStream` for live tv if the source ID is passed in, so that is now skipped for live tv. `.ts` files may have IDs formatted with a slash instead of colon, so the logic is updated to handle that plus fallbacks for potentially unknown future formats. ### Related issues Fixes #1767 ### Testing Emulator & nvidia shield, plus unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackViewModel.kt | 78 ++++++++++++------- .../ui/playback/TrackSelectionUtils.kt | 12 ++- .../test/TestSelectionTrackExamples.kt | 57 ++++++++++++++ .../damontecres/wholphin/test/TestTracks.kt | 2 + 4 files changed, 116 insertions(+), 33 deletions(-) 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 da55b98b..a3847f63 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 @@ -466,10 +466,16 @@ class PlaybackViewModel } } } - val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) + val mediaSource = + if (!isLiveTv) { + streamChoiceService.chooseSource(base, itemPlayback) + } else { + null + } + val plc = streamChoiceService.getPlaybackLanguageChoice(base) - if (mediaSource == null) { + if (mediaSource == null && !isLiveTv) { showToast( context, "Item has no media sources, skipping...", @@ -479,7 +485,8 @@ class PlaybackViewModel } val videoStream = - mediaSource.mediaStreams + mediaSource + ?.mediaStreams ?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.let { val isHdr = @@ -493,37 +500,48 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleStreams = getSubtitleStreams(mediaSource) - val audioStreams = getAudioStreams(mediaSource) + val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() + val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() val audioStream = - streamChoiceService - .chooseAudioStream( - source = mediaSource, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - ) + mediaSource?.let { + streamChoiceService + .chooseAudioStream( + source = mediaSource, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + ) + } val audioIndex = audioStream?.index val subtitleIndex = - streamChoiceService - .chooseSubtitleStream( - source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - )?.index - - Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + mediaSource?.let { + streamChoiceService + .chooseSubtitleStream( + source = mediaSource, + audioStream = audioStream, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + )?.index + } + Timber.d( + "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", + mediaSource?.id, + audioIndex, + subtitleIndex, + ) val trickPlayInfo = - item.data.trickplay - ?.get(mediaSource.id) - ?.values - ?.firstOrNull() + mediaSource?.id?.let { sourceId -> + item.data.trickplay + ?.get(sourceId) + ?.values + ?.firstOrNull() + } + trickPlayInfo?.let { trickplayInfo -> mediaSource.runTimeTicks?.ticks?.let { duration -> viewModelScope.launchIO { @@ -545,7 +563,7 @@ class PlaybackViewModel } updateCurrentMedia { CurrentMediaInfo( - sourceId = mediaSource.id, + sourceId = mediaSource?.id, videoStream = videoStream, audioStreams = audioStreams, subtitleStreams = subtitleStreams, @@ -559,7 +577,7 @@ class PlaybackViewModel audioIndex = audioIndex, subtitleIndex = subtitleIndex, positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, - sourceId = mediaSource.id, + sourceId = mediaSource?.id, enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index c9a16e79..7b377399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -158,10 +158,16 @@ object TrackSelectionUtils { } else { track.trackFormats[0] .id - ?.split(":") - ?.map { it.toInt() } + ?.split(":", "/") + ?.map { it.toIntOrNull() ?: Int.MAX_VALUE } ?.let { - track to it + if (it.size >= 2) { + track to it + } else if (it.isEmpty()) { + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) + } else { + track to listOf(it[0], Int.MAX_VALUE) + } } } }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] }) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt index 564ab7a9..a98440f4 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -296,4 +296,61 @@ class TestSelectionTrackExamples { Assert.assertFalse(result.bothSelected) } } + + @Test + fun `test exo track IDs with slashes`() { + val testTracks = + TestTracks( + listOf( + // .ts files (eg recorded TV) report IDs like this in ExoPlayer + TestTrack("3/49", 0, MediaStreamType.VIDEO, false), + TestTrack("3/52", 1, MediaStreamType.AUDIO, false), + TestTrack("", 2, MediaStreamType.DATA, false), + TestTrack("3/53", 3, MediaStreamType.AUDIO, false), + ), + ) + val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.DATA, false, C.FORMAT_HANDLED) + .addAudio() + .buildMediaSourceInfo() + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 3, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt index f9b1eab2..7370f29e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.test import androidx.media3.common.C import androidx.media3.common.Format +import androidx.media3.common.MimeTypes import androidx.media3.common.TrackGroup import androidx.media3.common.Tracks import org.jellyfin.sdk.model.api.MediaProtocol @@ -35,6 +36,7 @@ class TestTracks( MediaStreamType.VIDEO -> "video/sample" MediaStreamType.AUDIO -> "audio/default" MediaStreamType.SUBTITLE -> "text/sample" + MediaStreamType.DATA -> MimeTypes.APPLICATION_ID3 else -> throw UnsupportedOperationException("${it.type}") } val format = From d7d63f52c3139540aaae7d52ed7ddb099eb85903 Mon Sep 17 00:00:00 2001 From: Stefan Bullones <stefan.bullones@gmail.com> Date: Mon, 3 Aug 2026 15:54:03 +0200 Subject: [PATCH 324/475] Fix the alphabet buttons scroll bar on the Artists tab (#1775) ## Description positionOfLetter counted through /Items no matter which endpoint the grid was actually paging. A music library's Artists tab pages /Artists, so the index it returned had counted every album and song before the letter - an order of magnitude more rows than the artist grid holds - and every letter jumped to the last row. Count through the same endpoint createPager builds its pager from: /Artists for the ARTIST override, /Items for NONE. The artist request is now built once in createGetArtistsRequest and shared by both, so the two cannot drift apart. GetPersonsRequest has neither nameLessThan nor startIndex, so /Persons cannot be counted up to a letter at all; the PERSON override returns null, which the call sites already map to the "no jump" sentinel. That path is unreachable today - the only PERSON tab sorts by ItemSortBy.DEFAULT, so showLetterButtons is never true there and AlphabetButtons is never composed. ### Related issues https://github.com/damontecres/Wholphin/issues/1774 ### Testing Select a music library -> Go to Artist tab -> Select a letter on the right side alphabet scroll bar ## AI or LLM usage I used Cursor and Claude Code for planning, implementation and code review. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../ui/components/CollectionFolderView.kt | 100 +++++++--- .../github/damontecres/wholphin/ui/Utils.kt | 5 +- .../CollectionFolderViewModelTest.kt | 187 ++++++++++++++++++ 3 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 4f3154f2..e64115aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -71,6 +71,7 @@ import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -378,26 +379,30 @@ class CollectionFolderViewModel } GetItemsFilterOverride.ARTIST -> { - val item = state.value.item.successValue - val request = - filter.applyTo( - GetArtistsRequest( - parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - ), - ) - val newPager = - ApiRequestPager( - api, - request, - GetArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) } } + /** + * Shared with [positionOfLetter] so that the alphabet jump counts the same artists that are + * actually paged through + */ + private fun createGetArtistsRequest(filter: GetItemsFilter): GetArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + ), + ) + } + private fun createGetItemsRequest( sortAndDirection: SortAndDirection, recursive: Boolean, @@ -460,26 +465,57 @@ class CollectionFolderViewModel filterOption, ) + /** + * The count must come from the same endpoint that [createPager] pages through, otherwise + * the index refers to a different result set and lands past the end of the list. + */ suspend fun positionOfLetter(letter: Char): Int? = withContext(WholphinDispatchers.IO) { - val sort = state.value.sortAndDirection val filter = state.value.filter - val request = - createGetItemsRequest( - sortAndDirection = sort, - recursive = recursive, - filter = filter, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount + when (filter.override) { + GetItemsFilterOverride.ARTIST -> { + GetArtistsHandler.countMatching( + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ), + ) + } + + // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be + // counted up to a letter at all + GetItemsFilterOverride.PERSON -> { + null + } + + GetItemsFilterOverride.NONE -> { + GetItemsRequestHandler.countMatching( + createGetItemsRequest( + sortAndDirection = state.value.sortAndDirection, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ), + ) + } + } } + /** + * [request] must ask for the count, ie `limit = 0` and `enableTotalRecordCount = true` + */ + private suspend fun <T> RequestHandler<T>.countMatching(request: T): Int { + val result by execute(api, request) + return result.totalRecordCount + } + override fun setWatched( position: Int, itemId: UUID, 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 index 2fc00bce..6d30a600 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -17,4 +17,7 @@ fun SemanticsNodeInteraction.performClickEnter() = fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) -fun successQueryResult(items: List<BaseItemDto>) = successResponse(BaseItemDtoQueryResult(items, items.size, 0)) +fun successQueryResult( + items: List<BaseItemDto> = emptyList(), + totalRecordCount: Int = items.size, +) = successResponse(BaseItemDtoQueryResult(items, totalRecordCount, 0)) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt new file mode 100644 index 00000000..2d10f1a3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -0,0 +1,187 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ServerRepository +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.services.DeletedItem +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.UUID + +/** + * Each endpoint is stubbed with a distinct total, so a returned index identifies which endpoint + * produced it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionFolderViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockMediaManagementService = mockk<MediaManagementService>(relaxed = true) + private val mockUserLibraryApi = mockk<UserLibraryApi>() + + private val artistsRequest = slot<GetArtistsRequest>() + private val itemsRequest = slot<GetItemsRequest>() + + private val libraryId = UUID.randomUUID() + + private val library = + BaseItemDto( + id = libraryId, + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = CollectionType.MUSIC, + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + coEvery { mockUserLibraryApi.getItem(libraryId) } returns successResponse(library) + + // No current user, so no saved LibraryDisplayInfo is consulted + every { mockServerRepository.currentUser } returns null + every { mockMediaManagementService.deletedItemFlow } returns MutableSharedFlow<DeletedItem>() + + // mockkObject spies by default, so every handler must be stubbed or the real one runs + // against the relaxed ApiClient + mockkObject(GetItemsRequestHandler) + mockkObject(GetArtistsHandler) + mockkObject(GetPersonsHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns + successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT) + coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns + successQueryResult(totalRecordCount = ARTIST_COUNT) + coEvery { GetPersonsHandler.execute(mockApi, any()) } returns + successQueryResult(totalRecordCount = PERSON_COUNT) + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkObject(GetItemsRequestHandler) + unmockkObject(GetArtistsHandler) + unmockkObject(GetPersonsHandler) + } + + private fun createViewModel(filter: GetItemsFilter) = + CollectionFolderViewModel( + savedStateHandle = SavedStateHandle(), + api = mockApi, + context = mockk(relaxed = true), + serverRepository = mockServerRepository, + libraryDisplayInfoDao = mockk(relaxed = true), + favoriteWatchManager = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + navigationManager = mockk(relaxed = true), + themeSongPlayer = mockk(relaxed = true), + userPreferencesService = mockk(relaxed = true), + mediaManagementService = mockMediaManagementService, + musicService = mockk(relaxed = true), + streamChoiceService = mockk(relaxed = true), + mediaReportService = mockk(relaxed = true), + filterOptionCache = mockk(relaxed = true), + itemId = libraryId.toString(), + initialSortAndDirection = null, + recursive = true, + collectionFilter = CollectionFolderFilter(filter = filter), + useSeriesForPrimary = false, + defaultViewOptions = ViewOptionsSquare, + ) + + /** Counting via /Items would return every song and album, overshooting the artist grid */ + @Test + fun `positionOfLetter counts artists for the ARTIST override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ARTIST_COUNT, position) + assertEquals("K", artistsRequest.captured.nameLessThan) + assertEquals(libraryId, artistsRequest.captured.parentId) + assertEquals(0, artistsRequest.captured.limit) + + // Counting library items rather than artists is the bug this guards against + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + } + + /** + * /Persons has no nameLessThan or startIndex, so counting up to a letter is not expressible. + * Falling back to /Items would count media rather than people, ie the same defect as the artist + * grid, so guard against it even though no screen currently sorts people by name. + */ + @Test + fun `positionOfLetter does not fall back to counting items for the PERSON override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.PERSON)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(null, position) + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) } + } + + @Test + fun `positionOfLetter counts items for the NONE override`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter( + override = GetItemsFilterOverride.NONE, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + ), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(LIBRARY_ITEM_COUNT, position) + assertEquals("K", itemsRequest.captured.nameLessThan) + assertEquals(libraryId, itemsRequest.captured.parentId) + assertEquals(0, itemsRequest.captured.limit) + } + + companion object { + private const val LIBRARY_ITEM_COUNT = 7315 + private const val ARTIST_COUNT = 730 + private const val PERSON_COUNT = 12 + } +} From fceb5f81a75c727f0d9601434ffd9bedb60d52f6 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 3 Aug 2026 11:10:07 -0400 Subject: [PATCH 325/475] Release v1.0.5 From 7581b189aab8112b636b8fb60a9ec0d41e566afe Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 4 Aug 2026 11:01:19 -0400 Subject: [PATCH 326/475] Fix preference text overflow/clipping (#1785) ## Description Removes the max height constraints for slider preferences and navigation drawer preferences so that longer text (possibly in other languages or a long library name) won't cut off any content ### Related issues Fixes #1784 ### Testing Emulator with manual long text ## Screenshots N/A, no significant change except sliders can be slightly taller if needed ## AI or LLM usage None --- .../wholphin/ui/preferences/NavDrawerPreference.kt | 2 +- .../wholphin/ui/preferences/SliderPreference.kt | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index e2024aa2..7deeb95c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -227,7 +227,7 @@ fun NavDrawerPreferenceListItem( modifier = Modifier .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), + .heightIn(min = 40.dp), ) { ListItem( selected = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index ce686708..459ec479 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -18,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -36,7 +35,6 @@ fun SliderPreference( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, summaryBelow: Boolean = false, - heightAdjustment: Dp = 0.dp, additionalSummary: @Composable (ColumnScope.() -> Unit)? = null, ) { val focused = interactionSource.collectIsFocusedAsState().value @@ -49,16 +47,15 @@ fun SliderPreference( val contentColor = contentColorFor(background) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.SpaceEvenly, modifier = modifier -// .height(80.dp) // not dense - .height(72.dp + heightAdjustment) // dense + .defaultMinSize(minHeight = 72.dp) .fillMaxWidth() .background(background, shape = RoundedCornerShape(8.dp)) - .padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense, + .padding(PaddingValues(horizontal = 16.dp, vertical = 12.dp)), ) { - PreferenceTitle(title, color = contentColor) + PreferenceTitle(title, color = contentColor, modifier = Modifier.padding(bottom = 8.dp)) ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) { additionalSummary?.invoke(this) From 454c0212b6b4fe29a4b95f93f8319cea7e888c78 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 10 Aug 2026 20:17:42 -0400 Subject: [PATCH 327/475] Fix issues with external subtitles & transcoding (#1790) ## Description Fixes a bug where the external subtitle track would not be activated if playback is transcoded Also fixes a bug where external subtitles would cause MPV to incorrectly switch to transcoding ### Related issues Fixes #1778 ### Testing Emulator with files that have a variety of external SRT, embedded SRT & ASS, & embedded PGS ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackViewModel.kt | 93 +++++++++++-------- .../ui/playback/TracksChangedListener.kt | 41 ++++++++ 2 files changed, 93 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt 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 a3847f63..6587e758 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 @@ -184,6 +184,7 @@ class PlaybackViewModel internal lateinit var currentItem: PlaylistItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null + private var trackChangeListener: TracksChangedListener? = null private val jobs = mutableListOf<Job>() private val isPlaylist = destination is Destination.PlaybackList @@ -215,6 +216,9 @@ class PlaybackViewModel it.release() player.removeListener(it) } + this@PlaybackViewModel.trackChangeListener?.let { + player.removeListener(it) + } player.release() mediaSession?.release() } @@ -604,6 +608,9 @@ class PlaybackViewModel withContext(WholphinDispatchers.IO) { val itemId = item.id + trackChangeListener?.let { onMain { player.removeListener(it) } } + trackChangeListener = null + state.value.currentPlayback?.let { currentPlayback -> if (currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && @@ -715,6 +722,7 @@ class PlaybackViewModel val externalSubtitle = source.findExternalSubtitle(subtitleIndex)?.let { + Timber.v("Using externally delivered subtitle tracks %s", subtitleIndex) it.deliveryUrl?.let { deliveryUrl -> var flags = 0 if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) @@ -807,48 +815,51 @@ class PlaybackViewModel mediaItem, positionMs, ) - if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) { - val onTracksChangedListener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - Timber.v("onTracksChanged: $tracks") - if (tracks.groups.isNotEmpty()) { - val result = - TrackSelectionUtils.createTrackSelections( - player.trackSelectionParameters, - player.currentTracks, - audioIndex, - subtitleIndex, - source, - ) - Timber.v("onTracksChanged: %s", result) - player.removeListener(this) - if (result.bothSelected) { - player.trackSelectionParameters = - result.trackSelectionParameters - } else { - // Fall back to transcoding - Timber.w( - "Failed to select tracks, falling back to transcoding: %s", - result, - ) - viewModelScope.launchIO { - changeStreams( - item = item, - sourceId = sourceId, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - positionMs = positionMs, - enableDirectPlay = false, - enableDirectStream = true, - ) - } - } - viewModelScope.launchIO { loadSubtitleDelay() } - } - } + val onFailure: () -> Unit = { + if (player is MpvPlayer && externalSubtitle != null) { + // MpvPlayer may change tracks more than once to add external subtitles + trackChangeListener?.let { player.addListener(it) } + } else { + viewModelScope.launchIO { + changeStreams( + item = item, + sourceId = sourceId, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = positionMs, + enableDirectPlay = false, + enableDirectStream = true, + ) } - player.addListener(onTracksChangedListener) + } + } + val onTracksChangedListener = + if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) { + TracksChangedListener( + player = player, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else if (externalSubtitle != null) { + TracksChangedListener( + player = player, + audioIndex = null, // Do not manually activate audio + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else { + null + } + onTracksChangedListener?.let { + player.addListener(it) + this@PlaybackViewModel.trackChangeListener = it + } + + if (subtitleIndex != null) { + viewModelScope.launchIO { loadSubtitleDelay() } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt new file mode 100644 index 00000000..c5192153 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt @@ -0,0 +1,41 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaSourceInfo +import timber.log.Timber + +class TracksChangedListener( + val player: Player, + val audioIndex: Int?, + val subtitleIndex: Int?, + val source: MediaSourceInfo, + val onFailure: () -> Unit, +) : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + Timber.v("onTracksChanged: $tracks") + if (tracks.groups.isNotEmpty()) { + val result = + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, + audioIndex, + subtitleIndex, + source, + ) + Timber.v("onTracksChanged: %s", result) + player.removeListener(this) + if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters + } else { + // Fall back to transcoding + Timber.w( + "Failed to select tracks, falling back to transcoding: %s", + result, + ) + onFailure.invoke() + } + } + } +} From a0966dd05a865e858f75264f12a5b7f4316303a8 Mon Sep 17 00:00:00 2001 From: Stefan Bullones <stefan.bullones@gmail.com> Date: Tue, 11 Aug 2026 03:52:43 +0200 Subject: [PATCH 328/475] Fix alphabet highlight for names starting with "The " (#1788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The alphabet scroll bar highlighted the letter of an item's *display* name while the grid sorted by the server's article-stripped `SortName`, so "The Beatles" sat under **B** but highlighted **T**. - **Artists:** the Artists tab never requested `SortName`, so the highlight silently fell back to `Name`. It now asks for `SlimItemFields` (which already includes `SORT_NAME`), matching the sibling `/Items` branch. The count-only query used for letter positions passes `fields = null`, since a `limit = 0` request has no use for them. - **Genres and studios:** these grids bucketed on the display name unconditionally. The sort-name fallback is extracted as `BaseItemDto.alphabetSortName` (`sortName ?: name ?: ""`) and carried onto the `Genre` and `Studio` grid items, so all three grids now derive the letter from one value. ### Related issues https://github.com/damontecres/Wholphin/issues/1787 ### Testing Two unit tests added: - `CollectionFolderViewModelTest` pins that the artist pager actually requests `SORT_NAME` — the field whose absence caused the bug. - `AlphabetSortNameTest` covers both branches of the `alphabetSortName` fallback. Manually: select a music library → Artists tab → put the cursor on an artist starting with "The " and confirm the highlighted letter matches the grid position. Same check on the genre and studio grids. ## AI or LLM usage I used Cursor for debugging and implementing the fix, and Claude Code for a follow-up cleanup pass over the diff. --- .../wholphin/data/model/BaseItem.kt | 5 ++- .../ui/components/CollectionFolderView.kt | 2 ++ .../wholphin/ui/components/GenreCardGrid.kt | 10 ++++-- .../wholphin/ui/components/StudioCardGrid.kt | 10 ++++-- .../data/model/AlphabetSortNameTest.kt | 36 +++++++++++++++++++ .../CollectionFolderViewModelTest.kt | 15 ++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt 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 5ca6bf95..50df6071 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 @@ -54,7 +54,7 @@ data class BaseItem( get() = type.playable override val sortName: String - get() = data.sortName ?: data.name ?: "" + get() = data.alphabetSortName val type get() = data.type @@ -305,6 +305,9 @@ data class BaseItem( val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } +/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */ +val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: "" + @Immutable data class BaseItemUi( val episodeCornerText: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index e64115aa..1a7992d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -399,6 +399,7 @@ class CollectionFolderViewModel GetArtistsRequest( parentId = item?.id, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, ), ) } @@ -477,6 +478,7 @@ class CollectionFolderViewModel GetArtistsHandler.countMatching( createGetArtistsRequest(filter).copy( enableImageTypes = null, + fields = null, nameLessThan = letter.toString(), limit = 0, enableTotalRecordCount = true, 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 00dfbb56..aeae3fd7 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 @@ -20,6 +20,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.alphabetSortName import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -104,7 +105,12 @@ class GenreViewModel .execute(api, request) .content.items .map { - Genre(it.id, it.name ?: "", null) + Genre( + id = it.id, + name = it.name ?: "", + imageUrl = null, + sortName = it.alphabetSortName, + ) } _state.update { it.copy( @@ -243,10 +249,10 @@ data class Genre( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 3f424bad..461d1d94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -20,6 +20,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.alphabetSortName import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -97,7 +98,12 @@ class StudioViewModel imageType = ImageType.THUMB, fillWidth = cardWidthPx, ) - Studio(it.id, it.name ?: "", imageUrl) + Studio( + id = it.id, + name = it.name ?: "", + imageUrl = imageUrl, + sortName = it.alphabetSortName, + ) } _state.update { it.copy( @@ -133,10 +139,10 @@ data class Studio( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } data class StudioGridState( diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt new file mode 100644 index 00000000..0b18cc8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.UUID + +class AlphabetSortNameTest { + @Test + fun prefersSortNameOverName() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ARTIST, + name = "The Beatles", + sortName = "beatles", + ) + + assertEquals("beatles", dto.alphabetSortName) + assertEquals("beatles", BaseItem(dto).sortName) + } + + @Test + fun fallsBackToNameWhenSortNameMissing() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.STUDIO, + name = "The Walt Disney Company", + sortName = null, + ) + + assertEquals("The Walt Disney Company", dto.alphabetSortName) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt index 2d10f1a3..978cd70c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -33,10 +33,12 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.UUID @@ -141,6 +143,19 @@ class CollectionFolderViewModelTest { coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } } + /** + * Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists + * like "The Beatles" even though the grid sorts them under "B". + */ + @Test + fun `artist pager requests SortName for alphabet highlighting`() = + runTest(testDispatcher) { + createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + assertTrue(ItemFields.SORT_NAME in artistsRequest.captured.fields.orEmpty()) + } + /** * /Persons has no nameLessThan or startIndex, so counting up to a letter is not expressible. * Falling back to /Items would count media rather than people, ie the same defect as the artist From c1abb57cd24354e2d4059e57c76dd478a3db61a8 Mon Sep 17 00:00:00 2001 From: calebvetter <39844454+calebvetter@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:59:20 -0700 Subject: [PATCH 329/475] Add back stack for deep link navigation (#1756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When testing the new deep link feature from this PR [#1688](https://github.com/damontecres/Wholphin/pull/1688) I noticed that the back button would close the app. As in, all routes are cleared and only the playback is in the nav stack. I figured a more desirable behavior would be to create a nav stack, always starting with the home screen, then also inserting the media item's details page, if available. To achieve this, I made the return Target.destinations a list of destinations, and updated the other two uses in IntentService. Open to implementation tweaks, or scrap it if this isn't how it should work, haha. ### Testing Tested deep links, which produced the following nav stacks: - Movie view - Home → Movie details - Movie playback (with and without position) - Home → Movie details → Playback - Series view - Home → Series details - Season view - Home → Season details - Episode view - Home → Episode details - Episode playback (with and without position) - Home → Episode details → Playback - Trailer/extras playback (no details page available) - Home → Playback - TV channel view - Home → Playback - TV channel playback - Home → Playback ## AI or LLM usage AI was used to generate the initial code, debug and test, then manual refactoring and cleanup was done. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../damontecres/wholphin/MainActivity.kt | 9 +++- .../wholphin/services/IntentService.kt | 52 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 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 f1eb3b24..73ad2cbc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -449,7 +449,14 @@ class MainActivityViewModel val current = serverRepository.current.value if (current != null) { Timber.i("Received valid intent, switching to AppContent") - navigationManager.replace(result.destination) + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + setupNavigationManager.navigateTo( SetupDestination.AppContent(current), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index 0a043506..da59b98d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -27,12 +27,11 @@ class IntentService Timber.v("Parsing intent %s", intent) Timber.v("Intent extras: %s", intent.extras) Timber.v("Intent data: %s", intent.data) - if (intent.getStringParam("type") != null) { - return getDestinationFromChannel(intent)?.let { IntentResult.Target(it) } - ?: IntentResult.Error("Invalid parameters") - } val action = intent.action ?: intent.data?.host - if (action == Intent.ACTION_MAIN || action.isNullOrBlank()) { + if ( + intent.getStringParam("type") == null && + (action == Intent.ACTION_MAIN || action.isNullOrBlank()) + ) { return IntentResult.NoOp } @@ -65,9 +64,19 @@ class IntentService } } + if (intent.getStringParam("type") != null) { + return getDestinationFromChannel(intent)?.let { + IntentResult.Target( + destinations = listOf(it), + addHomeToBackStack = false, + ) + } + ?: IntentResult.Error("Invalid parameters") + } + if (action == Intent.ACTION_SEARCH || action == "search") { val query = intent.getStringParam(SearchManager.QUERY) - return IntentResult.Target(Destination.Search(query ?: "")) + return IntentResult.Target(listOf(Destination.Search(query ?: ""))) } val itemId = intent.getStringParam("itemId")?.toUUIDOrNull() @@ -84,25 +93,39 @@ class IntentService return IntentResult.Error("Could not fetch item $itemId") } - val destination = + val itemDestination = item.destination() + + val destinations = when (action) { Intent.ACTION_VIEW, "view" -> { - item.destination() + listOf(itemDestination) } "com.github.damontecres.wholphin.PLAYBACK", "play" -> { val position = intent.getLongParam("position")?.coerceAtLeast(0) - Destination.Playback( - itemId = itemId, - positionMs = position ?: 0L, - ) + + val playbackDestination = + Destination.Playback( + itemId = itemId, + positionMs = position ?: 0L, + ) + + if (itemDestination is Destination.Playback) { + listOf(playbackDestination) + } else { + listOf( + itemDestination, + playbackDestination, + ) + } } else -> { return IntentResult.Error("Invalid action: ${intent.action}") } } - return IntentResult.Target(destination) + + return IntentResult.Target(destinations) } private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) @@ -159,6 +182,7 @@ sealed interface IntentResult { data object NoOp : IntentResult data class Target( - val destination: Destination, + val destinations: List<Destination>, + val addHomeToBackStack: Boolean = true, ) : IntentResult } From 2745e76b8261626bfcb82b68e963e1b57c960f1b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:59:55 -0400 Subject: [PATCH 330/475] Update Github Actions (#1672) --- .github/actions/setup/action.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/pr.yml | 4 ++-- .github/workflows/release.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 28f6f9a2..14aa7950 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -10,7 +10,7 @@ runs: with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: zulu java-version: '21' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a63489e..75abc88d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2d5a73f6..18a42f69 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup Python @@ -31,7 +31,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5a0d66f..290d065e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup From 5f44f870dfdb94cf8471af47e9c42ced6706c6ba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:00:15 -0400 Subject: [PATCH 331/475] Update Kotlin to v2.4.10 (#1697) --- 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 cf4e9903..7a62a219 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" hiltCompiler = "1.3.0" kache = "2.1.1" -kotlin = "2.4.0" +kotlin = "2.4.10" ksp = "2.3.9" coreKtx = "1.18.0" appcompat = "1.7.1" From e94e82479b5612d2d56983cd49c6135183a69a3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:00:33 -0400 Subject: [PATCH 332/475] Update dependency org.openapi.generator to v7.24.0 (#1731) --- 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 7a62a219..553bb690 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ paletteKtx = "1.0.0" assMedia = "0.5.0" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.23.0" +openapi-generator = "7.24.0" runner = "1.7.0" wholphin-extensions = "0.2.1" junit = "4.13.2" From bd1a6c44ee9da6c385fe0ee0401ca44913820973 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 11 Aug 2026 13:33:49 -0400 Subject: [PATCH 333/475] Add shuffle parameter to playback intent (#1813) ## Description Adds an optional boolean parameter `shuffle` that is read for the playback intent. When true, it will cause the resulting media to be shuffled if applicable. Something can be shuffled if the `itemId` is a grouping on media such as a playlist, series, season, or even library. ### Related issues Depends on #1756 ### Testing Emulator with the documented command ## Screenshots N/A ## AI or LLM usage None --- Intents.md | 15 ++++++++++++--- .../wholphin/services/IntentService.kt | 2 ++ .../damontecres/wholphin/ui/nav/Destination.kt | 1 + .../wholphin/ui/playback/PlaybackViewModel.kt | 7 +++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Intents.md b/Intents.md index a083ae7a..02511787 100644 --- a/Intents.md +++ b/Intents.md @@ -1,8 +1,5 @@ # Intents -> [!WARNING] -> This feature is only available in the develop builds until the next release! - > [!IMPORTANT] > This feature is experimental and the exact behaviors and/or parameters may change at any time @@ -93,6 +90,7 @@ Shorthand action: `play` Parameters: - `itemId` - Required, the UUID of the media item - `position` - Optional, the start position for playback in milliseconds +- `shuffle` - Optional, whether to shuffle the resulting items ### Examples @@ -120,6 +118,17 @@ adb shell am start \ adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000' ``` +Shuffle a series (`itemId` is the Series ID): +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --ez shuffle true + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true' +``` + ## More examples ### Switch user diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index da59b98d..95343b47 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -103,11 +103,13 @@ class IntentService "com.github.damontecres.wholphin.PLAYBACK", "play" -> { val position = intent.getLongParam("position")?.coerceAtLeast(0) + val shuffle = intent.getBooleanExtra("shuffle", false) val playbackDestination = Destination.Playback( itemId = itemId, positionMs = position ?: 0L, + shuffle = shuffle, ) if (itemDestination is Destination.Playback) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index d9d027fc..081b18d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -90,6 +90,7 @@ sealed class Destination( val positionMs: Long, val forceTranscoding: Boolean = false, val backend: PlayerBackend? = null, + val shuffle: Boolean = false, ) : Destination(true) { constructor(item: BaseItem) : this(item.id, item.resumeMs) } 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 6587e758..ceade4c0 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 @@ -332,10 +332,17 @@ class PlaybackViewModel filter = destination.filter, ) } else { + val shuffled = + if (destination is Destination.Playback) { + destination.shuffle + } else { + false + } // Try to create a playlist playlistCreator.createFrom( item = queriedItem, recursive = true, + shuffled = shuffled, ) } when (val r = playlistResult) { From 5219b22fc746be7afcb0dd10beef587b4e638c0f Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 11 Aug 2026 13:35:11 -0400 Subject: [PATCH 334/475] Update wholphin-extensions & media3 (#1814) Updates playback dependencies: - `wholphin-extensions` to `v0.2.2` - `media3` to `1.11.0` - `libass-android` to `0.5.1` --- app/build.gradle.kts | 2 +- gradle/libs.versions.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a32917d3..e8046a68 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,7 +178,7 @@ configure<ApplicationExtension> { isEnable = !isBuildingBundle.get() reset() - include("armeabi-v7a", "arm64-v8a") + include("armeabi-v7a", "arm64-v8a", "x86_64") isUniversalApk = true } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 553bb690..2dbe114c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ tvFoundation = "1.0.0" tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -androidx-media3 = "1.10.1" +androidx-media3 = "1.11.0" coil = "3.5.0" jellyfin-sdk = "1.7.1" nav3Core = "1.1.3" @@ -40,12 +40,12 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -assMedia = "0.5.0" +libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" openapi-generator = "7.24.0" runner = "1.7.0" -wholphin-extensions = "0.2.1" +wholphin-extensions = "0.2.2" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" @@ -141,7 +141,7 @@ 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" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "libass-android" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } From de30c2ccac454c3f1c2813cea596e6bf2f16d8c4 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 11 Aug 2026 13:35:20 -0400 Subject: [PATCH 335/475] Improvements to Now Playing page, including artist backdrops (#1782) ## Description This PR has updates the Now Playing page. Now only the song's backdrops and the song's artists' backdrops will show. First, if the song has any backdrops (probably pretty uncommon), they will be shuffled and shown, then if the song's artists have backdrop, those are shuffled and shown. The backdrop changes every 2 minutes unless there's <15s left in the song to prevent rapid changes. Note: album backdrops and album artists not directly linked to the specific song are not included. If a song has a lyrics and the user is scrolling through them, if the user doesn't scroll for 5 seconds, the focus leaves the lyrics and they scroll to the current line if available. Dev note: Both backdrop & lyric updates are refactored to use `Flow`s to trigger lookups ### Related issues Fixes #1170 ### Testing Emulator, I need to test it longer term ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/detail/music/LyricsContent.kt | 5 +- .../ui/detail/music/NowPlayingPage.kt | 19 +- .../ui/detail/music/NowPlayingState.kt | 2 + .../ui/detail/music/NowPlayingViewModel.kt | 250 +++++++++--------- 4 files changed, 137 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt index 40dd5a87..e60e2a08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -121,7 +121,10 @@ fun LyricsContent( ), modifier = Modifier - .focusRequester(focusRequesters[index]), + .focusRequester(focusRequesters[index]) + .onFocusChanged { + if (it.isFocused) onFocusLyrics.invoke(true) + }, ) { val text = remember(lyric.text) { lyric.text.ifBlank { " " } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index f403c7a9..30cd20ad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -74,7 +74,9 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.delay import org.jellyfin.sdk.model.extensions.ticks +import java.util.Date import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) @@ -140,19 +142,22 @@ fun NowPlayingPage( var showViewOptionsDialog by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) } - var lyricsHaveFocus by remember { mutableStateOf(false) } + var lyricsFocused by remember { mutableStateOf<Date?>(null) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } val lyricsFocusRequester = remember { FocusRequester() } val hasLyrics = musicPrefs.showLyrics && state.hasLyrics - LaunchedEffect(lyricsHaveFocus) { - if (lyricsHaveFocus) { + LaunchedEffect(lyricsFocused) { + if (lyricsFocused != null) { controllerViewState.hideControls() } + delay(5.seconds) + lyricsFocused = null + focusRequester.tryRequestFocus() } - BackHandler(lyricsHaveFocus) { + BackHandler(lyricsFocused != null) { focusRequester.tryRequestFocus() } @@ -290,8 +295,10 @@ fun NowPlayingPage( LyricsContent( lyrics = state.lyrics, currentLyricPosition = state.currentLyricIndex, - lyricsHaveFocus = lyricsHaveFocus, - onFocusLyrics = { lyricsHaveFocus = it }, + lyricsHaveFocus = lyricsFocused != null, + onFocusLyrics = { + lyricsFocused = Date() + }, onClick = { it.start ?.ticks diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt index f6fe40e4..cfaa1cfc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.services.BackdropResult import com.github.damontecres.wholphin.services.MusicServiceState import org.jellyfin.sdk.model.api.LyricDto @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.LyricDto @Stable data class NowPlayingState( val musicServiceState: MusicServiceState, + val currentMediaItem: AudioItem? = null, val lyrics: LyricDto? = null, val currentLyricIndex: Int? = null, val visualizerPermissions: Boolean = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index a076bd29..7e4b7025 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -12,7 +12,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.model.AudioItem -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateMusicPreferences @@ -23,40 +22,43 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NowPlayingStatus import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.lyricsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes @UnstableApi @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @@ -99,6 +101,9 @@ class NowPlayingViewModel } init { + state.update { + it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem) + } player.addListener(this) addCloseable { player.removeListener(this) @@ -126,62 +131,39 @@ class NowPlayingViewModel }.join() controllerViewState.pulseControls() } - viewModelScope.launchDefault { - backdropService.clearBackdrop() - updateBackdrop(getCurrent()) - } - playbackLoop() + viewModelScope.launchDefault { backdropService.clearBackdrop() } + listenForBackdrop() + listenForLyrics() } fun reportInteraction() { controllerViewState.pulseControls() } - private suspend fun getCurrent(): AudioItem? { - val mediaItem = - onMain { - player.currentMediaItemIndex - .takeIf { it in 0..<player.mediaItemCount } - ?.let { player.getMediaItemAt(it) } - } - return mediaItem?.localConfiguration?.tag as? AudioItem - } - - private fun playbackLoop() { + private fun listenForLyrics() { viewModelScope.launchDefault { - while (isActive) { - val position = onMain { player.currentPosition }.milliseconds -// Timber.v("playbackLoop: %s", position) - getCurrent()?.let { audio -> -// Timber.v("Got current %s", audio.id) - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } + state.map { it.currentMediaItem }.distinctUntilChanged().collectLatest { audio -> + if (audio != null && audio.hasLyrics) { + val lyrics = fetchAndUpdateLyrics(audio) + Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) + while (lyrics != null && isActive) { + val position = onMain { player.currentPosition }.milliseconds + val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO + val lyricPosition = offset + position val lyricIndex = - if (lyrics != null) { - val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO - val lyricPosition = offset + position - lyrics.lyrics - .indexOfLast { - it.start?.ticks?.let { lyricPosition >= it } == true - }.takeIf { it >= 0 } - } else { - null - } -// Timber.v("lyricIndex=$lyricIndex") + lyrics.lyrics + .indexOfLast { + it.start?.ticks?.let { lyricPosition >= it } == true + }.takeIf { it >= 0 } state.update { it.copy( lyrics = lyrics, currentLyricIndex = lyricIndex, ) } + delay(150.milliseconds) } } - - delay(150) } } } @@ -192,100 +174,114 @@ class NowPlayingViewModel ) { val audio = mediaItem?.localConfiguration?.tag as? AudioItem Timber.v("onMediaItemTransition to %s", audio?.id) - updateBackdrop(audio) - viewModelScope.launchDefault { - state.update { - it.copy( - lyrics = null, - currentLyricIndex = null, - ) - } - audio?.let { audio -> - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } - Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) - state.update { - it.copy( - lyrics = lyrics, - ) - } - } - } + state.update { + it.copy( + currentMediaItem = audio, + lyrics = null, + currentLyricIndex = null, + ) } } - private var backDropJob: Job? = null - - private fun updateBackdrop(audio: AudioItem?) { - backDropJob?.cancel() - backDropJob = - viewModelScope.launchDefault { - val showBackdrop = - userPreferencesService - .getCurrent() - .appPreferences.musicPreferences.showBackdrop - if (showBackdrop) { - var backdropItem: BaseItem? = null + private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? = + audio?.let { audio -> + if (audio.hasLyrics) { + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? try { - if (audio?.artistId != null) { - api.userLibraryApi.getItem(audio.artistId).content.let { - if (it.backdropImageTags?.isNotEmpty() == true) { - backdropItem = BaseItem(it, false) - } - } - } - if (backdropItem == null && audio?.albumId != null) { - api.userLibraryApi.getItem(audio.albumId).content.let { - backdropItem = - getBackdropItemForAlbum(api, BaseItem(it, false)) - } - } - if (backdropItem != null) { - doUpdateBackdrop(backdropItem) - } else { - doUpdateBackdropRandom() - } + api.lyricsApi.getLyrics(audio.id).content + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.e(ex, "Error fetching backdrop") - doUpdateBackdropRandom() + Timber.e(ex, "Error fetching lyrics for %s", audio.id) + null } - delay(60.seconds) - doUpdateBackdropRandom() } + } else { + null } - } + } - private suspend fun doUpdateBackdropRandom() { - val randomArtist = - api.itemsApi - .getItems( - recursive = true, - imageTypes = listOf(ImageType.BACKDROP), - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), - sortBy = listOf(ItemSortBy.RANDOM), - limit = 1, - ).content.items - .firstOrNull() - if (randomArtist != null) { - doUpdateBackdrop(BaseItem(randomArtist)) - } else { - clearBackdrop() + private fun listenForBackdrop() { + viewModelScope.launchDefault { + userPreferencesService.flow + .map { it.appPreferences.musicPreferences.showBackdrop } + .combinePair(state.map { it.currentMediaItem }) + .distinctUntilChanged() + .collectLatest { (showBackdrop, audio) -> + if (showBackdrop && audio != null) { + try { + val song = + api.userLibraryApi + .getItem(audio.id) + .content + val songBackdrops = song.backdropImageUrls() + + val artistIds = song.artistItems.orEmpty().map { it.id } + val artists = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + limit = 50, + enableImages = true, + enableImageTypes = listOf(ImageType.BACKDROP), + ), + ).content.items + val artistBackdrops = + artists.flatMap { artist -> artist.backdropImageUrls() } + + val list = songBackdrops.shuffled() + artistBackdrops.shuffled() + Timber.v("Got %s backdrops for %s", list.size, song.id) + if (list.isNotEmpty()) { + val urls = generateSequence { list }.flatten() + urls.forEach { + val (position, duration) = onMain { player.currentPosition to player.duration } + if (duration - position >= 15_000 || state.value.backdropResult == BackdropResult.NONE) { + // Only change if there's backdrop currently, or at 15 seconds left to prevent quick changes + doUpdateBackdrop(audio.id, it) + delay(2.minutes) + } + } + } else { + clearBackdrop() + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching backdrops for %s", audio.id) + clearBackdrop() + } + } else { + clearBackdrop() + } + } } } - private suspend fun doUpdateBackdrop(item: BaseItem) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + private fun BaseItemDto.backdropImageUrls() = + backdropImageTags?.sorted().orEmpty().mapIndexedNotNull { index, tag -> + imageUrlService.getItemImageUrl( + itemId = id, + imageType = ImageType.BACKDROP, + tag = tag, + imageIndex = index, + ) + } + + private suspend fun doUpdateBackdrop( + itemId: UUID, + imageUrl: String, + ) { val (primaryColor, secondaryColor, tertiaryColor) = backdropService.extractColorsFromBackdrop( imageUrl, ) val backdropResult = BackdropResult( - itemId = item.id.toString(), + itemId = itemId.toString(), imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -343,19 +339,9 @@ class NowPlayingViewModel fun updatePreferences(prefs: AppPreferences) { viewModelScope.launchDefault { - var backdropChanged = false preferencesDataStore.updateData { - backdropChanged = - it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop prefs } - if (backdropChanged) { - if (prefs.musicPreferences.showBackdrop) { - updateBackdrop(getCurrent()) - } else { - clearBackdrop() - } - } } } From 1c0e9a585353cea54f6627c9f9865fade06bb2a7 Mon Sep 17 00:00:00 2001 From: Nehereus <55364637+Nehereus@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:36:03 +0800 Subject: [PATCH 336/475] Add a preference option to disable hardware audio offloading (#1564) ## Description By default, Wholphin plays music with hardware audio offloading enabled. This delegates audio decoding to the device's hardware DSP, making playback stability highly dependent on the manufacturer's system audio implementation. On certain hardware (such as my TCL TVs, e.g., the Chinese Model Q9L Pro), this implementation causes music playback to hang indefinitely at 0 seconds. ### Related issues #1372 ### Testing Tested on a TCL TV (Q9L Pro): - Some musics playback hangs at 0s with offloading enabled. - Disabling audio offloading fixes the issues for all medias. ## Screenshots <!-- Please include screenshots if the PR alters any UI elements --> <img width="4284" height="5712" alt="IMG_9486" src="https://github.com/user-attachments/assets/ddf95e68-6ebd-42f9-9565-568de4fbfc42" /> ## AI or LLM usage Developed in pair with Gemini and human-reviewed, tested. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../preferences/AppPreferencesSerializer.kt | 2 + .../preferences/ExperimentalPreference.kt | 12 +++++ .../wholphin/services/MusicService.kt | 11 +++- .../wholphin/services/PlayerFactory.kt | 51 ++++++++++++------- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 61 insertions(+), 18 deletions(-) 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 abcee865..c6dcd8ec 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 @@ -182,6 +182,8 @@ class AppPreferencesSerializer .apply { enabled = false videoTunnelingEnabled = false + preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue + disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue }.build() }.build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index 3950d626..e5923659 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -60,6 +60,17 @@ object ExperimentalPreference { } }, ) + + val DisableAudioOffload = + AppSwitchPreference<AppPreferences>( + title = R.string.disable_audio_offload, + defaultValue = false, + getter = { it.experimentalPreferences.disableAudioOffload }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { disableAudioOffload = value } + }, + summary = R.string.disable_audio_offload_summary, + ) } val experimentalPreferences = @@ -71,6 +82,7 @@ val experimentalPreferences = listOf( ExperimentalPreference.VideoTunneling, ExperimentalPreference.PreferAc3ForSurround, + ExperimentalPreference.DisableAudioOffload, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index eea66307..38888485 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -13,6 +13,7 @@ import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.gt @@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -83,8 +85,15 @@ class MusicService private val audioFormats by lazy { listOf(*supportedAudioCodecs) } val player: Player by lazy { + val disableAudioOffload = + runBlocking { + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .get { disableAudioOffload } ?: false + } playerFactory - .createAudioPlayer() + .createAudioPlayer(disableAudioOffload = disableAudioOffload) .also { it.addListener(MusicPlayerListener(it, _state)) } 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 24736015..cec0a205 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 @@ -132,9 +132,15 @@ class PlayerFactory extractorsFactory, ) } + val disableAudioOffload = + appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false val tunneling = appPreferences.experimentalPreferences.get { videoTunnelingEnabled } - val trackSelector = createTrackSelector(tunneling) + val trackSelector = + createTrackSelector( + tunneling = tunneling, + disableAudioOffload = disableAudioOffload, + ) ExoPlayer .Builder(context) @@ -164,7 +170,10 @@ class PlayerFactory return PlayerCreation(newPlayer, assHandler) } - fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer { + fun createAudioPlayer( + disableAudioOffload: Boolean, + extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK, + ): ExoPlayer { val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -182,7 +191,7 @@ class PlayerFactory OkHttpDataSource.Factory(authOkHttpClient), extractorsFactory, ) - val trackSelector = createTrackSelector() + val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) return ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) @@ -205,20 +214,28 @@ class PlayerFactory .setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true) - private fun createTrackSelector(tunneling: Boolean? = null) = - DefaultTrackSelector(context).apply { - setParameters( - buildUponParameters() - .apply { - tunneling?.let { setTunnelingEnabled(tunneling) } - }.setAudioOffloadPreferences( - AudioOffloadPreferences - .Builder() - .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) - .build(), - ), - ) - } + private fun createTrackSelector( + tunneling: Boolean? = null, + disableAudioOffload: Boolean = false, + ) = DefaultTrackSelector(context).apply { + val offloadMode = + if (disableAudioOffload) { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } else { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } + setParameters( + buildUponParameters() + .apply { + tunneling?.let { setTunnelingEnabled(tunneling) } + }.setAudioOffloadPreferences( + AudioOffloadPreferences + .Builder() + .setAudioOffloadMode(offloadMode) + .build(), + ), + ) + } fun createMediaSession(player: Player) = MediaSession diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 2db6201b..f190b43b 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -225,6 +225,7 @@ message ExperimentalPreferences { bool enabled = 1; bool video_tunneling_enabled = 2; bool prefer_ac3_surround = 3; + bool disable_audio_offload = 4; } message AppPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf37ea15..3b1bd402 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,6 +404,8 @@ <string name="will_remove_pin">Will remove PIN</string> <string name="pin_digits_hint">[0–9]</string> <string name="image_cache_size">Image disk cache size (MB)</string> + <string name="disable_audio_offload">Disable audio hardware offload</string> + <string name="disable_audio_offload_summary">Disabling offloading audio decoding may fix playback freezes on some devices</string> <string name="view_options">View options</string> <string name="columns">Columns</string> <string name="spacing">Spacing</string> From c6d81dada837c701ffc3746c4dafae8b0b3a7b92 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 12 Aug 2026 14:04:19 -0400 Subject: [PATCH 337/475] Fix playlist & collection random sorting on home page (#1820) ## Description Ensure that a playlist or collection's items are random sorted on the home page, if applicable. Previously, the request did sort by random then by sort name resulting in only sorting by sort name. However, there are still issues with random sorting in general, see #1819 ### Related issues Technically fixes #1806 Further bugs in #1819 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 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 0ca1e89e..a2ddae15 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 @@ -859,18 +859,28 @@ class HomeSettingsService sortBy = row.sort?.let { buildList { - add(it.sort) - if (it.sort != ItemSortBy.SORT_NAME) { + if (it.sort == ItemSortBy.RANDOM) { add(ItemSortBy.SORT_NAME) + add(ItemSortBy.RANDOM) + } else { + add(it.sort) + if (it.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } } } }, sortOrder = row.sort?.let { buildList { - add(it.direction) - if (it.sort != ItemSortBy.SORT_NAME) { + if (it.sort == ItemSortBy.RANDOM) { add(SortOrder.ASCENDING) + add(it.direction) + } else { + add(it.direction) + if (it.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } } } }, From 548ec09af318b0a0e180f42a59e852f67ad3febb Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 12 Aug 2026 16:25:30 -0400 Subject: [PATCH 338/475] Fix issue with scrolling to the right episode if there are specials in the season (#1827) ## Description Fixes an edge case where clicking on an episode to go to Series Overview wouldn't scroll to the episode if there is a special (season 0) episodes in the episode list. Dev note: to simplify things, if the entire dataset is cached, then just search with `indexOf`. Often the data is cached because 20 seasons or 100 episodes are fetched initially which covers many shows. ### Related issues Follow up to #1708 ### Testing Emulator and unit tests ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/series/SeriesViewModel.kt | 114 ++++++++++++------ .../damontecres/wholphin/util/RequestPager.kt | 2 +- .../test/TestFindIndexByNumberOrId.kt | 44 +++++++ 3 files changed, 123 insertions(+), 37 deletions(-) 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 05f82a83..eda99f49 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 @@ -177,10 +177,11 @@ class SeriesViewModel viewModelScope.launchIO { val index = (seasons as? ApiRequestPager<*>)?.let { - findIndexByNumberOrId( + findIndexByNumberOrIdFast( seasonEpisodeIds.seasonNumber, seasonEpisodeIds.seasonId, it, + null, ) } ?: 0 Timber.v("Got initial season index: $index") @@ -313,6 +314,7 @@ class SeriesViewModel includeItemTypes = listOf(BaseItemKind.SEASON), sortBy = listOf(ItemSortBy.INDEX_NUMBER), sortOrder = listOf(SortOrder.ASCENDING), + enableUserData = seriesPageType == SeriesPageType.DETAILS, fields = if (seriesPageType == SeriesPageType.DETAILS) { listOf( @@ -331,16 +333,8 @@ class SeriesViewModel request, GetItemsRequestHandler, viewModelScope, - pageSize = 10, + pageSize = 20, ).init(seasonNum ?: 0) -// val seasons = -// GetItemsRequestHandler.execute(api, request).content.items.map { -// BaseItem.from( -// it, -// api, -// ) -// } -// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") pager } @@ -362,6 +356,7 @@ class SeriesViewModel ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CAN_DELETE, + ItemFields.PARENT_ID, ), ) Timber.v( @@ -373,10 +368,10 @@ class SeriesViewModel pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexByNumberOrId(episodeNumber, episodeId, pager) + findIndexByNumberOrIdFast(episodeNumber, episodeId, pager, seasonId) .coerceAtLeast(0) } else { - // Force the first page to to be fetched + // Force the first page to be fetched if (pager.isNotEmpty()) { pager.getBlocking(0) } @@ -728,6 +723,32 @@ private fun checkNumberOrId( equalsNotNull(indexNumber, targetNum) } +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * If the list is smaller than its page size, then the entire dataset is cached and + * [BlockingList.indexOfBlocking] is used, otherwise this function calls [findIndexByNumberOrId] + */ +suspend fun findIndexByNumberOrIdFast( + targetNum: Int?, + targetId: UUID?, + list: ApiRequestPager<*>, + parentId: UUID?, +): Int = + if (list.size <= list.pageSize) { + Timber.v("Using findIndexByNumberOrIdFast indexOfBlocking method") + list.indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + if (parentId != null) { + it?.data?.parentId == parentId + } else { + true + } + } + } else { + findIndexByNumberOrId(targetNum, targetId, list as BlockingList<BaseItem?>, parentId) + } + /** * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] * @@ -743,7 +764,9 @@ suspend fun findIndexByNumberOrId( targetNum: Int?, targetId: UUID?, list: BlockingList<BaseItem?>, + parentId: UUID? = null, ): Int { + Timber.v("Using findIndexByNumberOrId") // Adjust for 1-based numbers val listIndex = targetNum?.minus(1)?.coerceAtLeast(0) val index = @@ -754,36 +777,55 @@ suspend fun findIndexByNumberOrId( checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) }.coerceAtLeast(0) } else if (listIndex != null && listIndex in list.indices) { - // Start searching from the target number and choose direction from there - val num = list.getBlocking(listIndex)?.indexNumber - if (num.lt(targetNum)) { - for (i in listIndex + 1 until list.size) { - val item = list.getBlocking(i) - if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { - return i - } - } - return 0 - } else if (num.gt(targetNum)) { - for (i in listIndex - 1 downTo 0) { - val item = list.getBlocking(i) - if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { - return i - } - } - return 0 - } else { - list - .indexOfBlocking { - checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) - }.coerceAtLeast(0) - } + searchList(listIndex, targetNum, targetId, list, parentId) } else { 0 } return index } +private suspend fun searchList( + listIndex: Int, + targetNum: Int, + targetId: UUID?, + list: BlockingList<BaseItem?>, + parentId: UUID?, +): Int { + val item = list.getBlocking(listIndex) + if (parentId != null && item?.data?.parentId != parentId) { + return if (listIndex - 1 in list.indices) { + searchList(listIndex - 1, targetNum, targetId, list, parentId) + } else if (listIndex + 1 in list.indices) { + searchList(listIndex + 1, targetNum, targetId, list, parentId) + } else { + 0 + } + } + val num = item?.indexNumber + if (num.lt(targetNum)) { + for (i in listIndex + 1 until list.size) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { + return i + } + } + return 0 + } else if (num.gt(targetNum)) { + for (i in listIndex - 1 downTo 0) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { + return i + } + } + return 0 + } else { + return list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + }.coerceAtLeast(0) + } +} + data class SeriesState( val series: DataLoadingState<BaseItem> = DataLoadingState.Pending, val seasons: List<BaseItem?> = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt index d8a34de6..10f0f64f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -24,7 +24,7 @@ import java.util.function.Predicate */ abstract class RequestPager<T>( protected val scope: CoroutineScope, - protected val pageSize: Int = DEFAULT_PAGE_SIZE, + val pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, ) : AbstractList<T?>(), BlockingList<T?> { diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt index 6fe8bd3a..6e840dd8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -11,6 +11,9 @@ import org.junit.Assert import org.junit.Test class TestFindIndexByNumberOrId { + val season0Id = UUID.randomUUID() + val season2Id = UUID.randomUUID() + fun create( indexNumber: Int, parentIndexNumber: Int, @@ -20,6 +23,12 @@ class TestFindIndexByNumberOrId { type = BaseItemKind.EPISODE, indexNumber = indexNumber, parentIndexNumber = parentIndexNumber, + parentId = + when (parentIndexNumber) { + 0 -> season0Id + 2 -> season2Id + else -> null + }, ), ) @@ -151,4 +160,39 @@ class TestFindIndexByNumberOrId { Assert.assertEquals(2, index) } } + + @Test + fun `Test with special in middle`() = + runTest { + val episodes = + BlockingList.of<BaseItem?>( + listOf( + epS02E01, + epS02E02, + epS02E03, + create(6, 0), + epS02E04, + epS02E05, + epS02E06, + ), + ) + + findIndexByNumberOrId( + targetNum = 3, + targetId = epS02E03.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(2, index) + } + + findIndexByNumberOrId( + targetNum = 4, + targetId = epS02E04.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(4, index) + } + } } From e6304f79a2c70f2e22b08de3048cf69f0966e63e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 12 Aug 2026 16:47:08 -0400 Subject: [PATCH 339/475] Fix requesting new seasons after approval (#1816) ## Description Fixes requesting additional seasons of a show if a previous request has already been approved Now if the previous request is already approved/completed/etc, a new request will be created instead of trying to update the previous one. Pending requests will still be updated as before instead of requesting a new one. ### Related issues Fixes #1702 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/DiscoverItem.kt | 17 +++ .../detail/discover/DiscoverSeriesDetails.kt | 3 +- .../discover/DiscoverSeriesViewModel.kt | 104 +++++++++++++++--- .../ui/detail/discover/RequestSeasons.kt | 71 ++++++++---- app/src/main/seerr/seerr-api.yml | 7 +- 5 files changed, 161 insertions(+), 41 deletions(-) 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 ef11aff3..5c5c7c40 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 @@ -71,6 +71,23 @@ enum class SeerrAvailability( } } +@Serializable +enum class RequestStatus( + val status: Int, +) { + UNKNOWN(0), + PENDING(1), + APPROVED(2), + DECLINED(3), + FAILURE(4), + COMPLETED(5), + ; + + companion object { + fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN + } +} + /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. */ 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 947dae96..d02b17eb 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 @@ -86,7 +86,7 @@ fun DiscoverSeriesDetails( val context = LocalContext.current val resources = LocalResources.current val state by viewModel.state.collectAsState() - val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val request4kEnabled by viewModel.request4kEnabled.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } @@ -192,6 +192,7 @@ fun DiscoverSeriesDetails( id = state.tvSeries.successValue?.id ?: -1, title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, + seasons4k = state.seasons4k, request4kEnabled = request4kEnabled, onSubmit = { showRequestSeasonDialog = false 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 4a9eb3f2..3efc3f6e 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 @@ -11,6 +11,7 @@ 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.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.Trailer @@ -36,10 +37,12 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber @@ -66,7 +69,10 @@ class DiscoverSeriesViewModel val state: StateFlow<DiscoverSeriesState> = _state val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } + val request4kEnabled = + seerrServerRepository.current + .map { it?.request4kTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { init() @@ -153,39 +159,99 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } - private fun updateSeasonStatus(tv: TvDetails) { - val seasonStatus = mutableMapOf<Int, SeerrAvailability>() + private suspend fun updateSeasonStatus(tv: TvDetails) { + if (request4kEnabled.first()) { + updateSeasonStatus(tv, true) + } + updateSeasonStatus(tv, false) + } + + private suspend fun updateSeasonStatus( + tv: TvDetails, + is4k: Boolean, + ) { + val currentUserId = seerrServerRepository.currentUserId.first() + val seasonStatus = mutableMapOf<Int, RequestStatus>() + val seasonAvailability = mutableMapOf<Int, SeerrAvailability>() + val editable = mutableMapOf<Int, Boolean>() tv.seasons?.forEach { - it.seasonNumber?.let { - seasonStatus[it] = SeerrAvailability.UNKNOWN + it.seasonNumber?.let { seasonNumber -> + seasonStatus[seasonNumber] = RequestStatus.UNKNOWN + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + seasonAvailability[seasonNumber] = availability } } - val tvStatus = - SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo ?.requests - ?.forEach { - it.seasons?.mapNotNull { season -> + ?.filter { it.is4k == is4k } + ?.forEach { req -> + req.seasons?.mapNotNull { season -> season.seasonNumber?.let { val current = seasonStatus[season.seasonNumber] - val new = - SeerrAvailability - .from(season.status) - ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + // Not status4k because the request itself is marked as is4k or not + val status = season.status + val new = RequestStatus.from(status) if (current == null || new.status > current.status) { seasonStatus[season.seasonNumber] = new } + editable[season.seasonNumber] = + currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status } } } + Timber.v("seasonAvailability=%s", seasonAvailability) Timber.v("seasonStatus=%s", seasonStatus) val requestSeasons = - seasonStatus.mapNotNull { (seasonNumber, availability) -> + seasonStatus.mapNotNull { (seasonNumber, status) -> tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { - RequestSeason(it, availability) + val availability = + when (status) { + RequestStatus.UNKNOWN -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.PENDING -> { + SeerrAvailability.PENDING + } + + RequestStatus.APPROVED -> { + SeerrAvailability.PROCESSING + } + + RequestStatus.DECLINED -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.FAILURE -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.COMPLETED -> { + seasonAvailability.getOrDefault( + seasonNumber, + SeerrAvailability.UNKNOWN, + ) + } + } + RequestSeason( + season = it, + status = status, + availability = availability, + editable = editable.getOrDefault(seasonNumber, true), + ) } } - _state.update { it.copy(seasons = requestSeasons) } + Timber.v("Got %s seasons, is4k=%s", requestSeasons.size, is4k) + _state.update { + if (is4k) { + it.copy(seasons4k = requestSeasons) + } else { + it.copy(seasons = requestSeasons) + } + } } private suspend fun updateCanCancel() { @@ -225,10 +291,11 @@ class DiscoverSeriesViewModel fun request(request: TvRequest) { viewModelScope.launchIO { state.value.tvSeries.successValue?.let { tv -> + val currentUserId = seerrServerRepository.currentUserId.first() val currentRequest = tv.mediaInfo?.requests?.firstOrNull { - it.requestedBy?.id == - seerrServerRepository.currentUserId.first() + it.status == RequestStatus.PENDING.status && + it.requestedBy?.id == currentUserId } try { if (currentRequest != null) { @@ -311,6 +378,7 @@ data class DiscoverSeriesState( val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending, val rating: DiscoverRating? = null, val seasons: List<RequestSeason> = emptyList(), + val seasons4k: List<RequestSeason> = emptyList(), val trailers: List<Trailer> = emptyList(), val people: List<DiscoverItem> = emptyList(), val similar: List<DiscoverItem> = emptyList(), 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 index 7adb018e..46c74c58 100644 --- 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 @@ -38,6 +38,7 @@ 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.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.ui.cards.AvailableIndicator import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator @@ -51,7 +52,9 @@ import com.github.damontecres.wholphin.util.LoadingState data class RequestSeason( val season: Season, + val status: RequestStatus, val availability: SeerrAvailability, + val editable: Boolean, ) @Composable @@ -59,26 +62,45 @@ fun RequestSeasons( id: Int, title: String, seasons: List<RequestSeason>, + seasons4k: List<RequestSeason>, data: SeerrRequestData, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, modifier: Modifier = Modifier, ) { - val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } - val selectedSeasons = - remember { - mutableStateSetOf<Int>( + var is4k by remember { mutableStateOf(request4kEnabled) } + val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } + + val allSeasonNumbers = + remember(seasons) { + seasons + .filter { it.editable } + .mapNotNull { it.season.seasonNumber } + .toSet() + } + val availableSeasons = + remember(seasons) { + mutableStateSetOf( *seasons - .mapNotNull { - if (it.availability > SeerrAvailability.UNKNOWN) { - it.season.seasonNumber - } else { - null - } - }.toTypedArray(), + .filter { season -> + season.status == RequestStatus.PENDING || + season.status == RequestStatus.APPROVED || + season.status == RequestStatus.COMPLETED || + season.availability == SeerrAvailability.PARTIALLY_AVAILABLE || + season.availability == SeerrAvailability.AVAILABLE + }.mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), + ) + } + val selectedSeasons = + remember(seasons) { + mutableStateSetOf<Int>( + *seasons + .filter { season -> season.status == RequestStatus.PENDING } + .mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), ) } - var is4k by remember { mutableStateOf(request4kEnabled) } var profile by remember(is4k) { mutableStateOf( @@ -201,12 +223,12 @@ fun RequestSeasons( } itemsIndexed(seasons) { index, season -> val seasonNumber = season.season.seasonNumber - val isSelected = seasonNumber in selectedSeasons + val checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons SeasonListItem( season = season, - selected = isSelected, + checked = checked, onClick = { - if (isSelected) { + if (checked) { selectedSeasons.remove(seasonNumber) } else { seasonNumber?.let { selectedSeasons.add(it) } @@ -242,11 +264,12 @@ fun RequestSeasons( @Composable fun SeasonListItem( season: RequestSeason, - selected: Boolean, + checked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { ListItem( + enabled = season.editable, selected = false, headlineContent = { Text( @@ -265,9 +288,10 @@ fun SeasonListItem( }, leadingContent = { when (season.availability) { - SeerrAvailability.UNKNOWN -> {} - - SeerrAvailability.DELETED -> {} + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + -> { + } SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, @@ -285,14 +309,14 @@ fun SeasonListItem( SeerrAvailability.BLOCKLISTED -> { // TODO handle block listed -// BlocklistedIndicator() + // BlocklistedIndicator() } } }, trailingContent = { Row { Switch( - checked = selected, + checked = checked, onCheckedChange = { onClick.invoke() }, @@ -362,6 +386,7 @@ fun RequestSeasonsDialog( loading: LoadingState, data: SeerrRequestData, seasons: List<RequestSeason>, + seasons4k: List<RequestSeason>, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, @@ -388,6 +413,7 @@ fun RequestSeasonsDialog( title = title, data = data, seasons = seasons, + seasons4k = seasons4k, request4kEnabled = request4kEnabled, onSubmit = onSubmit, modifier = @@ -416,12 +442,14 @@ fun RequestSeasonsPreview() { seasonNumber = it + 1, episodeCount = 10 + it, ), + status = RequestStatus.UNKNOWN, availability = if (it < 3) { SeerrAvailability.AVAILABLE } else { SeerrAvailability.UNKNOWN }, + editable = it >= 3, ) } @@ -430,6 +458,7 @@ fun RequestSeasonsPreview() { id = 1, title = "Series title", seasons = seasons, + seasons4k = emptyList(), data = SeerrRequestData( profiles4k = diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 3581c599..c9c617cf 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1090,7 +1090,12 @@ components: status: type: integer example: 0 - description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + description: Availability of the season + readOnly: true + status4k: + type: integer + example: 0 + description: Availability of the season in 4K readOnly: true TvShowStatus: type: string From 54966f91246608ae87fe272eb26d16aa2e9d89c5 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 12 Aug 2026 21:01:28 -0400 Subject: [PATCH 340/475] Handle density configuration changes to prevent unnecessary app restarts (#1828) ## Description Add `density` to `configChanges` and handle any changes to density that occur. Thankfully, Compose handles density changes itself, so only changes where a density calculation is "remembered" needed to be updated. Without this, some devices will recreate `MainActivity` when switching resolutions for playback. That recreation would cause playback to end immediately. ### Related issues Fixes #1809 ### Testing Tested on Onn 4k Pro & nvidia shield I was able to reproduce on the Onn, but not the Shield, so this might be device and/or API level specific ## Screenshots N/A ## AI or LLM usage None --- app/src/main/AndroidManifest.xml | 2 +- .../github/damontecres/wholphin/MainActivity.kt | 2 +- .../damontecres/wholphin/ui/cards/BannerCard.kt | 2 +- .../damontecres/wholphin/ui/cards/ChapterCard.kt | 2 +- .../ui/components/CollectionFolderGrid.kt | 2 +- .../wholphin/ui/components/GenreCardGrid.kt | 2 +- .../wholphin/ui/components/StudioCardGrid.kt | 2 +- .../wholphin/ui/detail/PlaylistDetails.kt | 2 +- .../ui/detail/collection/CollectionMixedGrid.kt | 2 +- .../wholphin/ui/playback/PlaybackPage.kt | 6 +++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 15 +++++++++++++++ .../ui/playback/overlay/PlaybackOverlay.kt | 4 ++-- 12 files changed, 31 insertions(+), 12 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5a5994ae..6036dabd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,7 +51,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density"> <intent-filter> <action android:name="android.intent.action.MAIN" /> 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 73ad2cbc..9fa76ea0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -380,7 +380,7 @@ class MainActivity : AppCompatActivity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - Timber.d("onConfigurationChanged") + Timber.d("onConfigurationChanged: newConfig=%s", newConfig) } override fun onNewIntent(intent: Intent) { 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 d4d2ac50..4536a364 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 @@ -73,7 +73,7 @@ fun BannerCard( val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val fillHeight = - remember(cardHeight) { + remember(cardHeight, density) { if (cardHeight.isSpecified) { with(density) { cardHeight.roundToPx() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 634cc988..34966e43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -49,7 +49,7 @@ fun ChapterCard( val density = LocalDensity.current val imageUrlService = LocalImageUrlService.current val imageUrl = - remember(chapter, cardHeight) { + remember(chapter, cardHeight, density) { imageUrlService.getItemImageUrl( itemId = chapter.itemId, imageType = ImageType.CHAPTER, 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 bdf454a8..0493629b 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 @@ -114,7 +114,7 @@ fun CollectionFolderGrid( columns = viewOptions.columns, spacing = viewOptions.spacing.dp, bringIntoViewSpec = - remember(viewOptions) { + remember(viewOptions, density) { val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } if (viewOptions.showDetails) { ScrollToTopBringIntoViewSpec(spacingPx) 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 aeae3fd7..17133f4d 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 @@ -275,7 +275,7 @@ fun GenreCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 461d1d94..78f2cbd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -166,7 +166,7 @@ fun StudioCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 1e570c61..715104f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -796,7 +796,7 @@ fun PlaylistItem( val focused by interactionSource.collectIsFocusedAsState() val imageWidth = 160.dp val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } + val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } ListItem( selected = false, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt index 757b4cf9..057c6db2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -76,7 +76,7 @@ fun CollectionMixedGrid( columns = cardViewOptions.columns, spacing = cardViewOptions.spacing.dp, bringIntoViewSpec = - remember(cardViewOptions) { + remember(cardViewOptions, density) { val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } if (cardViewOptions.showDetails) { ScrollToTopBringIntoViewSpec(spacingPx) 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 e428546f..209e8473 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 @@ -165,6 +165,7 @@ fun PlaybackPageContent( val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val density = LocalDensity.current + LaunchedEffect(density) { viewModel.updateDensity(density) } val userDto by viewModel.currentUserDto.collectAsState() var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -504,7 +505,9 @@ fun PlaybackPageContent( val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } - var cueCount by remember { mutableIntStateOf(0) } + + // Reset cueCount when density changes to force re-applying subtitle style + var cueCount by remember(density) { mutableIntStateOf(0) } val subtitleVisible = skipIndicatorDuration == 0L && @@ -540,6 +543,7 @@ fun PlaybackPageContent( } subtitleView.setCues(state.subtitleCues) if (state.subtitleCues.size > cueCount) { + Timber.i("Applying subtitle style to SubtitleView") // The output creates a painter for each cue, so need to apply the changes when the number of cues increases Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(subtitleView) 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 ceade4c0..009f3cc2 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 @@ -1636,6 +1636,21 @@ class PlaybackViewModel } } + fun updateDensity(density: Density) { + Timber.d("Density changed") + viewModelScope.launchDefault { + val availableCommands = onMain { player.availableCommands } + if (availableCommands.contains(Player.COMMAND_PREPARE) && player is MpvPlayer) { + Timber.i("Applying density change for subtitle config to MPV") + val configuration = context.resources.configuration + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, + ) + } + } + } + override fun onBandwidthEstimate( eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 5f5891de..a1a83df6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -100,11 +100,11 @@ fun PlaybackOverlay( val density = LocalDensity.current val titleHeight = - remember(item?.title) { + remember(item?.title, density) { if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp } val subtitleHeight = - remember(item?.subtitleLong) { + remember(item?.subtitleLong, density) { if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp } From 51ab39ed3bdef5f2efecf76d79861c145e8a1408 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 13 Aug 2026 12:36:20 -0400 Subject: [PATCH 341/475] Several minor bug fixes (#1811) ## Description Fixes several small bugs and does some minor refactoring: - Use "Recently Added in XYZ" instead of "Recently recorded" for recorded tv libraries - Improve home page query performance by using fewer fields in the query - When an error occurs connecting to discovered servers, show the error instead of only logging - Fix headers sometimes appearing briefly on some pages when navigating back - Catch exceptions in socket connections - Use a larger buffer when downloading updates - Split up ADB logging for media info ### Related issues Related to https://github.com/damontecres/Wholphin/issues/1517#issuecomment-5184316086 Maybe the larger download buffer will help with https://github.com/damontecres/Wholphin/issues/1696 Will help show the error in #1801 Prevents the crash in #1786 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 61 ++-- .../wholphin/services/MediaReportService.kt | 20 +- .../wholphin/services/ServerEventListener.kt | 3 + .../wholphin/services/UpdateChecker.kt | 47 +-- .../damontecres/wholphin/ui/UiConstants.kt | 12 +- .../wholphin/ui/cards/ItemCardImage.kt | 4 +- .../ui/components/CollectionFolderList.kt | 3 +- .../ui/detail/CollectionFolderGeneric.kt | 3 +- .../ui/detail/CollectionFolderPhotoAlbum.kt | 3 +- .../ui/detail/CollectionFolderPlaylist.kt | 4 +- .../ui/detail/CollectionFolderRecordings.kt | 4 +- .../ui/discover/DiscoverRequestGrid.kt | 3 +- .../wholphin/ui/main/SearchPage.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 3 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 3 + .../wholphin/ui/setup/SwitchServerContent.kt | 332 +++++++++--------- .../ui/setup/SwitchServerViewModel.kt | 25 +- 17 files changed, 282 insertions(+), 250 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 a2ddae15..934e440d 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 @@ -12,9 +12,8 @@ import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.HomeItemFields import com.github.damontecres.wholphin.ui.ProgramItemFields -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 @@ -62,6 +61,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.UserDto @@ -280,7 +280,7 @@ class HomeSettingsService libraries .mapIndexed { index, it -> val parentId = it.itemId - val title = getRecentlyAddedTitle(it) + val title = getRecentlyAddedTitle(it.name) if (it.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, @@ -766,13 +766,11 @@ class HomeSettingsService } is HomeRowConfig.RecentlyAdded -> { - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(library) + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(library?.name) val request = GetLatestMediaRequest( - fields = SlimItemFields, + fields = library.itemFields, imageTypeLimit = 1, parentId = row.parentId, groupItems = true, @@ -797,12 +795,9 @@ class HomeSettingsService } is HomeRowConfig.RecentlyReleased -> { - val name = - libraries - .firstOrNull { it.itemId == row.parentId } - ?.name + val library = libraries.firstOrNull { it.itemId == row.parentId } val title = - name?.let { + library?.name?.let { ResArgStringProvider(R.string.recently_released_in, it) } ?: ResStringProvider(R.string.recently_released) val request = @@ -821,7 +816,7 @@ class HomeSettingsService SortOrder.ASCENDING, SortOrder.DESCENDING, ), - fields = DefaultItemFields, + fields = library.itemFields, recursive = true, maxPremiereDate = LocalDateTime.now(), isUnaired = false, @@ -851,6 +846,7 @@ class HomeSettingsService } is HomeRowConfig.ByParent -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } val request = GetItemsRequest( userId = userDto.id, @@ -885,7 +881,7 @@ class HomeSettingsService } }, limit = limit, - fields = DefaultItemFields, + fields = library.itemFields, ) // Not using getItemName because we want to throw the 404 @@ -970,7 +966,7 @@ class HomeSettingsService GetPersonsRequest( userId = userDto.id, limit = limit, - fields = DefaultItemFields, + fields = HomeItemFields, isFavorite = true, enableImages = true, enableImageTypes = listOf(ImageType.PRIMARY), @@ -988,12 +984,18 @@ class HomeSettingsService ) } } else { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } val request = GetItemsRequest( userId = userDto.id, recursive = true, limit = limit, - fields = DefaultItemFields, + fields = fields, includeItemTypes = listOf(row.kind), isFavorite = true, ) @@ -1027,7 +1029,7 @@ class HomeSettingsService GetRecordingsRequest( userId = userDto.id, isInProgress = true, - fields = DefaultItemFields, + fields = HomeItemFields, limit = limit, enableImages = true, enableUserData = true, @@ -1098,7 +1100,7 @@ class HomeSettingsService val request = GetLiveTvChannelsRequest( userId = userDto.id, - fields = DefaultItemFields, + fields = HomeItemFields, limit = limit, enableImages = true, ) @@ -1225,10 +1227,17 @@ class UnsupportedHomeSettingsVersionException( val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") -fun getRecentlyAddedTitle(library: Library?): StringProvider = - if (library?.isRecordingFolder == true) { - ResStringProvider(R.string.recently_recorded) - } else { - library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) } - ?: ResStringProvider(R.string.recently_added) - } +fun getRecentlyAddedTitle(name: String?): StringProvider = + name?.let { ResArgStringProvider(R.string.recently_added_in, it) } + ?: ResStringProvider(R.string.recently_added) + +private val Library?.itemFields: List<ItemFields> + get() = + if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { + // Get child count to show in the header + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt index 67ef91e8..c60e12af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler +import com.google.protobuf.MessageLiteOrBuilder import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json @@ -76,21 +77,18 @@ class MediaReportService model=${Build.MODEL} apiLevel=${Build.VERSION.SDK_INT} - playbackPrefs=${ - appPreferences.playbackPreferences.toString().replace("\n", ", ") - .replace("\t", " ") - } - experimental=${ - appPreferences.experimentalPreferences.toString().replace("\n", ", ") - .replace("\t", " ") - } - - deviceProfile=$deviceProfileJson + playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} + experimental=${appPreferences.experimentalPreferences.toStringOneLine()} mediaSources=$sourcesJson + + deviceProfile=$deviceProfileJson """.trimIndent() - Timber.w(body) + body.chunked(2048).forEach { Timber.w(it) } + Timber.w("End send media info") val response by api.clientLogApi.logFile(body) showToast(context, "Sent! Filename=${response.fileName}") } } + +private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt index ec77c65c..708eb8b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.jellyfin.sdk.api.client.ApiClient @@ -94,6 +95,8 @@ class ServerEventListener .joinToString("\n") showToast(context, toast, Toast.LENGTH_LONG) } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") }.launchIn(activity.lifecycleScope) } 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 7fbcbe53..8c2d84cb 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.Manifest +import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.content.Intent @@ -61,8 +62,6 @@ class UpdateChecker private const val APK_MIME_TYPE = "application/vnd.android.package-archive" - private const val PERMISSION_REQUEST_CODE = 123456 - private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") val ACTIVE = BuildConfig.UPDATING_ENABLED @@ -158,8 +157,8 @@ class UpdateChecker private fun getRelease(request: Request): Release? { return okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { - val result = Json.parseToJsonElement(it.body!!.string()) + if (it.isSuccessful) { + val result = Json.parseToJsonElement(it.body.string()) val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull val version = Version.tryFromString(name) val publishedAt = @@ -195,6 +194,7 @@ class UpdateChecker /** * Download and install an update */ + @SuppressLint("RequestInstallPackagesPolicy") suspend fun installRelease( release: Release, callback: DownloadCallback, @@ -208,7 +208,7 @@ class UpdateChecker .get() .build() okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { + if (it.isSuccessful) { Timber.v("Request successful for ${release.downloadUrl}") withContext(WholphinDispatchers.Main) { callback.contentLength(it.body.contentLength()) @@ -230,7 +230,7 @@ class UpdateChecker contentValues, ) if (uri != null) { - it.body!!.byteStream().use { input -> + it.body.byteStream().use { input -> resolver.openOutputStream(uri).use { output -> copyTo(input, output!!, callback = callback) } @@ -290,7 +290,7 @@ class UpdateChecker downloadDir.mkdirs() val targetFile = File(downloadDir, APK_NAME) targetFile.outputStream().use { output -> - response.body!!.byteStream().use { input -> + response.body.byteStream().use { input -> copyTo(input, output, callback = callback) } } @@ -374,7 +374,7 @@ data class Release( Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), "#$1", ) - // Remove the last line for full changelog since its just a link + // Remove the last line for full changelog since it's just a link .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") } @@ -387,29 +387,30 @@ interface DownloadCallback { suspend fun copyTo( input: InputStream, out: OutputStream, - bufferSize: Int = 16 * 1024, + bufferSize: Int = 64 * 1024, callback: DownloadCallback, -): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(bufferSize) - var bytes = input.read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - withContext(WholphinDispatchers.Main) { - callback.bytesDownloaded(bytesCopied) +): Long = + withContext(WholphinDispatchers.IO) { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = input.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + withContext(WholphinDispatchers.Main) { + callback.bytesDownloaded(bytesCopied) + } + bytes = input.read(buffer) } - bytes = input.read(buffer) + return@withContext bytesCopied } - return bytesCopied -} fun getDownloadUrl( assets: JsonArray, debug: Boolean, - supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), + supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), ): String? { - val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" } + val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" } val releaseSuffix = if (debug) "-debug" else "-release" val preferredNames = buildList { 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 7dbcac1e..b91e2b45 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 @@ -49,8 +49,6 @@ const val DEFAULT_PAGE_SIZE = 100 */ val DefaultItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.TRICKPLAY, @@ -58,7 +56,6 @@ val DefaultItemFields = ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, ItemFields.CAN_DELETE, ) @@ -67,13 +64,16 @@ val DefaultItemFields = */ val SlimItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, + ) + +val HomeItemFields = + listOf( + ItemFields.OVERVIEW, ItemFields.CAN_DELETE, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 3f7a6b1a..aaa3de97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -265,7 +265,7 @@ fun ItemCardImageOverlay( } } - watchedPercent?.let { percent -> + if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { Box( modifier = Modifier @@ -274,7 +274,7 @@ fun ItemCardImageOverlay( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) .height(Cards.playedPercentHeight) - .fillMaxWidth((percent / 100.0).toFloat()), + .fillMaxWidth((watchedPercent / 100.0).toFloat()), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 3ae9085f..a048ed94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -99,7 +100,7 @@ fun CollectionFolderList( val positionFocusRequester = remember { FocusRequester() } val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } fun jumpTo(newPosition: Int) { scope.launch { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 7d5c262c..ccd7e556 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -32,7 +33,7 @@ fun CollectionFolderGeneric( sortOptions: List<ItemSortBy> = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val viewOptions = remember(usePosters) { if (usePosters) { 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 ce4c7ec8..02ecfd9f 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 @@ -5,6 +5,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -48,7 +49,7 @@ fun CollectionFolderPhotoAlbum( ) }, ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val state by viewModel.state.collectAsState() CollectionFolderView( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 30a40fcd..70acd69b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,7 +24,7 @@ fun CollectionFolderPlaylist( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 0279bd72..95fcaf00 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,7 +24,7 @@ fun CollectionFolderRecordings( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index 57a43ba4..3d6d808e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -259,7 +260,7 @@ fun DiscoverRequestGrid( ) { val state by viewModel.state.collectAsState() - var showHeader by remember { mutableStateOf(state.startIndex < 6) } + var showHeader by rememberSaveable { mutableStateOf(state.startIndex < 6) } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, 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 81770c9d..1709e588 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 @@ -576,7 +576,7 @@ fun SearchPage( viewModel.navigationManager.navigateTo(dest) } - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val positionCallback = { columns: Int, index: Int -> showHeader = index < columns } 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 4fb8398e..ff8099c7 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 @@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.getRecentlyAddedTitle import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.AspectRatio @@ -381,7 +382,7 @@ class HomeSettingsViewModel } LibraryRowType.RECENTLY_RECORDED -> { - val title = ResStringProvider(R.string.recently_recorded) + val title = getRecentlyAddedTitle(library.name) HomeRowConfigDisplay( id = id, title = title, 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 009f3cc2..79d2ae57 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 @@ -84,6 +84,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -1470,6 +1471,8 @@ class PlaybackViewModel } } } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") }.launchIn(viewModelScope) /** 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 71483c0a..24a8e0ca 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 @@ -278,188 +278,182 @@ private fun SwitchServerContentInternal( } if (showAddServer) { - var showEnterAddress by remember { mutableStateOf(false) } + AddServerDialog( + onDismissRequest = { showAddServer = false }, + viewModel = viewModel, + ) + } + } +} - LaunchedEffect(Unit) { - viewModel.clearAddServerState() - if (!showEnterAddress) { - viewModel.discoverServers() - } - } +@Composable +private fun AddServerDialog( + onDismissRequest: () -> Unit, + viewModel: SwitchServerViewModel, +) { + val state by viewModel.state.collectAsState() + var showEnterAddress by remember { mutableStateOf(false) } - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) - val filteredDiscoveredServers = - remember(state.discoveredServers) { - val seenUrls = mutableSetOf<String>() - state.discoveredServers.filter { server -> - val normalizedUrl = server.url.lowercase().trim() - if (normalizedUrl in seenUrls) { - false // Duplicate, filter it out - } else { - seenUrls.add(normalizedUrl) - true // First occurrence, keep it + LaunchedEffect(Unit) { + viewModel.clearAddServerState() + if (!showEnterAddress) { + viewModel.discoverServers() + } + } + + val firstDiscoveredServerFocusRequester = remember { FocusRequester() } + + // Default focus to first discovered server if available + LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { + if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { + firstDiscoveredServerFocusRequester.tryRequestFocus() + } + } + + BasicDialog( + onDismissRequest = { + showEnterAddress = false + viewModel.clearAddServerState() + onDismissRequest.invoke() + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 10.dp, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (!showEnterAddress) { + // Show discovered servers first + Text( + text = stringResource(R.string.discovered_servers), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (state.discoveredServers.isEmpty()) { + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + ) { + items( + state.discoveredServers.size, + key = { state.discoveredServers[it].url }, + ) { index -> + val server = state.discoveredServers[index] + val focusRequester = + if (index == 0) { + firstDiscoveredServerFocusRequester + } else { + remember { FocusRequester() } + } + + ListItem( + enabled = true, + selected = false, + headlineContent = { + Text( + text = + server.name?.ifBlank { null } + ?: server.url, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + viewModel.addServer(server.url, showToast = true) + }, + modifier = Modifier.focusRequester(focusRequester), + ) } } } - val firstDiscoveredServerFocusRequester = remember { FocusRequester() } - - // Default focus to first discovered server if available - LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) { - if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) { - firstDiscoveredServerFocusRequester.tryRequestFocus() + TextButton( + onClick = { + showEnterAddress = true + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = stringResource(R.string.enter_server_address)) } - } + } else { + // Show enter server address form + val addServerState = state.addServerState + var url by remember { mutableStateOf("") } + val submit = { + viewModel.addServer(url, showToast = false) + } + val textBoxFocusRequester = remember { FocusRequester() } - BasicDialog( - onDismissRequest = { - showAddServer = false - showEnterAddress = false + LaunchedEffect(Unit) { + textBoxFocusRequester.tryRequestFocus() + } + LaunchedEffect(url) { viewModel.clearAddServerState() - }, - properties = DialogProperties(usePlatformDefaultWidth = false), - elevation = 10.dp, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + } + + Text( + text = stringResource(R.string.enter_server_url), + ) + EditTextBox( + value = url, + onValueChange = { url = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { submit.invoke() }, + ), modifier = Modifier - .padding(16.dp) - .fillMaxWidth(.4f), + .testTag("server_url_text") + .focusRequester(textBoxFocusRequester) + .fillMaxWidth(), + ) + when (val st = addServerState) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } + TextButton( + onClick = { submit.invoke() }, + enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, + modifier = Modifier, ) { - if (!showEnterAddress) { - // Show discovered servers first - Text( - text = stringResource(R.string.discovered_servers), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else if (filteredDiscoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.no_servers_found), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 300.dp), - ) { - items( - filteredDiscoveredServers.size, - key = { filteredDiscoveredServers[it].url }, - ) { index -> - val server = filteredDiscoveredServers[index] - val focusRequester = - if (index == 0) { - firstDiscoveredServerFocusRequester - } else { - remember { FocusRequester() } - } - - ListItem( - enabled = true, - selected = false, - headlineContent = { - Text( - text = - server.name?.ifBlank { null } - ?: server.url, - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - text = server.url, - style = MaterialTheme.typography.bodyMedium, - ) - }, - onClick = { - viewModel.addServer(server.url) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - } - - TextButton( - onClick = { - showEnterAddress = true - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text(text = stringResource(R.string.enter_server_address)) - } + if (addServerState == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) } else { - // Show enter server address form - val addServerState = state.addServerState - var url by remember { mutableStateOf("") } - val submit = { - viewModel.addServer(url) - } - val textBoxFocusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - textBoxFocusRequester.tryRequestFocus() - } - - Text( - text = stringResource(R.string.enter_server_url), - ) - EditTextBox( - value = url, - onValueChange = { url = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { submit.invoke() }, - ), - modifier = - Modifier - .testTag("server_url_text") - .focusRequester(textBoxFocusRequester) - .fillMaxWidth(), - ) - when (val st = addServerState) { - is LoadingState.Error -> { - Text( - text = - st.message ?: st.exception?.localizedMessage - ?: "An error occurred", - color = MaterialTheme.colorScheme.error, - ) - } - - else -> {} - } - TextButton( - onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, - modifier = Modifier, - ) { - if (addServerState == LoadingState.Loading) { - CircularProgress(Modifier.size(32.dp)) - } else { - Text(text = stringResource(R.string.submit)) - } - } + Text(text = stringResource(R.string.submit)) } } } 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 209320eb..7098c118 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue @@ -134,6 +135,14 @@ class SwitchServerViewModel .getPublicSystemInfo() .content ServerConnectionStatus.Success(systemInfo) + } catch (ex: TimeoutException) { + Timber.w(ex, "timeout checking server ${server.url}") + val cause = ex.cause + if (cause is java.net.ConnectException) { + ServerConnectionStatus.Error("${cause.localizedMessage}") + } else { + ServerConnectionStatus.Error(ex.localizedMessage) + } } catch (ex: Exception) { Timber.w(ex, "Error checking server ${server.url}") ServerConnectionStatus.Error(ex.localizedMessage) @@ -155,12 +164,15 @@ class SwitchServerViewModel serverRepository.addAndChangeServer(updatedServer) navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } else if (result is ServerConnectionStatus.Error) { - showToast(context, "Error connecting: $${result.message}") + showToast(context, "Error connecting: ${result.message}") } } } - fun addServer(inputUrl: String) { + fun addServer( + inputUrl: String, + showToast: Boolean, + ) { _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { @@ -186,7 +198,9 @@ class SwitchServerViewModel _state.update { it.copy(addServerState = LoadingState.Success) } navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) } + val msg = "Server returned invalid response" + _state.update { it.copy(addServerState = LoadingState.Error(msg)) } + if (showToast) showToast(context, msg) } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -215,10 +229,12 @@ class SwitchServerViewModel } val message = "Error, tried addresses:\n$errors" _state.update { it.copy(addServerState = LoadingState.Error(message)) } + if (showToast) showToast(context, message) } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } + if (showToast) showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -247,6 +263,9 @@ class SwitchServerViewModel .toMutableList() .apply { add(jellyfinServer) + }.distinctBy { + // Filter out duplicates within the discovered servers list (same URL appearing multiple times) + it.url.lowercase().trim() }, ) } From ba52915cf04af986b00de98c2ba8f8b7cde88847 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:40:22 -0400 Subject: [PATCH 342/475] Update actions/setup-python action to v7 (#1822) --- .github/actions/setup/action.yml | 2 +- .github/workflows/pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 14aa7950..bf6de7a1 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,7 +6,7 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Setup JDK diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 18a42f69..ffb33c03 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Run pre-commit From 2110d846c1b0a4cc1d395ff7b007c05b99c82f20 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 13 Aug 2026 13:57:51 -0400 Subject: [PATCH 343/475] Fix some bugs on discover pages (#1832) ## Description Fix some bugs on discover pages: - UI fixes for available seasons - Fix toggle not updating when removing a season from a pending request - Don't request images if the path is null - Better error handling when fetching ratings ### Related issues Follow up to #1816 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/SeerrService.kt | 32 +++++++------- .../detail/discover/DiscoverMovieViewModel.kt | 19 ++++++-- .../discover/DiscoverSeriesViewModel.kt | 44 +++++++++++++++---- .../ui/detail/discover/DiscoverUtils.kt | 26 +++++++++++ .../ui/detail/discover/RequestSeasons.kt | 12 ++--- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 22 +++------- app/src/main/seerr/seerr-api.yml | 6 +++ 7 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt 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 1a484f90..df9488ba 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 @@ -184,21 +184,23 @@ class SeerrService ) } } - 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/w${backdropWidth}_and_h${backdropHeight}_multi_faces" - else -> throw IllegalArgumentException("Image type not supported: $imageType") - } - return "${base}${prefix}$path" + return path?.takeIf { it.isNotNullOrBlank() }?.let { + 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/w${backdropWidth}_and_h${backdropHeight}_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" + } } suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData { 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 9afde46e..86c55d38 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 @@ -11,6 +11,7 @@ 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.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer @@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -100,9 +102,13 @@ class DiscoverMovieViewModel updateCanCancel() viewModelScope.launchIO { - val result = - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) - _state.update { it.copy(rating = DiscoverRating(result)) } + val rating = + getDiscoverRating(item.id) { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), + ) + } + _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { viewModelScope.launchIO { @@ -248,7 +254,12 @@ fun canUserCancelRequest( ( // User requested this user.hasPermission(SeerrPermission.REQUEST) && - requests?.any { it.requestedBy?.id == user?.id } == true + requests + .orEmpty() + .any { + equalsNotNull(it.requestedBy?.id, user?.id) && + RequestStatus.from(it.status) < RequestStatus.APPROVED + } ) data class DiscoverMovieState( 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 3efc3f6e..b473d510 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 @@ -105,8 +105,11 @@ class DiscoverSeriesViewModel updateCanCancel() viewModelScope.launchIO { - val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) - _state.update { it.copy(rating = DiscoverRating(result)) } + val rating = + getDiscoverRating(item.id) { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } + _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { viewModelScope.launchIO { @@ -183,6 +186,18 @@ class DiscoverSeriesViewModel seasonAvailability[seasonNumber] = availability } } + tv.mediaInfo?.seasons?.forEach { + it.seasonNumber?.let { seasonNumber -> + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + val current = + seasonAvailability.getOrDefault(seasonNumber, SeerrAvailability.UNKNOWN) + if (availability > current) { + seasonAvailability[seasonNumber] = availability + } + } + } tv.mediaInfo ?.requests @@ -209,10 +224,6 @@ class DiscoverSeriesViewModel tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { val availability = when (status) { - RequestStatus.UNKNOWN -> { - SeerrAvailability.UNKNOWN - } - RequestStatus.PENDING -> { SeerrAvailability.PENDING } @@ -229,22 +240,39 @@ class DiscoverSeriesViewModel SeerrAvailability.UNKNOWN } - RequestStatus.COMPLETED -> { + RequestStatus.UNKNOWN, + RequestStatus.COMPLETED, + -> { seasonAvailability.getOrDefault( seasonNumber, SeerrAvailability.UNKNOWN, ) } } + val defaultEditable = + availability != SeerrAvailability.AVAILABLE && + availability != SeerrAvailability.PARTIALLY_AVAILABLE && + availability != SeerrAvailability.PROCESSING && + availability != SeerrAvailability.BLOCKLISTED RequestSeason( season = it, status = status, availability = availability, - editable = editable.getOrDefault(seasonNumber, true), + editable = editable.getOrDefault(seasonNumber, defaultEditable), ) } } Timber.v("Got %s seasons, is4k=%s", requestSeasons.size, is4k) +// requestSeasons.forEach { +// Timber.v( +// "is4k=%s, season %s: availability=%s, status=%s, editable=%s", +// is4k, +// it.season.seasonNumber, +// it.availability, +// it.status, +// it.editable, +// ) +// } _state.update { if (is4k) { it.copy(seasons4k = requestSeasons) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt new file mode 100644 index 00000000..8dfc4290 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.data.model.DiscoverRating +import kotlinx.coroutines.CancellationException +import timber.log.Timber + +suspend fun getDiscoverRating( + id: Int, + func: suspend () -> DiscoverRating, +): DiscoverRating? = + try { + func.invoke() + } catch (ex: CancellationException) { + throw ex + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + Timber.w("No rating for %s", id) + } else { + Timber.e(ex, "Error fetching rating for %s", id) + } + null + } catch (ex: Exception) { + Timber.e(ex, "Error fetching rating for %s", id) + null + } 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 index 46c74c58..c482484e 100644 --- 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 @@ -83,11 +83,13 @@ fun RequestSeasons( mutableStateSetOf( *seasons .filter { season -> - season.status == RequestStatus.PENDING || - season.status == RequestStatus.APPROVED || - season.status == RequestStatus.COMPLETED || - season.availability == SeerrAvailability.PARTIALLY_AVAILABLE || - season.availability == SeerrAvailability.AVAILABLE + !season.editable && ( + season.status == RequestStatus.PENDING || + season.status == RequestStatus.APPROVED || + season.status == RequestStatus.COMPLETED || + season.availability == SeerrAvailability.PARTIALLY_AVAILABLE || + season.availability == SeerrAvailability.AVAILABLE + ) }.mapNotNull { season -> season.season.seasonNumber } .toTypedArray(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 360bdfb5..9702f9fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -30,7 +30,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating @@ -42,6 +41,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -262,13 +262,11 @@ class SeerrDiscoverViewModel return@launchIO } val result = - try { + getDiscoverRating(id) { when (type) { SeerrItemType.MOVIE -> { DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = id, - ), + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id), ) } @@ -284,18 +282,8 @@ class SeerrDiscoverViewModel DiscoverRating(null, null) } } - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - Timber.w("No rating found for %s", id) - DiscoverRating(null, null) - } else { - Timber.e(ex, "Error getting rating for %s", id) - return@launchIO - } - } catch (ex: Exception) { - Timber.e(ex, "Error getting rating for %s", id) - return@launchIO - } + } ?: DiscoverRating(null, null) + ratingCache.put(id, result) rating.update { ratingCache.asMap().toMap() diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index c9c617cf..354f3298 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1304,6 +1304,12 @@ components: type: integer example: 0 description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + seasons: + type: array + readOnly: true + nullable: true + items: + $ref: '#/components/schemas/Season' requests: type: array readOnly: true From fe943fef4e3345f36990978cd52e5ae466c89533 Mon Sep 17 00:00:00 2001 From: nofkclue <nofkclue@noreply.codeberg.org> Date: Mon, 3 Aug 2026 06:00:08 +0000 Subject: [PATCH 344/475] Translated using Weblate (German) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b764f319..0234f9ab 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -695,4 +695,11 @@ <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> <string name="user_views">Benutzeransicht</string> + <string name="appears_on">Erscheint auf</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Kleine Suchleiste am unteren Rand des Bildschirms (Standard)</string> + <string name="brown">Braun</string> + <string name="dpad_seek_mode_trickplay_summary">Suchleiste mit Trickplay Vorschaubildern</string> + <string name="dpad_seek_mode_none_summary">Keine Suchleiste</string> + <string name="server_version_not_supported">Nicht unterstützte Server-Version</string> </resources> From b12b078293d98a960d7f2b0b77fbd83d8cac6991 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Mon, 3 Aug 2026 11:31:13 +0000 Subject: [PATCH 345/475] Translated using Weblate (Slovak) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 27aa6dda..f1dd7353 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -729,8 +729,8 @@ <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> <string name="any_language">Akýkoľvek jazyk</string> - <string name="d_pad_seek_mode_title">Režim pretáčania pomocou D-Padu</string> - <string name="dpad_seek_mode_trickplay">Trickplay náhľady</string> + <string name="d_pad_seek_mode_title">Režim panela pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> <string name="hash_matches">Hash zhody</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="programs">TV programy</string> @@ -740,4 +740,10 @@ <string name="books">Knihy</string> <string name="audio_books">Audioknihy</string> <string name="brown">Hnedá</string> + <string name="dpad_seek_mode_minimal">Minimálne</string> + <string name="dpad_seek_mode_minimal_summary">Malý panel pretáčania v dolnej časti obrazovky (predvolené)</string> + <string name="dpad_seek_mode_trickplay_summary">Panel pretáčania s náhľadmi Trickplay</string> + <string name="dpad_seek_mode_none_summary">Bez panela pretáčania</string> + <string name="server_version_not_supported">Verzia servera nie je podporovaná</string> + <string name="appears_on">Objavuje sa v</string> </resources> From fc9d2287d7dc538f79b6580834143c0cb72ddd69 Mon Sep 17 00:00:00 2001 From: ymir <ymir@noreply.codeberg.org> Date: Mon, 3 Aug 2026 08:50:23 +0000 Subject: [PATCH 346/475] Translated using Weblate (French) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d129132c..1608ec08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -179,6 +179,8 @@ <string name="ui_interface">Interface</string> <string name="video_scale">Échelle de la vidéo</string> <plurals name="years_old"> + <item quantity="one">%1$d an</item> + <item quantity="many">%1$d ans</item> <item quantity="other">%1$d ans</item> </plurals> <string name="italic_font">Mettre la police en italique</string> @@ -721,4 +723,5 @@ <string name="books">Livres</string> <string name="audio_books">Livre audio</string> <string name="server_version_not_supported">Version du serveur non prise en charge</string> + <string name="appears_on">Apparaît dans</string> </resources> From f7509767b09edb3745a22a72b12df828eb57a8fc Mon Sep 17 00:00:00 2001 From: outopik <outopik@noreply.codeberg.org> Date: Mon, 3 Aug 2026 08:51:31 +0000 Subject: [PATCH 347/475] Translated using Weblate (French) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1608ec08..4d7506ed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -698,9 +698,9 @@ <string name="subtitle_mode_none">Aucun</string> <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> <string name="preferred_audio_language">Langue audio préférée</string> - <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur provenant du serveur</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur à partir du serveur</string> <string name="subtitle_mode">Mode sous-titres</string> - <string name="use_user_profile">Accédez aux paramètres de votre profil</string> + <string name="use_user_profile">Utiliser vos paramètres</string> <string name="subtitle_mode_default">Par défaut</string> <string name="subtitle_mode_smart">Intelligent</string> <string name="subtitle_mode_always">Toujours</string> From 884735df60c6d77bc2597bcf3080e106dbeee4e2 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Mon, 3 Aug 2026 19:07:56 +0000 Subject: [PATCH 348/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index a098fbf1..c6c2f2db 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -140,7 +140,7 @@ <string name="mark_watched">סמן כנצפה</string> <string name="max_bitrate">מקסימום קצב נתונים</string> <string name="more_like_this">עוד דומים</string> - <string name="download_and_update"><![CDATA[הורד & עדכן]]></string> + <string name="download_and_update"><![CDATA[הורדה ועדכון]]></string> <string name="forced_track">כפוי</string> <string name="only_forced_subtitles">כתוביות כפויות בלבד</string> <string name="hide_controller_timeout">הסתר בקרי ניגון</string> @@ -171,7 +171,7 @@ <string name="restart">התחל מחדש</string> <string name="resume">המשך ניגון</string> <string name="save">שמור</string> - <string name="search_and_download"><![CDATA[חיפוש & הורדה]]></string> + <string name="search_and_download"><![CDATA[חיפוש והורדה]]></string> <string name="voice_search">חיפוש קולי</string> <string name="voice_search_prompt">חפש בקולך</string> <string name="processing">מעבד</string> @@ -723,4 +723,5 @@ <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> <string name="audio_books">ספרי שמע</string> <string name="hash_matches">התאמות של פונקציית־גיבוב</string> + <string name="appears_on">מופיע ב־</string> </resources> From c10e410fd337ac364d439841f8aa85d29cb6790b Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Mon, 3 Aug 2026 05:05:18 +0000 Subject: [PATCH 349/475] Translated using Weblate (Polish) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b2d934ce..de796d6d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -745,4 +745,5 @@ <string name="books">Książki</string> <string name="audio_books">Audiobooki</string> <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> + <string name="appears_on">Wystąpił w</string> </resources> From e6f93cd8096e7ee802792b5c69d68d73fe9883de Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Tue, 4 Aug 2026 08:58:06 +0000 Subject: [PATCH 350/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1bf1f897..0568ae65 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -678,4 +678,6 @@ <string name="books">書籍</string> <string name="audio_books">有聲書</string> <string name="server_version_not_supported">不支援此伺服器版本</string> + <string name="user_views">使用者檢視</string> + <string name="appears_on">出現於</string> </resources> From 9def1b964be1363ee16b0c175853254cc1c7f99d Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Wed, 5 Aug 2026 15:26:54 +0000 Subject: [PATCH 351/475] Translated using Weblate (Spanish) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4737ad1c..19549e14 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -74,6 +74,8 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <plurals name="years_old"> + <item quantity="one">%1$d año</item> + <item quantity="many">%1$d años</item> <item quantity="other">%1$d años</item> </plurals> <string name="play_with_transcoding">Reproducir con transcodificación</string> @@ -646,7 +648,7 @@ <string name="behind_the_scenes_title">Detrás de las cámaras</string> <string name="theme_songs_title">Temas musicales</string> <string name="theme_videos_title">Vídeos temáticos</string> - <string name="clips_title">Clips</string> + <string name="clips_title">Fragmentos de vídeo</string> <string name="deleted_scenes_title">Escenas eliminadas</string> <string name="interviews_title">Entrevistas</string> <string name="scenes_title">Escenas</string> @@ -721,4 +723,5 @@ <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> <string name="brown">Marrón</string> <string name="server_version_not_supported">Versión de servidor no soportada</string> + <string name="appears_on">Aparece en</string> </resources> From 486cf527bdaa2a8c5c7f5e6fa29fde431c8ad6ed Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 5 Aug 2026 01:02:27 +0000 Subject: [PATCH 352/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0568ae65..ba2bb0d4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -678,6 +678,6 @@ <string name="books">書籍</string> <string name="audio_books">有聲書</string> <string name="server_version_not_supported">不支援此伺服器版本</string> - <string name="user_views">使用者檢視</string> + <string name="user_views">使用者檢視方式</string> <string name="appears_on">出現於</string> </resources> From 0cb2d104558ce0d7d2de7891d17c46026602f8f5 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Fri, 7 Aug 2026 19:55:06 +0000 Subject: [PATCH 353/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1a54331f..f6e0e289 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -723,4 +723,5 @@ <string name="books">Livros</string> <string name="audio_books">Audiolivros</string> <string name="server_version_not_supported">Versão do servidor não suportada</string> + <string name="appears_on">Aparece em</string> </resources> From a0ee9ce45c5d7a3e40c9ed2325aa59a2f6c06ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Fri, 7 Aug 2026 11:59:54 +0000 Subject: [PATCH 354/475] Translated using Weblate (Estonian) Currently translated at 100.0% (642 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e72bc82f..cf1882a4 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -693,4 +693,13 @@ <string name="books">Raamatud</string> <string name="audio_books">Heliraamatud</string> <string name="server_version_not_supported">Serveri versioon pole toetatud</string> + <string name="dpad_seek_mode_minimal">Minimaalne</string> + <string name="d_pad_seek_mode_title">D-Pad\'i kerimisriba tööpõhimõte</string> + <string name="dpad_seek_mode_minimal_summary">Väike kerimisriba ekraani allosas (vaikimisi variant)</string> + <string name="dpad_seek_mode_trickplay">Trikitamine</string> + <string name="dpad_seek_mode_trickplay_summary">Kerimisriba trikitamise eelvaatepiltidega</string> + <string name="dpad_seek_mode_none_summary">Kerimisriba pole</string> + <string name="hash_matches">Räsivasted</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="appears_on">On osalenud siin:</string> </resources> From 8144b674e2ed1fc82a07f8899f8472f3daf1a6b4 Mon Sep 17 00:00:00 2001 From: lednurb <lednurb@noreply.codeberg.org> Date: Fri, 7 Aug 2026 18:58:27 +0000 Subject: [PATCH 355/475] Translated using Weblate (Dutch) Currently translated at 93.3% (599 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 28539092..3dff3a5f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -43,7 +43,7 @@ <string name="forced_track">Geforceerd</string> <string name="genres">Genres</string> <string name="go_to_series">Ga naar serie</string> - <string name="go_to">Ga naar</string> + <string name="go_to">Ga naar item</string> <string name="hide_controller_timeout">Afspeelbediening verbergen</string> <string name="hide_debug_info">Foutopsporingsinformatie verbergen</string> <string name="hide">Verbergen</string> From 88fbae9867a12dd7d4ac386895a38ed2ab7f1644 Mon Sep 17 00:00:00 2001 From: addisonv <addisonv@noreply.codeberg.org> Date: Fri, 7 Aug 2026 13:19:11 +0000 Subject: [PATCH 356/475] Translated using Weblate (Portuguese (Brazil)) Currently translated at 87.8% (564 of 642 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ffee134b..37217d9f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -648,4 +648,11 @@ <string name="view_options_type_list_compact">Lista Compacta</string> <string name="theme_videos_title">Vídeos Tema</string> <string name="clips_title">Clipes</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Configuração de Perfil de Usuário do servidor</string> + <string name="time_left">%1$s left</string> </resources> From 3cbb738af8b595d8035c4f34b28b1c7122104abf Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Tue, 11 Aug 2026 21:36:33 +0000 Subject: [PATCH 357/475] Translated using Weblate (Spanish) Currently translated at 100.0% (644 of 644 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 19549e14..0c739c8a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -724,4 +724,6 @@ <string name="brown">Marrón</string> <string name="server_version_not_supported">Versión de servidor no soportada</string> <string name="appears_on">Aparece en</string> + <string name="disable_audio_offload">Desactivar el procesamiento de audio por hardware</string> + <string name="disable_audio_offload_summary">Desactivar el procesamiento de audio por hardware puede solucionar los bloqueos de reproducción en algunos dispositivos</string> </resources> From e2a5f30ed5866d571ef46485f0de3896ad13928b Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Wed, 12 Aug 2026 01:53:29 +0000 Subject: [PATCH 358/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ba2bb0d4..398292b2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -680,4 +680,6 @@ <string name="server_version_not_supported">不支援此伺服器版本</string> <string name="user_views">使用者檢視方式</string> <string name="appears_on">出現於</string> + <string name="disable_audio_offload">停用音訊硬體卸載</string> + <string name="disable_audio_offload_summary">停用音訊解碼卸載或許可以解決某些裝置的播放卡住問題</string> </resources> From 0e70fe030a4dbc0910a2a6c6702fd4d68093fdea Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Tue, 11 Aug 2026 23:24:15 +0000 Subject: [PATCH 359/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (644 of 644 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 30ea7470..366304aa 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -680,4 +680,6 @@ <string name="dpad_seek_mode_none_summary">无进度条</string> <string name="server_version_not_supported">服务器版本不支持</string> <string name="appears_on">出现在</string> + <string name="disable_audio_offload">禁用音频硬件分流</string> + <string name="disable_audio_offload_summary">禁用分流音频解码可能会解决某些设备上的播放卡顿问题</string> </resources> From d463dbf87667c7a38e27e01a18bdd31b1fd7c455 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Wed, 12 Aug 2026 15:18:29 +0000 Subject: [PATCH 360/475] Translated using Weblate (German) Currently translated at 100.0% (644 of 644 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, 2 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0234f9ab..8d13051d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -702,4 +702,6 @@ <string name="dpad_seek_mode_trickplay_summary">Suchleiste mit Trickplay Vorschaubildern</string> <string name="dpad_seek_mode_none_summary">Keine Suchleiste</string> <string name="server_version_not_supported">Nicht unterstützte Server-Version</string> + <string name="disable_audio_offload">Audio-Hardware-Offload deaktivieren</string> + <string name="disable_audio_offload_summary">Das Auslagern der Audio Decodierung zu deaktivieren könnte Wiedergabeprobleme auf einigen Gräten beheben</string> </resources> From ea28a2d58f5557d5b90749dfb37998d2f187b066 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Wed, 12 Aug 2026 22:02:22 +0000 Subject: [PATCH 361/475] Added translation using Weblate (Croatian) --- app/src/main/res/values-hr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-hr/strings.xml diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> \ No newline at end of file From 84185530c95f15620e50f3f120fdb72f38255b28 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Wed, 12 Aug 2026 23:34:08 +0000 Subject: [PATCH 362/475] Translated using Weblate (Croatian) Currently translated at 15.2% (98 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 96 +++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 55344e51..805c0dc3 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -1,3 +1,97 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file + <string name="about">O aplikaciji</string> + <string name="active_recordings">Aktivna snimanja</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Dodaj poslužitelj</string> + <string name="add_user">Dodaj korisnika</string> + <string name="audio">Zvuk</string> + <string name="birthplace">Mjesto rođenja</string> + <string name="born">Rođenje</string> + <string name="cancel_recording">Otkaži snimanje</string> + <string name="cancel_series_recording">Otkaži snimanje serije</string> + <string name="cancel">Odustani</string> + <string name="chapters">Poglavlja</string> + <string name="choose_stream">Odaberi %1$s</string> + <string name="clear_image_cache">Očisti predmemoriju slika</string> + <string name="collection">Kolekcija</string> + <string name="collections">Kolekcije</string> + <string name="commercial">Reklama</string> + <string name="community_rating">Ocjena zajednice</string> + <string name="compose_error_message_title">Došlo je do greške! Pritisnite gumb za slanje zapisa na poslužitelj.</string> + <string name="confirm">Potvrdi</string> + <string name="continue_watching">Nastavi gledanje</string> + <string name="critic_rating">Ocjena kritike</string> + <string name="decimal_seconds">%.2f sekundi</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="default_track">Zadano</string> + <string name="delete">Izbriši</string> + <string name="died">Smrt</string> + <string name="directed_by">Režija %1$s</string> + <string name="director">Redatelj</string> + <string name="disabled">Onemogućeno</string> + <string name="discovered_servers">Pronađeni poslužitelji</string> + <string name="download_and_update"><![CDATA[Preuzmi i ažuriraj]]></string> + <string name="downloading">Preuzimanje…</string> + <string name="enabled">Omogućeno</string> + <string name="ends_at">Završava u %1$s</string> + <string name="enter_server_url">Unesite IP ili URL poslužitelja</string> + <string name="enter_server_address">Unesite adresu poslužitelja</string> + <string name="episodes">Epizode</string> + <string name="error_loading_collection">Greška pri učitavanju kolekcije %1$s</string> + <string name="external_track">Vanjski</string> + <string name="favorites">Favoriti</string> + <string name="file_size">Veličina</string> + <string name="forced_track">Prisilni</string> + <string name="genres">Žanrovi</string> + <string name="go_to_series">Idi na seriju</string> + <string name="go_to">Idi na</string> + <string name="hide_controller_timeout">Sakrij kontrole reprodukcije</string> + <string name="hide_debug_info">Sakrij informacije za otklanjanje grešaka</string> + <string name="hide">Sakrij</string> + <string name="home">Početna</string> + <string name="immediate">Odmah</string> + <string name="intro">Uvod</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteka</string> + <string name="license_info">Informacije o licenci</string> + <string name="live_tv">Live TV</string> + <string name="loading">Učitavanje…</string> + <string name="mark_entire_series_as_played">Označiti cijelu seriju kao pogledanu?</string> + <string name="mark_entire_series_as_unplayed">Označiti cijelu seriju kao nepogledanu?</string> + <string name="mark_unwatched">Označi kao nepogledano</string> + <string name="mark_watched">Označi kao pogledano</string> + <string name="max_bitrate">Maks. bitrate</string> + <string name="more_like_this">Slično ovome</string> + <string name="more">Više</string> + <string name="movies_title">Filmovi</string> + <string name="name">Ime</string> + <string name="next_up">Slijedi</string> + <string name="no_data">Nema podataka</string> + <string name="no_results">Nema rezultata</string> + <string name="no_servers_found">Nije pronađen nijedan poslužitelj</string> + <string name="no_scheduled_recordings">Nema zakazanih snimanja</string> + <string name="no_update_available">Nema dostupnih ažuriranja</string> + <string name="bitrate">Bitrate</string> + <string name="none">Nijedan</string> + <string name="only_forced_subtitles">Samo prisilni titlovi</string> + <string name="outro">Odjava</string> + <string name="path">Putanja</string> + <string name="people_title">Osobe</string> + <string name="play_count">Broj reprodukcija</string> + <string name="play_from_here">Reproduciraj odavde</string> + <string name="play">Reproduciraj</string> + <string name="playback_debug_info">Prikaži informacije za otklanjanje grešaka reprodukcije</string> + <string name="playback_speed">Brzina reprodukcije</string> + <string name="playback">Reprodukcija</string> + <string name="playlist">Popis za reprodukciju</string> + <string name="playlists">Popisi za reprodukciju</string> + <string name="preview">Pretpregled</string> + <string name="profile_specific_settings">Postavke korisničkog profila</string> + <string name="profile_specific_settings_from_server">Postavke korisničkog profila s poslužitelja</string> +</resources> From 14a1f2e1104f5c9bf37816044752071414cdbe13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Thu, 13 Aug 2026 16:29:02 +0000 Subject: [PATCH 363/475] Translated using Weblate (Estonian) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index cf1882a4..4ce7a329 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -702,4 +702,6 @@ <string name="hash_matches">Räsivasted</string> <string name="subtitles_hearing_impaired">SDH</string> <string name="appears_on">On osalenud siin:</string> + <string name="disable_audio_offload">Lülita graafikaprotsessori kasutamine heli töötluseks välja</string> + <string name="disable_audio_offload_summary">Kui lülita graafikaprotsessori kasutamise heli töötluseks välja, siis see võib mõnede seadmete puhul parandada taasesituse hangumise vead</string> </resources> From 894e0db4598aeea06d9076469a8134a793a299ef Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Thu, 13 Aug 2026 07:17:49 +0000 Subject: [PATCH 364/475] Translated using Weblate (Slovak) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index f1dd7353..e6d66f83 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -746,4 +746,6 @@ <string name="dpad_seek_mode_none_summary">Bez panela pretáčania</string> <string name="server_version_not_supported">Verzia servera nie je podporovaná</string> <string name="appears_on">Objavuje sa v</string> + <string name="disable_audio_offload">Zakázať hardvérové odovzdávanie zvuku</string> + <string name="disable_audio_offload_summary">Zakázanie odovzdávania dekódovania zvuku môže vyriešiť zamŕzanie prehrávania na niektorých zariadeniach</string> </resources> From 2033f1857ad4380252900b768dac7271c8251f55 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Thu, 13 Aug 2026 10:09:57 +0000 Subject: [PATCH 365/475] Translated using Weblate (Italian) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 38e47565..f17baea6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -724,4 +724,6 @@ <string name="audio_books">Audiolibri</string> <string name="server_version_not_supported">Versione del server non supportata</string> <string name="appears_on">Appare in</string> + <string name="disable_audio_offload">Disabilita offload hardware audio</string> + <string name="disable_audio_offload_summary">Disabilitare l’offload della decodifica audio può risolvere i blocchi di riproduzione su alcuni dispositivi</string> </resources> From 40d01b54ef14d397e01e89a1351ba8f7a68275f2 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Thu, 13 Aug 2026 16:36:18 +0000 Subject: [PATCH 366/475] Translated using Weblate (Croatian) Currently translated at 17.7% (114 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 805c0dc3..720a96cb 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -94,4 +94,19 @@ <string name="preview">Pretpregled</string> <string name="profile_specific_settings">Postavke korisničkog profila</string> <string name="profile_specific_settings_from_server">Postavke korisničkog profila s poslužitelja</string> + <string name="queue">Red čekanja</string> + <string name="recap">Sažetak</string> + <string name="recently_added_in">Nedavno dodano u %1$s</string> + <string name="recently_added">Nedavno dodano</string> + <string name="recently_recorded">Nedavno snimljeno</string> + <string name="recently_released">Nedavno objavljeno</string> + <string name="recommended">Preporučeno</string> + <string name="record_program">Snimi program</string> + <string name="record_series">Snimi seriju</string> + <string name="remove_favorite">Ukloni iz favorita</string> + <string name="restart">Ponovno pokreni</string> + <string name="resume">Nastavi</string> + <string name="save">Spremi</string> + <string name="search_and_download"><![CDATA[Pretraži i preuzmi]]></string> + <string name="search">Pretraži</string> </resources> From 8ea73b6f3a1045b81cdfa0985ac41bc0a628b474 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Thu, 13 Aug 2026 17:38:00 +0000 Subject: [PATCH 367/475] Translated using Weblate (Croatian) Currently translated at 32.4% (209 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 720a96cb..11700824 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -109,4 +109,164 @@ <string name="save">Spremi</string> <string name="search_and_download"><![CDATA[Pretraži i preuzmi]]></string> <string name="search">Pretraži</string> + <string name="searching">Pretraživanje…</string> + <string name="voice_search">Glasovno pretraživanje</string> + <string name="voice_starting">Pokretanje</string> + <string name="voice_search_prompt">Govorite za pretraživanje</string> + <string name="processing">Obrada</string> + <string name="press_back_to_cancel">Pritisnite Natrag za odustajanje</string> + <string name="voice_error_audio">Greška pri snimanju zvuka</string> + <string name="voice_error_client">Greška pri prepoznavanju glasa</string> + <string name="voice_error_permissions">Potrebno je dopuštenje za mikrofon</string> + <string name="voice_error_network">Greška mreže</string> + <string name="voice_error_network_timeout">Isteklo vrijeme mreže</string> + <string name="voice_error_no_match">Govor nije prepoznat</string> + <string name="voice_error_busy">Prepoznavanje glasa zauzeto</string> + <string name="voice_error_server">Greška poslužitelja</string> + <string name="voice_error_speech_timeout">Govor nije otkriven</string> + <string name="voice_error_unknown">Nepoznata greška</string> + <string name="voice_error_start_failed">Pokretanje prepoznavanja glasa nije uspjelo</string> + <string name="voice_error_timeout">Isteklo vrijeme prepoznavanja glasa</string> + <string name="retry">Pokušaj ponovno</string> + <string name="select_server">Odaberite poslužitelj</string> + <string name="select_user">Odaberite korisnika</string> + <string name="show_debug_info">Prikaži informacije za otklanjanje grešaka</string> + <string name="show_next_up_when">Prikaži \"Slijedi\"</string> + <string name="show">Prikaži</string> + <string name="shuffle">Nasumično</string> + <string name="skip">Preskoči</string> + <string name="sort_by_date_added">Datum dodavanja</string> + <string name="sort_by_date_episode_added">Datum dodavanja epizode</string> + <string name="sort_by_date_played">Datum reprodukcije</string> + <string name="sort_by_date_released">Datum izdavanja</string> + <string name="sort_by_name">Ime</string> + <string name="sort_by_random">Nasumično</string> + <string name="studios">Studiji</string> + <string name="submit">Pošalji</string> + <string name="subtitle_download_too_long">Preuzimanje traje dugo, možda ćete morati ponovno pokrenuti reprodukciju</string> + <string name="subtitle">Titl</string> + <string name="subtitles">Titlovi</string> + <string name="suggestions">Prijedlozi</string> + <string name="switch_servers">Promijeni poslužitelja</string> + <string name="switch_user">Promijeni</string> + <string name="time_left">%1$s preostalo</string> + <string name="top_unwatched">Najbolje ocijenjeno nepogledano</string> + <string name="trailers_title">Najave</string> + <string name="tv_dvr_schedule">Raspored DVR-a</string> + <string name="tv_guide">Vodič</string> + <string name="tv_season">Sezona</string> + <string name="tv_seasons">Sezone</string> + <string name="tv_shows_title">TV serije</string> + <string name="ui_interface">Sučelje</string> + <string name="unknown">Nepoznato</string> + <string name="updates">Ažuriranja</string> + <string name="version">Verzija</string> + <string name="video_scale">Skaliranje videa</string> + <string name="video">Video</string> + <string name="videos">Videozapisi</string> + <string name="watch_live">Gledaj uživo</string>g + <plurals name="years_old"> + <item quantity="one">%1$d godina</item> + <item quantity="few">%1$d godine</item> + <item quantity="other">%1$d godina</item> + </plurals> + <string name="play_with_transcoding">Reproduciraj s transkodiranjem</string> + <string name="media_information">Informacije o mediju</string> + <string name="show_clock">Prikaži sat</string> + <string name="aired_episode_order">Redoslijed emitiranja epizoda</string> + <string name="create_playlist">Stvori novi popis za reprodukciju</string> + <string name="add_to_playlist">Dodaj na popis za reprodukciju</string> + <string name="one_click_pause">Pauza jednim klikom</string> + <string name="one_click_pause_summary_on">Pritisnite sredinu D-Pada za pauzu/reprodukciju</string> + <plurals name="deleted_scenes"> + <item quantity="one">Izbrisana scena</item> + <item quantity="few">Izbrisane scene</item> + <item quantity="other">Izbrisanih scena</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Intervju</item> + <item quantity="few">Intervjua</item> + <item quantity="other">Intervjua</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scena</item> + <item quantity="few">Scene</item> + <item quantity="other">Scena</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječaka</item> + <item quantity="other">Isječaka</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Prateći film</item> + <item quantity="few">Prateća filma</item> + <item quantity="other">Pratećih filmova</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kratki film</item> + <item quantity="few">Kratka filma</item> + <item quantity="other">Kratkih filmova</item> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s preuzimanje</item> + <item quantity="few">%s preuzimanja</item> + <item quantity="other">%s preuzimanja</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d sat</item> + <item quantity="few">%d sata</item> + <item quantity="other">%d sati</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dan</item> + <item quantity="few">%s dana</item> + <item quantity="other">%s dana</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d stavka</item> + <item quantity="few">%d stavke</item> + <item quantity="other">%d stavki</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekunde</item> + <item quantity="other">%d sekundi</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minute</item> + <item quantity="other">%s minuta</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filma</item> + <item quantity="other">Filmova</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV serija</item> + <item quantity="few">TV serije</item> + <item quantity="other">TV serija</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osobe</item> + <item quantity="other">Osoba</item> + </plurals> + <string name="ac3_supported">Uređaj podržava AC3/Dolby Digital</string> + <string name="advanced_settings">Napredne postavke</string> + <string name="advanced_ui">Napredno sučelje</string> + <string name="app_theme">Tema aplikacije</string> + <string name="auto_check_for_updates">Automatski provjeravaj ažuriranja</string> + <string name="auto_play_next_delay">Odgoda prije reprodukcije sljedećeg</string> + <string name="auto_play_next">Automatska reprodukcija sljedećeg</string> + <string name="check_for_updates">Provjeri ažuriranja</string> + <string name="combine_continue_next_summary">Primjenjuje se samo na TV serije</string> + <string name="combine_continue_next"><![CDATA[Spoji \"Nastavi gledanje\" i \"Slijedi\"]]></string> + <string name="direct_play_ass">Koristi libass za ASS titlove</string> + <string name="direct_play_pgs">Izravna reprodukcija PGS titlova</string> + <string name="downmix_stereo">Uvijek smiksaj na stereo</string> + <string name="ffmpeg_extension_pref">Koristi FFmpeg modul dekodera</string> + <string name="global_content_scale">Zadano skaliranje sadržaja</string> + <string name="install_update">Instaliraj ažuriranje</string> </resources> From 007ac4801c0ea7ebd98e45e8191e44d9f7401083 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 13 Aug 2026 18:36:38 -0400 Subject: [PATCH 368/475] Release v1.0.6 From 22a949bc76f4260391d88f1f763b64521ed7acd0 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 16 Aug 2026 10:20:33 -0400 Subject: [PATCH 369/475] Fixes slideshow images not being full screen (#1849) ## Description I accidentally removed the original fix/workaround from #1089 during a merge in #1052, so this PR restores the fix. ### Related issues Fixes #1736 Related to the original fix #1089 ### Testing Emulator with portrait and landscape images ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/slideshow/SlideshowPage.kt | 7 +++++-- 1 file changed, 5 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 9a988753..91dc2543 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 @@ -408,8 +408,11 @@ fun SlideshowPage( ImageRequest .Builder(LocalContext.current) .data(imageState.url) - .size(Size.ORIGINAL) - .transitionFactory(CrossFadeFactory(750.milliseconds)) + .apply { + // Fixes images noting being full size when resolutions change + // See: https://github.com/damontecres/Wholphin/pull/1089 + if (isZoomed) size(Size.ORIGINAL) + }.transitionFactory(CrossFadeFactory(750.milliseconds)) .useExistingImageAsPlaceholder(true) .build(), contentDescription = null, From 4e53cbaa7ff8ee4d4eacde1151cafb2705401d2a Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 16 Aug 2026 10:20:46 -0400 Subject: [PATCH 370/475] Preload next image during slideshow (#1847) ## Description When viewing images in a slideshow, this PR enqueues the next image so it will likely be cached and ready to display when the slideshow advances. This works for both automatic and manual scrolling as well as advancing next or previous images. ### Related issues Closes #1835 ### Testing Emulator with large photos ## Screenshots N/A ## AI or LLM usage None --- .../ui/slideshow/SlideshowViewModel.kt | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) 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 c81ffcb7..e74ffdf2 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 @@ -6,6 +6,9 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.Player +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.size.Size import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.ServerRepository @@ -172,7 +175,7 @@ class SlideshowViewModel val size = state.value.items.size val newPosition = state.value.position + 1 return if (newPosition < size) { - updatePosition(newPosition) + updatePosition(newPosition, true) true } else { false @@ -182,14 +185,23 @@ class SlideshowViewModel fun previousImage(): Boolean { val newPosition = state.value.position - 1 return if (newPosition >= 0) { - updatePosition(newPosition) + updatePosition(newPosition, false) true } else { false } } - fun updatePosition(position: Int): Job? = + /** + * Update image state to the specified position + * + * @param position the index to use + * @param isNext whether moving to the next or previous image for prefetching purposes + */ + fun updatePosition( + position: Int, + isNext: Boolean = true, + ): Job? = (state.value.items as? ApiRequestPager<*>)?.let { pager -> viewModelScope.launchIO { try { @@ -286,6 +298,31 @@ class SlideshowViewModel ) } } + try { + // If there is a next/previous image, fetch it into the image cache + val nextIndex = position + if (isNext) 1 else -1 + if (nextIndex in pager.indices) { + pager.getBlocking(nextIndex)?.let { nextImage -> + if (nextImage.data.mediaType == MediaType.PHOTO) { + val url = api.libraryApi.getDownloadUrl(nextImage.id) + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .build() + context.imageLoader.enqueue(request) + Timber.v( + "Prefetching index=%s, id=%s", + nextIndex, + nextImage.id, + ) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error preloading image") + } } } @@ -339,7 +376,7 @@ class SlideshowViewModel fun pulseSlideshow() = pulseSlideshow(slideshowDelay) fun pulseSlideshow(milliseconds: Long) { - Timber.v("pulseSlideshow $milliseconds") +// Timber.v("pulseSlideshow $milliseconds") slideshowJob?.cancel() slideshowJob = viewModelScope From f89d752d2c3bcd486dc7913f9773246fdcf7f2ba Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 16 Aug 2026 10:29:03 -0400 Subject: [PATCH 371/475] Updates & fixes to discover pages (#1836) ## Description Fixes several issues on the discover pages: - Fixes sorting by release date for tv shows - Fixes automatic go-to for movie & tv show grid - Fixes tv show discover page's go-to using a movie detail page - Updates the alignment & padding for the headers on different discover pages to match the reset of the app - Use media's logo image if available and the setting is enabled ### Related issues N/A ### Testing Emulator mostly ## Screenshots N/A, headers and logos are unchanged, just moved slightly to match regular content ## AI or LLM usage None --- README.md | 2 +- .../wholphin/data/filter/DiscoverFilter.kt | 11 ++-- .../wholphin/data/model/DiscoverItem.kt | 1 + .../wholphin/services/SeerrService.kt | 37 ++++++++------ .../wholphin/ui/components/TitleOrLogo.kt | 9 ++++ .../detail/discover/DiscoverMovieDetails.kt | 14 ++--- .../discover/DiscoverMovieDetailsHeader.kt | 40 ++++++++++----- .../detail/discover/DiscoverMovieViewModel.kt | 12 +++++ .../detail/discover/DiscoverSeriesDetails.kt | 51 ++++++++++--------- .../discover/DiscoverSeriesViewModel.kt | 12 +++++ .../ui/detail/discover/DiscoverUtils.kt | 46 +++++++++++++++++ .../ui/discover/DiscoverFocusedItem.kt | 5 ++ .../ui/discover/DiscoverRequestGrid.kt | 2 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 15 ++++-- 14 files changed, 182 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index b37ac85a..ed82692f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! -Seerr integration is tested with `v3.3.0`. Older versions may not work. +Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility. ## Contributions diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt index bef5c87a..446a64c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -17,10 +17,11 @@ private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d") enum class DiscoverSort( val key: String, - @StringRes val stringRes: Int, + @param:StringRes val stringRes: Int, + val tvKey: String = key, ) { POPULARITY("popularity", R.string.popularity), - RELEASE_DATE("release_date", R.string.sort_by_date_released), + RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"), TMDB_VOTE("vote_average", R.string.community_rating), ALPHABETICAL("original_title", R.string.sort_by_name), } @@ -36,7 +37,7 @@ data class DiscoverSortAndDirection( val sort: DiscoverSort = DiscoverSort.POPULARITY, val direction: SortOrder = SortOrder.DESCENDING, ) { - val key = "${sort.key}.${direction.key}" + fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}" fun flip() = copy(direction = direction.flip()) } @@ -103,7 +104,7 @@ data class DiscoverFilter( network = null, keywords = keywordIds?.joinToString(",") { it.toString() }, excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, - sortBy = sortBy?.key, + sortBy = sortBy?.key(true), // firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, // firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), @@ -135,7 +136,7 @@ data class DiscoverFilter( // network = null, keywords = keywordIds?.joinToString(",") { it.toString() }, excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, - sortBy = sortBy?.key, + sortBy = sortBy?.key(false), // firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, // firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), 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 5c5c7c40..24d7d340 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 @@ -103,6 +103,7 @@ data class DiscoverItem( @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, val posterUrl: String?, val backDropUrl: String?, + val logoUrl: String?, val jellyfinItemId: UUID?, ) : CardGridItem { override val gridId: String get() = id.toString() 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 df9488ba..8a283e4e 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 @@ -28,6 +28,7 @@ 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 java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -169,14 +170,7 @@ class SeerrService backdropWidth: Int = 1920, ): String? { if (mediaInfo != null) { - val itemId = - if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId.toUUIDOrNull() - } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId4k.toUUIDOrNull() - } else { - null - } + val itemId = mediaInfo.jellyfinId if (itemId != null) { return imageUrlService.getItemImageUrl( itemId = itemId, @@ -348,7 +342,8 @@ class SeerrService 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(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = @@ -364,7 +359,8 @@ class SeerrService 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(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = @@ -380,7 +376,8 @@ class SeerrService 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(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = @@ -396,7 +393,8 @@ class SeerrService 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(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = @@ -412,7 +410,8 @@ class SeerrService 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(), + logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = @@ -438,7 +437,8 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = @@ -464,6 +464,13 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) } + +val MediaInfo.jellyfinId: UUID? + get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull() + +val MediaInfo.jellyfinIdAsString: String? + get() = jellyfinMediaId4k ?: jellyfinMediaId diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index ff001d0b..99288b9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -21,6 +21,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -40,6 +41,10 @@ fun TitleOrLogo( model = logoImageUrl, contentDescription = title, contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, modifier = Modifier .height(HeaderUtils.logoHeight) @@ -113,6 +118,10 @@ fun SimpleTitleOrLogo( model = logoImageUrl, contentDescription = item?.title, contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, modifier = Modifier.fillMaxSize(), ) } else { 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 4dea6d41..e91dfb82 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 @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -64,7 +65,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull @Composable fun DiscoverMovieDetails( @@ -138,14 +138,7 @@ fun DiscoverMovieDetails( ) }, goToOnClick = { - movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } + viewModel.goTo(movie.mediaInfo, BaseItemKind.MOVIE) }, moreOnClick = {}, onLongClickPerson = { index, person -> }, @@ -238,10 +231,11 @@ fun DiscoverMovieDetailsContent( rating = rating, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( availability = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 8b6f3e98..1c0915ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -23,9 +22,13 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -33,6 +36,7 @@ import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.ImageType import java.util.Locale import kotlin.time.Duration.Companion.minutes @@ -43,29 +47,36 @@ fun DiscoverMovieDetailsHeader( rating: DiscoverRating?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(movie) { + movie.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - // Title - Text( - text = movie.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = movie.title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp val resources = LocalResources.current val details = remember(movie, rating, resources) { @@ -102,9 +113,9 @@ fun DiscoverMovieDetailsHeader( } } - QuickDetailsText(details) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) movie.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } @@ -113,7 +124,7 @@ fun DiscoverMovieDetailsHeader( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } @@ -150,6 +161,7 @@ fun DiscoverMovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } } 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 86c55d38..20cecd12 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MediaRequest import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank +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.showToast @@ -44,6 +46,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException @@ -172,6 +175,15 @@ class DiscoverMovieViewModel navigationManager.navigateTo(destination) } + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, + ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } + } + fun request(request: MovieRequest) { viewModelScope.launchIO { if (request.movieId == 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 d02b17eb..049f7610 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 @@ -27,12 +27,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem @@ -45,6 +42,8 @@ import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -53,9 +52,11 @@ import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.formatDuration @@ -70,7 +71,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.successValue import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.jellyfin.sdk.model.api.ImageType import kotlin.time.Duration.Companion.minutes @Composable @@ -125,14 +126,7 @@ fun DiscoverSeriesDetails( viewModel.navigateTo(Destination.DiscoveredItem(it)) }, goToOnClick = { - item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } + viewModel.goTo(item.mediaInfo, BaseItemKind.SERIES) }, overviewOnClick = { overviewDialog = @@ -254,7 +248,6 @@ fun DiscoverSeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) .fillMaxSize(), ) { LazyColumn( @@ -274,10 +267,11 @@ fun DiscoverSeriesDetailsContent( series = series, rating = rating, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( availability = @@ -430,24 +424,33 @@ fun DiscoverSeriesDetailsHeader( series: TvDetails, rating: DiscoverRating?, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(series) { + series.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Text( - text = series.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = series.name, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp val resources = LocalResources.current val details = remember(series, rating, resources) { @@ -469,9 +472,9 @@ fun DiscoverSeriesDetailsHeader( } } - QuickDetailsText(details) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) series.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } series.overview?.let { overview -> OverviewText( 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 b473d510..ec500351 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo 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.RequestRequestIdPutRequest @@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.isNotNullOrBlank +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.showToast @@ -45,6 +47,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @@ -162,6 +165,15 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, + ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } + } + private suspend fun updateSeasonStatus(tv: TvDetails) { if (request4kEnabled.first()) { updateSeasonStatus(tv, true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt index 8dfc4290..3e625d60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -1,8 +1,17 @@ package com.github.damontecres.wholphin.ui.detail.discover +import android.content.Context import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.services.jellyfinIdAsString +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast import kotlinx.coroutines.CancellationException +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber suspend fun getDiscoverRating( @@ -24,3 +33,40 @@ suspend fun getDiscoverRating( Timber.e(ex, "Error fetching rating for %s", id) null } + +/** + * Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId] + * + * If not, a error toast is shown + */ +suspend fun goToButtonDiscover( + mediaInfo: MediaInfo?, + type: BaseItemKind, + context: Context, + navigationManager: NavigationManager, +) { + val id = mediaInfo?.jellyfinId + if (id != null) { + navigationManager.navigateTo( + Destination.MediaItem( + itemId = id, + type = type, + ), + ) + } else { + val stringId = mediaInfo?.jellyfinIdAsString + val msg = + if (stringId.isNotNullOrBlank()) { + "Unknown Jellyfin ID: $stringId" + } else { + "No Jellyfin ID found" + } + Timber.w( + "Unknown jellyfinId for tmdb=%s: %s/%s", + mediaInfo?.tmdbId, + mediaInfo?.jellyfinMediaId, + mediaInfo?.jellyfinMediaId4k, + ) + showToast(context, msg) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt index c9464528..b0d49985 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt @@ -13,6 +13,7 @@ sealed interface DiscoverFocusedItem { val subtitle: String? val overview: String? val backDropUrl: String? + val logoUrl: String? data class Item( val item: DiscoverItem, @@ -26,6 +27,8 @@ sealed interface DiscoverFocusedItem { get() = item.overview override val backDropUrl: String? get() = item.backDropUrl + override val logoUrl: String? + get() = item.logoUrl } data class Genre( @@ -39,6 +42,8 @@ sealed interface DiscoverFocusedItem { get() = null override val overview: String? get() = null + override val logoUrl: String? + get() = null } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index 3d6d808e..fe6dd475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -299,7 +299,7 @@ fun DiscoverRequestGrid( initialPosition = state.startIndex, pager = s.data, onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, onClickPlay = { _, _ -> }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 9702f9fa..cd7c6a27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable @@ -40,6 +40,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.launchIO @@ -438,16 +439,20 @@ fun SeerrDiscoverPage( title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, - overviewTwoLines = true, + overviewTwoLines = false, quickDetails = details, timeRemaining = null, endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, - logoImageUrl = null, // TODO + logoImageUrl = focusedItem?.logoUrl, modifier = Modifier - .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) - .fillMaxHeight(.25f), + .padding( + top = HeaderUtils.topPadding, + // Intentionally no bottom padding because of tabs + bottom = 0.dp, + start = HeaderUtils.startPadding, + ).height(HeaderUtils.height), ) val density = LocalDensity.current From 197af604d653ba91dea44649ea5bf858d77ddd88 Mon Sep 17 00:00:00 2001 From: Stefan Bullones <stefan.bullones@gmail.com> Date: Tue, 18 Aug 2026 04:51:51 +0200 Subject: [PATCH 372/475] Pause music playback when another app takes audio focus (#1842) ## Description The audio player passed handleAudioFocus = false when setting its audio attributes, so ExoPlayer never requested audio focus. Music kept playing and mixed with other audio when a call arrived, another app started playback, or voice search took focus. ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/1841 ### Testing 1. Play a music track 2. Open YouTube and play a video ## AI or LLM usage Cursor and Claude Code --- .../com/github/damontecres/wholphin/services/PlayerFactory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 cec0a205..cf4128fb 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 @@ -202,9 +202,10 @@ class PlayerFactory it.setAudioAttributes( AudioAttributes .Builder() + .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), - false, + true, ) } } From c4264a32634308f47915f602e63bd8165b00fd52 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 17 Aug 2026 22:53:40 -0400 Subject: [PATCH 373/475] Adds option to dim the screensaver (#1834) ## Description Adds a new sceensaver setting to add dimming. A larger value is darker This works with or without the in-app screensaver. If dimming is enabled, the screen be will be dimmed after inactivity. If both dimming and the in-app screensaver are enabled, the screensaver will show dimmed after inactivity. ### Related issues Closes #1179 ### Testing Emulator ## Screenshots N/A, it's just darker ## AI or LLM usage None --- .../damontecres/wholphin/MainContent.kt | 19 +++++- .../wholphin/WholphinDreamService.kt | 14 +++- .../wholphin/preferences/AppPreference.kt | 8 +++ .../preferences/ScreensaverPreference.kt | 31 +++++++++ .../wholphin/services/AppUpgradeHandler.kt | 8 +++ .../wholphin/services/ScreensaverService.kt | 68 +++++++++++++++++-- app/src/main/proto/WholphinDataStore.proto | 2 + app/src/main/res/values/strings.xml | 3 + 8 files changed, 144 insertions(+), 9 deletions(-) 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 78545930..25221874 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState 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.lifecycle.Lifecycle @@ -136,12 +137,26 @@ fun MainContent( val screenSaverState by screensaverService.state.collectAsState() if (screenSaverState.enabled || screenSaverState.enabledTemp) { AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), + visible = screenSaverState.show, + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), ) { AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) } } + AnimatedVisibility( + visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show), + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), + ) { + val alpha = + userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f + Box( + Modifier.background(Color.Black.copy(alpha = alpha)), + ) + } } } } 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 a281b101..41a87222 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.ComposeView import androidx.datastore.core.DataStore import androidx.lifecycle.Lifecycle @@ -94,6 +95,14 @@ class WholphinDreamService : preferencesDataStore.data.collectLatest { prefs = it } } prefs?.let { prefs -> + val alpha = + prefs.interfacePreferences.screensaverPreference.run { + if (dimEnabled) { + dimPercent / 100f + } else { + 1f + } + } CoilConfig( prefs = prefs, okHttpClient = okHttpClient, @@ -109,7 +118,10 @@ class WholphinDreamService : showClock = screensaverPrefs.showClock, duration = screensaverPrefs.duration.milliseconds, animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .alpha(alpha = alpha), ) } } 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 a3f5b660..43d2d53a 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 @@ -1338,6 +1338,14 @@ val screensaverPreferences = ScreensaverPreference.Start, ), ), + PreferenceGroup( + title = R.string.dim_screen, + preferences = + listOf( + ScreensaverPreference.DimToggle, + ScreensaverPreference.DimPercentage, + ), + ), ) data class AppSwitchPreference<Pref>( 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 index 1e1ad91c..04fb4a63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -176,6 +176,37 @@ object ScreensaverPreference { }, ) + val DimToggle = + AppSwitchPreference<AppPreferences>( + title = R.string.dim_after_delay, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.dimEnabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimEnabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val DimPercentage = + AppSliderPreference<AppPreferences>( + title = R.string.dim_percentage, + defaultValue = 75, + min = 0, + max = 95, + interval = 5, + getter = { + it.interfacePreferences.screensaverPreference.dimPercent + .toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimPercent = value.toInt() } + }, + summarizer = { value -> + value?.let { "$value%" } + }, + ) + val Start = AppClickablePreference<AppPreferences>( 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 cc814210..29348be6 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 @@ -418,5 +418,13 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt() + } + } + } } } 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 871a20e2..90b0c04a 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,6 +1,9 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import coil3.imageLoader import coil3.request.ImageRequest import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope @@ -57,6 +60,7 @@ class ScreensaverService val keepScreenOn = MutableStateFlow(false) private var waitJob: Job? = null + private var dimJob: Job? = null init { userPreferencesService.flow @@ -65,7 +69,14 @@ class ScreensaverService val enabled = prefs.appPreferences.interfacePreferences.screensaverPreference.enabled keepScreenOnInternal(enabled) - ScreensaverState(enabled, false, false, false) + ScreensaverState( + enabled = enabled, + enabledTemp = false, + active = false, + paused = false, + dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled, + dimActive = false, + ) } }.launchIn(scope) } @@ -74,12 +85,16 @@ class ScreensaverService * Reset the timer before showing the in-app screensaver */ fun pulse() { + Timber.v("state=%s", _state.value) waitJob?.cancel() if (_state.value.enabled) { // Timber.v("pulse") _state.update { if (!it.active) { - it.copy(active = false) + it.copy( + active = false, + dimActive = false, + ) } else { it } @@ -94,7 +109,30 @@ class ScreensaverService .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds delay(startDelay) _state.update { - it.copy(active = true) + it.copy( + active = true, + dimActive = it.dimEnabled, + ) + } + } + } + } + dimJob?.cancel() + if (_state.value.dimEnabled) { + _state.update { + it.copy(dimActive = false) + } + if (!_state.value.paused) { + dimJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + Timber.v("state dim=%s", _state.value) + _state.update { + it.copy(dimActive = !_state.value.paused) } } } @@ -109,6 +147,7 @@ class ScreensaverService it.copy( enabledTemp = true, active = true, + dimActive = true, ) } } @@ -121,9 +160,13 @@ class ScreensaverService it.copy( enabledTemp = false, active = false, + dimActive = false, ) } - if (cancelJob) waitJob?.cancel() + if (cancelJob) { + waitJob?.cancel() + dimJob?.cancel() + } } /** @@ -131,12 +174,16 @@ class ScreensaverService */ fun keepScreenOn(keep: Boolean) { scope.launchDefault { - val screensaverEnabled = _state.value.enabled + val screensaverEnabled = _state.value.run { enabled || dimEnabled } 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) + it.copy( + active = false, + dimActive = false, + paused = keep, + ) } if (!keep) { pulse() @@ -245,6 +292,11 @@ class ScreensaverService ) return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() } + + companion object { + val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000)) + val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500)) + } } data class ScreensaverState( @@ -252,6 +304,10 @@ data class ScreensaverState( val enabledTemp: Boolean, val active: Boolean, val paused: Boolean, + val dimEnabled: Boolean = false, + val dimActive: Boolean = false, ) { val show get() = (enabled || enabledTemp) && active && !paused + + val showDim get() = dimEnabled && dimActive && !paused } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f190b43b..98698f7c 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -174,6 +174,8 @@ message ScreensaverPreferences{ bool enabled = 5; repeated string item_types = 6; bool show_clock = 7; + bool dim_enabled = 8; + int32 dim_percent = 9; } message SearchPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b1bd402..990d28f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -910,4 +910,7 @@ <string name="audio_books">Audio books</string> <string name="server_version_not_supported">Server version not supported</string> <string name="appears_on">Appears on</string> + <string name="dim_after_delay">Dim screen after delay</string> + <string name="dim_percentage">Dim percentage</string> + <string name="dim_screen">Dim screen</string> </resources> From b064af7c4ed167ea011282e9d590c3f7f04c506f Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 17 Aug 2026 22:54:15 -0400 Subject: [PATCH 374/475] Update item fields queried & expand usage of primary aspect ratio (#1852) ## Description Slight optimization to remove "child count" for grid pages. Adds back "primary image aspect ratio" for several queries where its necessary. Also updates extras cards to use the "primary image aspect ratio" instead of a hard coded value. Dev note: some light refactoring is also in this PR including renaming the item fields lists to better reflect the intended usage in code ### Related issues Fixes #1843 ### Testing Emulator, I tested many pages and views, but probably need to do "real world" testing to see if I missed anything ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/ExtrasItem.kt | 4 ++ .../wholphin/services/ExtrasService.kt | 4 +- .../wholphin/services/MusicService.kt | 4 +- .../wholphin/services/PlaylistCreator.kt | 12 ++--- .../damontecres/wholphin/ui/UiConstants.kt | 16 +++--- .../wholphin/ui/cards/ExtrasRow.kt | 2 +- .../ui/components/CollectionFolderView.kt | 51 +++++++++---------- .../wholphin/ui/components/GenreCardGrid.kt | 21 ++++---- .../wholphin/ui/components/StudioCardGrid.kt | 23 +++++---- .../wholphin/ui/detail/PlaylistDetails.kt | 4 +- .../ui/detail/movie/MovieViewModel.kt | 4 +- .../ui/detail/music/AlbumDetailsPage.kt | 6 +-- .../ui/detail/music/ArtistDetailsPage.kt | 12 ++--- .../wholphin/ui/detail/music/Utils.kt | 8 +-- .../ui/detail/series/SeriesViewModel.kt | 4 +- .../wholphin/util/ApiRequestPager.kt | 13 +++++ 16 files changed, 105 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index 8e17ec0d..490103b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -27,6 +27,7 @@ sealed interface ExtrasItem { val subtitle: String? val isPlayed: Boolean val playedPercentage: Double + val aspectRatio: Float? /** * Represents multiple extras of the same type @@ -39,6 +40,7 @@ sealed interface ExtrasItem { override val title: String, override val subtitle: String, override val isPlayed: Boolean, + override val aspectRatio: Float?, ) : ExtrasItem { override val destination: Destination = Destination.ItemGrid( @@ -82,6 +84,8 @@ sealed interface ExtrasItem { get() = item.played override val playedPercentage get() = item.data.userData?.playedPercentage ?: 0.0 + override val aspectRatio: Float? + get() = item.aspectRatio } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index 381032e1..80a4e5ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -74,6 +74,7 @@ class ExtrasService items.size, items.size, ) + val itemForImage = items.random() ExtrasItem.Group( parentId = itemId, type = type, @@ -83,9 +84,10 @@ class ExtrasService isPlayed = items.all { it.played }, imageUrl = imageUrlService.getItemImageUrl( - items.random(), + itemForImage, ImageType.PRIMARY, ), + aspectRatio = itemForImage.aspectRatio, ) } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 38888485..bab5fd93 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -15,7 +15,7 @@ import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain @@ -180,7 +180,7 @@ class MusicService userId = serverRepository.currentUser?.id, itemId = itemId, limit = 200, - fields = DefaultItemFields, + fields = DetailItemFields, ).content.items .map { BaseItem(it, false) } setQueue(items, false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 22ecb1ca..b1a48753 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -8,7 +8,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.data.model.PlaylistItem -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.gt @@ -65,7 +65,7 @@ class PlaylistCreator GetEpisodesRequest( seriesId = seriesId, seasonId = seasonId, - fields = DefaultItemFields, + fields = DetailItemFields, startItemId = episodeId, sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, @@ -94,7 +94,7 @@ class PlaylistCreator GetItemsRequest( userId = serverRepository.currentUser?.id, parentId = playlistId, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, sortBy = listOf(sortAndDirection.sort), @@ -135,7 +135,7 @@ class PlaylistCreator excludeItemIds = listOf(item.id), sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, excludeLocationTypes = listOf(LocationType.VIRTUAL), @@ -208,7 +208,7 @@ class PlaylistCreator seasonId = item.id, limit = Playlist.MAX_SIZE, sortBy = ItemSortBy.RANDOM, - fields = DefaultItemFields, + fields = DetailItemFields, ).content.items .convertAndAddParts() .let { @@ -236,7 +236,7 @@ class PlaylistCreator seriesId = item.id, limit = Playlist.MAX_SIZE, sortBy = ItemSortBy.RANDOM, - fields = DefaultItemFields, + fields = DetailItemFields, ).content.items .convertAndAddParts() .let { 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 b91e2b45..a37eb2f5 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 @@ -45,11 +45,10 @@ object AppColors { const val DEFAULT_PAGE_SIZE = 100 /** - * The default [ItemFields] to fetch for most queries + * The [ItemFields] to fetch for detailed paged and playback */ -val DefaultItemFields = +val DetailItemFields = listOf( - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.TRICKPLAY, ItemFields.SORT_NAME, @@ -57,6 +56,7 @@ val DefaultItemFields = ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.CAN_DELETE, + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ) /** @@ -64,13 +64,17 @@ val DefaultItemFields = */ val SlimItemFields = listOf( - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.CAN_DELETE, ) +/** + * ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard] + */ +val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO + val HomeItemFields = listOf( ItemFields.OVERVIEW, @@ -78,13 +82,13 @@ val HomeItemFields = ) val PhotoItemFields = - DefaultItemFields + + DetailItemFields + listOf( ItemFields.WIDTH, ItemFields.HEIGHT, ) -val ProgramItemFields = DefaultItemFields + listOf(ItemFields.CHANNEL_INFO) +val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO) object Cards { const val HEIGHT_2X3_DP = 172 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 2bf2bac6..238adaf8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -38,7 +38,7 @@ fun ExtrasRow( unplayedItemCount = -1, playedPercentage = item?.playedPercentage ?: 0.0, numberOfVersions = -1, - aspectRatio = AspectRatios.FOUR_THREE, // TODO + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, ) }, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 1a7992d5..f2b17325 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -71,7 +71,6 @@ import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted @@ -476,13 +475,16 @@ class CollectionFolderViewModel when (filter.override) { GetItemsFilterOverride.ARTIST -> { GetArtistsHandler.countMatching( - createGetArtistsRequest(filter).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ), + api = api, + request = + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), ) } @@ -494,30 +496,25 @@ class CollectionFolderViewModel GetItemsFilterOverride.NONE -> { GetItemsRequestHandler.countMatching( - createGetItemsRequest( - sortAndDirection = state.value.sortAndDirection, - recursive = recursive, - filter = filter, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ), + api = api, + request = + createGetItemsRequest( + sortAndDirection = state.value.sortAndDirection, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), ) } } } - /** - * [request] must ask for the count, ie `limit = 0` and `enableTotalRecordCount = true` - */ - private suspend fun <T> RequestHandler<T>.countMatching(request: T): Int { - val result by execute(api, request) - return result.totalRecordCount - } - override fun setWatched( position: Int, itemId: UUID, 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 17133f4d..ecc643fa 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 @@ -149,16 +149,17 @@ class GenreViewModel suspend fun positionOfLetter(letter: Char): Int = withContext(WholphinDispatchers.IO) { - val request = - GetGenresRequest( - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetGenresRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + GetGenresRequestHandler.countMatching( + api = api, + request = + GetGenresRequest( + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 78f2cbd3..9ff0e2c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -120,17 +120,18 @@ class StudioViewModel suspend fun positionOfLetter(letter: Char): Int = withContext(WholphinDispatchers.IO) { - val request = - GetStudiosRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetStudiosRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + GetStudiosRequestHandler.countMatching( + api = api, + request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 715104f5..6b1c090d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -70,7 +70,7 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ContextMenu @@ -225,7 +225,7 @@ class PlaylistViewModel GetItemsRequest( parentId = itemId, userId = user.id, - fields = DefaultItemFields, + fields = SlimItemFields, sortBy = listOf(sortAndDirection.sort), sortOrder = listOf(sortAndDirection.direction), ), 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 49a7d742..168a392c 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 @@ -26,7 +26,7 @@ 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.ItemRowFields import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -168,7 +168,7 @@ class MovieViewModel GetSimilarItemsRequest( userId = serverRepository.currentUser?.id, itemId = itemId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index c43b0fe6..a45f45f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -58,7 +58,7 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ExtrasRow @@ -211,7 +211,7 @@ class AlbumViewModel userId = serverRepository.currentUser?.id, albumIds = listOf(itemId), parentId = null, - fields = DefaultItemFields, + fields = ItemRowFields, recursive = true, includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), ) @@ -563,7 +563,7 @@ fun AlbumDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index 2a010148..4fc21483 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -54,7 +54,7 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -157,7 +157,7 @@ class ArtistViewModel GetItemsRequest( albumArtistIds = listOf(itemId), recursive = true, - fields = DefaultItemFields, + fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), sortBy = listOf( @@ -174,7 +174,7 @@ class ArtistViewModel GetItemsRequest( contributingArtistIds = listOf(itemId), recursive = true, - fields = DefaultItemFields, + fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), sortBy = listOf( @@ -208,7 +208,7 @@ class ArtistViewModel val request = GetItemsRequest( artistIds = listOf(itemId), - fields = DefaultItemFields, + fields = SlimItemFields, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), minCommunityRating = 1.0, @@ -266,7 +266,7 @@ class ArtistViewModel userId = serverRepository.currentUser?.id, artistIds = listOf(itemId), parentId = null, - fields = DefaultItemFields, + fields = ItemRowFields, recursive = true, includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), ) @@ -671,7 +671,7 @@ fun ArtistDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt index 8e170706..26346e7c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.api.client.ApiClient @@ -19,7 +19,7 @@ suspend fun ViewModel.getPagerForAlbum( GetItemsRequest( parentId = albumId, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.PARENT_INDEX_NUMBER, @@ -39,7 +39,7 @@ suspend fun ViewModel.getPagerForArtist( artistIds = listOf(artistId), recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, // TODO better sort sortBy = listOf( @@ -60,7 +60,7 @@ suspend fun ViewModel.getPagerForPlaylist( parentId = playlistId, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.DEFAULT, 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 eda99f49..8d0bb1c5 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 @@ -27,7 +27,7 @@ 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.ItemRowFields import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault @@ -232,7 +232,7 @@ class SeriesViewModel GetSimilarItemsRequest( userId = serverRepository.currentUser?.id, itemId = seriesId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items 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 a87d79ec..750ba711 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 @@ -131,6 +131,19 @@ sealed interface RequestHandler<T> { api: ApiClient, request: T, ): Response<BaseItemDtoQueryResult> + + /** + * Count the total number of possible results for the request. + * + * [request] should be efficient and use `limit = 0` and `enableTotalRecordCount = true` + */ + suspend fun countMatching( + api: ApiClient, + request: T, + ): Int { + val result by execute(api, request) + return result.totalRecordCount + } } @Serializable From ed204ef7e94a5ca3fb96266ab95a445b4941233d Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 20 Aug 2026 17:08:31 -0400 Subject: [PATCH 375/475] Fix experimental AC3 setting being used by default (#1879) ## Description There were two problems with the experimental "Use AC3 for surround sound audio" setting (#947): 1. Defaulted to true 2. Access wasn't guarded by enabling experimental settings This caused unintentional audio transcoding ### Related issues Fixes #1878 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../preferences/ExperimentalPreference.kt | 2 +- .../wholphin/services/AppUpgradeHandler.kt | 16 ++++++++++++++++ .../wholphin/services/DeviceProfileService.kt | 3 ++- .../wholphin/services/MusicService.kt | 4 +++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index e5923659..f18df846 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -40,7 +40,7 @@ object ExperimentalPreference { val PreferAc3ForSurround = AppSwitchPreference<AppPreferences>( title = R.string.prefer_ac3_for_surround, - defaultValue = true, + defaultValue = false, getter = { it.experimentalPreferences.preferAc3Surround }, setter = { prefs, value -> prefs.updateExperimentalPreferences { 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 29348be6..4910110a 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 @@ -13,9 +13,11 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference 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.updateExperimentalPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences @@ -426,5 +428,19 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) { + // preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings + appPreferences.updateData { + if (!it.experimentalPreferences.enabled) { + it.updateExperimentalPreferences { + preferAc3Surround = + ExperimentalPreference.PreferAc3ForSurround.defaultValue + } + } else { + it + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index a7dfe235..29df802c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.ExperimentalPreferences import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -62,7 +63,7 @@ class DeviceProfileService pgsDirectPlay = newConfig.overrides.directPlayPgs, dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, - preferAc3ForSurround = appPrefs.experimentalPreferences.preferAc3Surround, + preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index bab5fd93..ec6e9d2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -13,6 +13,7 @@ import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.DetailItemFields @@ -108,7 +109,8 @@ class MusicService private suspend fun preferAc3Surround() = userPreferencesService .getCurrent() - .appPreferences.experimentalPreferences.preferAc3Surround + .appPreferences.experimentalPreferences + .enabled { preferAc3Surround } /** * Start music playback From 39523aa6e2176f18702cb9444184ab1a637557b5 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 20 Aug 2026 17:09:39 -0400 Subject: [PATCH 376/475] Several minor bug fixes (#1866) ## Description Another PR to gather small bug fixes: - Fix aspect ratio for episodes on person page - Use the song's artist instead of the album's artist on album page - Fix how the dim percentage is applied to the OS screensaver dim percentage (#1834) ### Related issues Fixes #1851 Fixes #1863 ### Testing <!-- Describe how this change was tested and on what device(s) --> ## Screenshots <!-- Please include screenshots if the PR alters any UI elements --> ## AI or LLM usage <!-- If you used any AI or LLM assistance, please list where in the code and how you tested it --> --- .../damontecres/wholphin/MainContent.kt | 4 +- .../wholphin/WholphinDreamService.kt | 39 ++++++++++--------- .../wholphin/services/ScreensaverService.kt | 1 - .../wholphin/ui/detail/PersonPage.kt | 6 ++- .../ui/detail/music/SongListDisplay.kt | 7 +++- 5 files changed, 33 insertions(+), 24 deletions(-) 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 25221874..26dda908 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -154,7 +154,9 @@ fun MainContent( val alpha = userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f Box( - Modifier.background(Color.Black.copy(alpha = alpha)), + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), ) } } 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 41a87222..57d4a447 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin import android.service.dreams.DreamService +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -9,7 +11,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.datastore.core.DataStore import androidx.lifecycle.Lifecycle @@ -95,14 +97,6 @@ class WholphinDreamService : preferencesDataStore.data.collectLatest { prefs = it } } prefs?.let { prefs -> - val alpha = - prefs.interfacePreferences.screensaverPreference.run { - if (dimEnabled) { - dimPercent / 100f - } else { - 1f - } - } CoilConfig( prefs = prefs, okHttpClient = okHttpClient, @@ -113,16 +107,23 @@ class WholphinDreamService : 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() - .alpha(alpha = alpha), - ) + Box(Modifier.fillMaxSize()) { + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + if (screensaverPrefs.dimEnabled) { + val alpha = screensaverPrefs.dimPercent / 100f + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), + ) + } + } } } } 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 90b0c04a..09aa29a8 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 @@ -85,7 +85,6 @@ class ScreensaverService * Reset the timer before showing the in-app screensaver */ fun pulse() { - Timber.v("state=%s", _state.value) waitJob?.cancel() if (_state.value.enabled) { // Timber.v("pulse") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index c7f9ee26..8c9b05de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -46,10 +46,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton @@ -180,7 +181,7 @@ class PersonViewModel GetItemsRequest( personIds = listOf(itemId), includeItemTypes = listOf(type), - fields = SlimItemFields, + fields = ItemRowFields, recursive = true, sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME), sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING), @@ -409,6 +410,7 @@ fun PersonPageContent( }, onLongClick = onLongClick, imageHeight = Cards.heightEpisode, + aspectRatio = item?.aspectRatio ?: AspectRatios.FOUR_THREE, modifier = mod .ifElse( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 3879cca4..e4664ac9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -54,7 +54,12 @@ fun SongListItem( isQueued: Boolean = false, ) = SongListItem( title = song?.title, - artist = if (showArtist) song?.data?.albumArtist else null, + artist = + if (showArtist) { + remember(song) { song?.data?.artists?.joinToString(", ") } + } else { + null + }, indexNumber = song?.data?.indexNumber, runtime = remember(song) { From 4b927e6b7b138337615373b6df770c50177372b7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 21 Aug 2026 15:04:07 -0400 Subject: [PATCH 377/475] Fix Next Up channel launch sign in (#1886) ## Description When parsing intents, not every path for authentication was checked, so launching via the "Next Up" OS channel wouldn't check for a profile PIN. This PR fixes that. Additionally, the "Next Up" OS channel launch wasn't checking the correct parameter to ensure the back stack was written properly. ### Related issues Fixes #1883 ### Testing I manually test on the emulator with these test cases: - No profile PIN, cold start - No profile PIN, hot start - Profile PIN, cold start - Profile PIN, hot start - No profile PIN, launch via "Next Up" channel - No profile PIN, launch via intent - Profile PIN, launch via "Next Up" channel - Profile PIN, launch via intent Also added unit tests for intent-based and regular launching which covers those cases and more. ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 13 +- .../wholphin/services/IntentService.kt | 84 ++-- .../wholphin/services/IntentServiceTest.kt | 222 ++++++++++ .../test/TestMainActivityViewModel.kt | 415 ++++++++++++++++++ .../damontecres/wholphin/test/TestUtils.kt | 36 ++ 5 files changed, 736 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.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 9fa76ea0..0baaefdc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -439,9 +439,16 @@ class MainActivityViewModel val result = intent?.let { intentService.parseIntent(intent) } when (result) { is IntentResult.Error -> { - setupNavigationManager.navigateTo(SetupDestination.ServerList) + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) Timber.e("Error parsing intent: %s", result.message) - showToast(context, "Invalid intent: ${result.message}") + showToast(context, result.message) return@withLock } @@ -505,7 +512,7 @@ class MainActivityViewModel prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { - if (current.user.hasPin || current.user.requireLogin) { + if (current.user.isProtected) { setupNavigationManager.navigateTo( SetupDestination.UserList( current.server, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index 95343b47..a37a0ed2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -29,49 +29,26 @@ class IntentService Timber.v("Intent data: %s", intent.data) val action = intent.action ?: intent.data?.host if ( - intent.getStringParam("type") == null && + intent.getStringParam(INTENT_ITEM_TYPE) == null && (action == Intent.ACTION_MAIN || action.isNullOrBlank()) ) { + // Normal app launch return IntentResult.NoOp } - val userId = intent.getStringParam("userId")?.toUUIDOrNull() - val serverId = intent.getStringParam("serverId")?.toUUIDOrNull() - if (userId != null && serverId != null) { - Timber.v("Intent switches user") - val user = - serverRepository.serverDao.getUser(serverId, userId) - ?: return IntentResult.Error("User not found") - if (!user.isProtected) { - serverRepository.restoreSession(serverId, userId) - } else { - return IntentResult.Error("Cannot switch to specified user") - } - } else if (serverRepository.currentUser == null) { - Timber.v("No current user, so restoring last") - val appPrefs = userPreferencesService.flow.first().appPreferences - if (appPrefs.signInAutomatically) { - val userId = appPrefs.currentUserId.toUUIDOrNull() - val serverId = appPrefs.currentServerId.toUUIDOrNull() - if (userId != null && serverId != null) { - serverRepository.restoreSession(serverId, userId) - ?: return IntentResult.Error("Error restoring user") - } else { - return IntentResult.Error("Could not auto sign-in, specify a server & user") - } - } else { - return IntentResult.Error("Auto sign-in not enabled, specify a server & user") - } + val result = prepare(intent) + if (result != null) { + return result } - if (intent.getStringParam("type") != null) { + // Existence of this key implies launching from Play Next channel + if (intent.getStringParam(INTENT_ITEM_TYPE) != null) { return getDestinationFromChannel(intent)?.let { IntentResult.Target( destinations = listOf(it), addHomeToBackStack = false, ) - } - ?: IntentResult.Error("Invalid parameters") + } ?: IntentResult.Error("Invalid parameters") } if (action == Intent.ACTION_SEARCH || action == "search") { @@ -130,6 +107,49 @@ class IntentService return IntentResult.Target(destinations) } + internal suspend fun prepare(intent: Intent): IntentResult? { + val appPrefs = userPreferencesService.flow.first().appPreferences + + val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull() + val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() + return if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = serverRepository.serverDao.getUser(serverId, userId) + if (user != null && !user.isProtected) { + serverRepository.restoreSession(serverId, userId) + ?: IntentResult.Error("Error restoring user") + null + } else { + IntentResult.Error("Cannot switch to specified user") + } + } else { + val profileProtected = + serverRepository.current.value + ?.user + ?.isProtected == true + if (appPrefs.signInAutomatically && !profileProtected) { + Timber.v("No current user, so restoring last") + val userId = appPrefs.currentUserId?.toUUIDOrNull() + val serverId = appPrefs.currentServerId?.toUUIDOrNull() + + if (userId != null && serverId != null) { + val current = + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + if (current.user.isProtected) { + IntentResult.Error("Could not auto sign-in, user is protected") + } else { + null + } + } else { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } + } else { + IntentResult.Error("Auto sign-in not enabled or user is protected") + } + } + } + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) private fun Intent.getLongParam(key: String) = @@ -173,6 +193,8 @@ class IntentService const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + const val INTENT_SERVER_ID = "serverId" + const val INTENT_USER_ID = "userId" } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt new file mode 100644 index 00000000..17c6a313 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.services + +import android.content.Intent +import com.github.damontecres.wholphin.data.CurrentUser +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.preferences.update +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.toServerString +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.UUID +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentServiceTest { + private val api: ApiClient = mockk() + private val serverRepository: ServerRepository = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + + lateinit var intentService: IntentService + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setup() { + intentService = IntentService(api, serverRepository, userPreferencesService) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { userPreferencesService.flow } returns + flow { + emit( + UserPreferences( + AppPreferences.getDefaultInstance().update(block), + null, + ), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertNull(result) + + coVerify { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign disabled in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertNull(result) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified does not exist`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null + coEvery { serverRepository.restoreSession(serverId, userId) } returns null + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt new file mode 100644 index 00000000..556a2fc0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt @@ -0,0 +1,415 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.MainActivityViewModel +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TestMainActivityViewModel { + private val testDispatcher = StandardTestDispatcher() + + private val context: Context = mockk() + private val preferences: DataStore<AppPreferences> = mockk() + private val serverRepository: ServerRepository = mockk() + private val setupNavigationManager: SetupNavigationManager = mockk(relaxed = true) + private val navigationManager: NavigationManager = mockk(relaxed = true) + private val deviceProfileService: DeviceProfileService = mockk() + private val backdropService: BackdropService = mockk(relaxed = true) + private val appUpgradeHandler: AppUpgradeHandler = mockk() + private val intentService: IntentService = mockk() + private val serverDao: JellyfinServerDao = mockk() + + val viewModel = + MainActivityViewModel( + context, + preferences, + serverRepository, + setupNavigationManager, + navigationManager, + deviceProfileService, + backdropService, + appUpgradeHandler, + intentService, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { appUpgradeHandler.needUpgrade() } returns false + every { appUpgradeHandler.copySubfont(any()) } returns Unit + every { serverRepository.serverDao } returns serverDao + coEvery { serverDao.getServer(serverId) } returns + JellyfinServerUsers( + currentUser.server, + // User list is unused + emptyList(), + ) + mockkStatic(Toast::class) + val mockToast = mockk<Toast>() + every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast + every { mockToast.show() } returns Unit + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkStatic(Toast::class) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { preferences.data } returns + flow { + emit( + AppPreferences.getDefaultInstance().update(block), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test auto sign disabled in with current server`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + verify(exactly = 1) { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getServer(serverId) } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(null, null) } returns null + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is noop`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test intent result is error, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test intent result is error, does not have server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is target, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + // Note: navigation manager internally handles Home destination, so it won't be in this list + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has server, no home backstack`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = false, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has no server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 87a44416..21841c25 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -1,11 +1,15 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.isNotNullOrBlank import io.mockk.MockKMatcherScope import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair +import org.jellyfin.sdk.model.api.UserDto fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() } @@ -75,3 +79,35 @@ fun playlist( seriesId = null, genreItems = genres, ) + +fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + +fun user( + serverId: UUID, + userId: UUID = UUID.randomUUID(), +) = JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = null, +) + +fun userDto(userId: UUID) = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + +fun currentUser( + serverId: UUID = UUID.randomUUID(), + userId: UUID = UUID.randomUUID(), +) = CurrentUser( + server(serverId), + user(userId), +) From 7d0dd26f42addb04fd66a615b9c8876506bff1a5 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Fri, 14 Aug 2026 08:34:21 +0000 Subject: [PATCH 378/475] Translated using Weblate (Croatian) Currently translated at 81.8% (527 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 342 +++++++++++++++++++++++++ 1 file changed, 342 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 11700824..0e11d563 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -269,4 +269,346 @@ <string name="ffmpeg_extension_pref">Koristi FFmpeg modul dekodera</string> <string name="global_content_scale">Zadano skaliranje sadržaja</string> <string name="install_update">Instaliraj ažuriranje</string> + <string name="installed_version">Instalirana verzija</string> + <string name="max_homepage_items">Maks. stavki u redovima početne stranice</string> + <string name="nav_drawer_pins_summary">Odaberite zadane stavke za prikaz, ostale će biti skrivene</string> + <string name="nav_drawer_pins">Prilagodi stavke navigacijske ladice</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknite za promjenu stranica</string> + <string name="nav_drawer_switch_on_focus">Promijeni stranice navigacijske ladice pri fokusu</string> + <string name="pass_out_protection">Pauza kad zaspete</string> + <string name="play_theme_music">Reproduciraj tematsku glazbu</string> + <string name="playback_overrides">Nadjačavanja reprodukcije</string> + <string name="remember_selected_tab">Zapamti odabrane kartice</string> + <string name="rewatch_next_up">Omogući ponovno gledanje u \"Slijedi\"</string> + <string name="seek_bar_steps">Koraci trake za premotavanje</string> + <string name="send_app_logs_summary">Korisno za otklanjanje grešaka</string> + <string name="send_app_logs">Pošalji zapise aplikacije na trenutni poslužitelj</string> + <string name="send_crash_reports_summary">Pokušat će poslati na zadnji povezani poslužitelj</string> + <string name="send_crash_reports">Pošalji izvješća o rušenju</string> + <string name="settings">Postavke</string> + <string name="skip_back_on_resume_preference">Premotaj unatrag pri nastavku reprodukcije</string> + <string name="skip_back_preference">Premotaj unatrag</string> + <string name="skip_commercials_behavior">Ponašanje preskakanja reklama</string> + <string name="skip_forward_preference">Premotaj unaprijed</string> + <string name="skip_intro_behavior">Ponašanje preskakanja uvoda</string> + <string name="skip_outro_behavior">Ponašanje preskakanja odjave</string> + <string name="skip_previews_behavior">Ponašanje preskakanja najava</string> + <string name="skip_recap_behavior">Ponašanje preskakanja sažetka</string> + <string name="update_available">Dostupno ažuriranje</string> + <string name="update_url_summary">URL za provjeru ažuriranja aplikacije</string> + <string name="update_url">URL za ažuriranje</string> + <string name="font_size">Veličina fonta</string> + <string name="font_color">Boja fonta</string> + <string name="font_opacity">Neprozirnost fonta</string> + <string name="edge_style">Stil rubova</string> + <string name="edge_color">Boja rubova</string> + <string name="background_opacity">Neprozirnost pozadine</string> + <string name="background_style">Stil pozadine</string> + <string name="subtitle_style">Stil titlova</string> + <string name="background_color">Boja pozadine</string> + <string name="reset">Vratiti zadano</string> + <string name="bold_font">Podebljani font</string> + <string name="player_backend">Pozadinski sustav reprodukcije</string> + <string name="mpv_hardware_decoding">MPV: Koristi hardversko dekodiranje</string> + <string name="disable_if_crash">Onemogućite ako dolazi do rušenja</string> + <string name="mpv_options">MPV Opcije</string> + <string name="exoplayer_options">ExoPlayer opcije</string> + <string name="skip_segment_unknown">Preskoči segment</string> + <string name="skip_segment_commercial">Preskoči reklame</string> + <string name="skip_segment_preview">Preskoči najavu</string> + <string name="skip_segment_recap">Preskoči sažetak</string> + <string name="skip_segment_outro">Preskoči odjavu</string> + <string name="skip_segment_intro">Preskoči uvod</string> + <string name="ffmpeg_prefer">Preferiraj FFmpeg umjesto ugrađenih dekodera</string> + <string name="ffmpeg_never">Nikad ne koristi FFmpeg dekodere</string> + <string name="next_up_playback_end">Na kraju reprodukcije</string> + <string name="next_up_outro">Tijekom odjavne špice/odjave</string> + <string name="next_up_never">Nikad</string> + <string name="white">Bijela</string> + <string name="light_gray">Svijetlosiva</string> + <string name="dark_gray">Tamnosiva</string> + <string name="yellow">Žuta</string> + <string name="cyan">Tirkizna</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Obrub</string> + <string name="subtitle_edge_shadow">Sjena</string> + <string name="background_style_wrap">Obavijeno</string> + <string name="background_style_boxed">U okviru</string> + <string name="prefer_mpv">Preferiraj MPV</string> + <string name="external_player">Vanjski player</string> + <string name="player_backend_options_subtitles_prefer_mpv">Koristi ExoPlayer za HDR reprodukciju</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Glavna</string> + <string name="image_type_thumb">Sličica</string> + <string name="view_options_type_grid">Mreža</string> + <string name="view_options_type_list">Popis</string> + <string name="view_options_type_list_compact">Zbijeni popis</string> + <string name="backdrop_style_dynamic">Slika s dinamičnom bojom</string> + <string name="backdrop_style_image">Samo slika</string> + <string name="enter_url_api_key"><![CDATA[Unesite URL i API ključ]]></string> + <string name="italic_font">Ukošeni font</string> + <string name="font">Font</string> + <string name="background">Pozadina</string> + <string name="success">Uspjeh</string> + <string name="official_rating">Roditeljska ocjena</string> + <string name="runtime_sort">Trajanje</string> + <string name="extras_title">Dodaci</string> + <string name="other_extras_title">Ostalo</string> + <string name="behind_the_scenes_title">Iza kulisa</string> + <string name="theme_songs_title">Tematske pjesme</string> + <string name="theme_videos_title">Tematski videozapisi</string> + <string name="clips_title">Isječci</string> + <string name="deleted_scenes_title">Izbrisane scene</string> + <string name="interviews_title">Intervjui</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Isječci</string> + <string name="featurettes_title">Prateći filmovi</string> + <string name="shorts_title">Kratki filmovi</string> + <string name="favorite_items_title">Favoriti %s</string> + <plurals name="trailers"> + <item quantity="one">Najava</item> + <item quantity="few">Najave</item> + <item quantity="other">Najava</item> + </plurals> + <plurals name="other_extras"> + <item quantity="one">Ostalo</item> + <item quantity="few">Ostalo</item> + <item quantity="other">Ostalo</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Iza kulisa</item> + <item quantity="few">Iza kulisa</item> + <item quantity="other">Iza kulisa</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tematska pjesma</item> + <item quantity="few">Tematske pjesme</item> + <item quantity="other">Tematskih pjesama</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tematski videozapis</item> + <item quantity="few">Tematska videozapisa</item> + <item quantity="other">Tematskih videozapisa</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječka</item> + <item quantity="other">Isječaka</item> + </plurals> + <string name="played">Pogledano</string> + <string name="filter">Filtar</string> + <string name="year">Godina</string> + <string name="decade">Desetljeće</string> + <string name="remove">Ukloni</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Koristi gpu-next</string> + <string name="mpv_conf">Uredi mpv.conf</string> + <string name="discard_change">Odbaciti promjene?</string> + <string name="subtitle_margin">Margina</string> + <string name="verbose_logging">Detaljno bilježenje</string> + <string name="enter_pin">Unesite PIN</string> + <string name="sign_in_auto">Automatska prijava</string> + <string name="use_server_credentials">Prijava putem poslužitelja</string> + <string name="press_enter_to_confirm">Pritisnite srednju tipku za potvrdu</string> + <string name="require_pin_code">Zahtijevaj PIN za profil</string> + <string name="require_login">Zahtijevaj prijavu za profil</string> + <string name="confirm_pin">Potvrdite PIN</string> + <string name="incorrect">Netočno</string> + <string name="pin_too_short">PIN mora imati 4 ili više znamenki</string> + <string name="will_remove_pin">Ukloniti će PIN</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="image_cache_size">Veličina predmemorije slika na disku (MB)</string> + <string name="disable_audio_offload">Onemogući hardversko rasterećenje zvuka</string> + <string name="disable_audio_offload_summary">Onemogućavanje rasterećenja dekodiranja zvuka može riješiti zastajkivanja reprodukcije na nekim uređajima</string> + <string name="view_options">Opcije prikaza</string> + <string name="columns">Stupci</string> + <string name="spacing">Razmak</string> + <string name="aspect_ratio">Omjer slike</string> + <string name="show_details">Prikaži pojedinosti</string> + <string name="image_type">Vrsta slike</string> + <string name="cast_and_crew"><![CDATA[Glumci i filmska ekipa]]></string> + <string name="guest_stars">Gostujuće zvijezde</string> + <string name="edge_size">Veličina rubova</string> + <string name="refresh_rate_switching">Promjena frekvencije osvježavanja</string> + <string name="automatic">Automatski</string> + <string name="username_or_password">Koristi korisničko ime/lozinku</string> + <string name="login">Prijava</string> + <string name="general">Općenito</string> + <string name="container">Kontejner</string> + <string name="title">Naslov</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Razina</string> + <string name="resolution">Razlučivost</string> + <string name="anamorphic">Anamorfni</string> + <string name="interlaced">Isprepleteni</string> + <string name="framerate">Broj sličica</string> + <string name="bit_depth">Dubina boje</string> + <string name="video_range">Raspon videa</string> + <string name="video_range_type">Vrsta raspona videa</string> + <string name="color_space">Prostor boja</string> + <string name="color_transfer">Prijenos boja</string> + <string name="color_primaries">Primarne boje</string> + <string name="pixel_format">Format piksela</string> + <string name="ref_frames">Referentne sličice</string> + <string name="nal">NAL</string> + <string name="language">Jezik</string> + <string name="layout">Raspored</string> + <string name="channels">Kanali</string> + <string name="sample_rate">Frekvencija uzorkovanja</string> + <string name="avc">AVC</string> + <string name="yes">Da</string> + <string name="no">Ne</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Prikaži naslove</string> + <string name="live_tv_repeat">Ponavljanje</string> + <string name="favorite_channels_at_beginning">Prvo prikaži omiljene kanale</string> + <string name="sort_channels_recently_watched">Sortiraj kanale po nedavno gledanima</string> + <string name="color_code_programs">Programi u boji</string> + <string name="subtitle_delay">Kašnjenje titlova</string> + <string name="backdrop_display">Stil pozadine</string> + <string name="combined_search_results">Kombinirani rezultati pretraživanja</string> + <string name="combined_search_results_on">Prikaži sve rezultate na jednom popisu</string> + <string name="combined_search_results_off">Prikaži rezultate po kategoriji</string> + <string name="show_voice_search_button">Prikaži gumb za glasovno pretraživanje</string> + <string name="visible_ui">Vidljivo</string> + <string name="hidden_ui">Skriveno</string> + <string name="results">Rezultati</string> + <string name="resolution_switching">Promjena razlučivosti</string> + <string name="local">Lokalno</string> + <string name="play_trailer">Reproduciraj najavu</string> + <string name="no_trailers">Bez najava</string> + <string name="clear_track_choices">Očisti odabir zapisa</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> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Izravna reprodukcija Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Zanemaruje provjere kompatibilnosti uređaja</string> + <string name="discover">Otkrij</string> + <string name="request">Zatraži</string> + <string name="pending">Na čekanju</string> + <string name="seerr_integration">Seerr integracija</string> + <string name="remove_seerr_server">Ukloni Seerr poslužitelj</string> + <string name="seerr_server_added">Seerr poslužitelj dodan</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Autorizirajte drugi uređaj za prijavu na vaš račun</string> + <string name="quick_connect_code">Unesite Quick Connect kod</string> + <string name="quick_connect_code_error">Kod mora imati 6 znamenki</string> + <string name="quick_connect_success">Uređaj uspješno autoriziran</string> + <string name="password">Lozinka</string> + <string name="username">Korisničko ime</string> + <string name="url">URL</string> + <string name="trending">U trendu</string> + <string name="upcoming_movies">Nadolazeći filmovi</string> + <string name="upcoming_tv">Nadolazeće TV serije</string> + <string name="request_4k">Zatraži u 4K</string> + <string name="software_decoding_av1">AV1 programsko dekodiranje</string> + <string name="upgrade_mpv_toast">MPV je sada zadani player osim za HDR.\nOvo možete promijeniti u postavkama.</string> + <string name="hdr_subtitle_style">HDR stil titlova</string> + <string name="image_subtitle_opacity">Neprozirnost slikovnih titlova</string> + <string name="brightness">Svjetlina</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Zasićenost</string> + <string name="hue">Nijansa</string> + <string name="red">Crvena</string> + <string name="green">Zelena</string> + <string name="blue">Plava</string> + <string name="blur">Zamućenje</string> + <string name="save_for_album">Spremi u album</string> + <string name="play_slideshow">Pokreni prezentaciju</string> + <string name="stop_slideshow">Zaustavi prezentaciju</string> + <string name="slideshow_at_beginning">Na početak</string> + <string name="no_more_images">Nema više fotografija</string> + <string name="rotate_left">Rotiraj ulijevo</string> + <string name="rotate_right">Rotiraj udesno</string> + <string name="zoom_in">Približi</string> + <string name="zoom_out">Udalji</string> + <string name="slideshow_duration">Trajanje prezentacije</string> + <string name="play_videos_during_slideshow">Reproduciraj videozapise tijekom prezentacije</string> + <string name="send_media_info_log_to_server">Pošalji zapis o mediju na poslužitelj</string> + <string name="no_limit">Bez ograničenja</string> + <string name="max_days_next_up">Maks. dana u \"Slijedi\"</string> + <string name="albums">Albumi</string> + <string name="artists">Izvođači</string> + <string name="songs">Pjesme</string> + <string name="go_to_artist">Idi na izvođača</string> + <string name="now_playing">Trenutno svira</string> + <string name="instant_mix">Trenutni miks</string> + <string name="go_to_album">Idi na album</string> + <string name="add_to_queue">Dodaj u red čekanja</string> + <string name="add_row">Dodaj red</string> + <string name="genres_in">Žanrovi u %1$s</string> + <string name="recently_released_in">Nedavno objavnjeno u %1$s</string> + <string name="height">Visina</string> + <string name="apply_all_rows">Primijeni na sve redove</string> + <string name="customize_home">Prilagodi početnu stranicu</string> + <string name="home_rows">Redovi početne stranice</string> + <string name="load_from_server">Učitaj s korisničkog profila poslužitelja</string> + <string name="save_to_server">Spremi na korisnički profil poslužitelja</string> + <string name="load_from_web_client">Učitaj s web klijenta</string> + <string name="use_series">Koristi sliku serije</string> + <string name="add_row_for">Dodaj red za %1$s</string> + <string name="overwrite_server_settings">Prebrisati postavke na poslužitelju?</string> + <string name="overwrite_local_settings">Prebrisati lokalne postavke?</string> + <string name="for_episodes">Za epizode</string> + <string name="suggestions_for">Prijedlozi za %1$s</string> + <string name="increase_all_cards_size">Povećaj veličinu svih kartica</string> + <string name="decrease_all_cards_size">Smanji veličinu svih kartica</string> + <string name="use_thumb_images">Koristi sličice</string> + <string name="settings_saved">Postavke spremljene</string> + <string name="display_presets">Zadane postavke prikaza</string> + <string name="display_presets_description">Ugrađene postavke za brzo oblikovanje svih redova</string> + <string name="customize_home_summary">Odaberite redove i slike na početnoj stranici</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkodiraj ne-stereo zvuk u AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Koristi AC3 za surround zvuk</string> + <string name="start_after">Počni nakon</string> + <string name="animate">Animiraj</string> + <string name="max_age_rating">Maks. dobna ocjena</string> + <string name="no_max">Bez maksimuma</string> + <string name="for_all_ages">Za sve uzraste</string> + <string name="up_to_age">Do dobi %s</string> + <string name="screensaver">Čuvar zaslona</string> + <string name="screensaver_settings">Postavke čuvara zaslona</string> + <string name="start_screensaver">Pokreni čuvar zaslona</string> + <string name="duration">Trajanje</string> + <string name="photos">Fotografije</string> + <string name="include_types">Uključi vrste</string> + <string name="include_types_summary">Za koje vrste stavki prikazivati slike</string> + <string name="content_scale_fit">Prilagodi</string> + <string name="content_scale_crop">Obreži</string> + <string name="content_scale_fill">Ispuni</string> + <string name="content_scale_fill_width">Ispuni širinu</string> + <string name="content_scale_fill_height">Ispuni visinu</string> + <string name="display_preset_default">Wholphin zadani</string> + <string name="display_preset_compact">Wholphin zbijeni</string> + <string name="display_preset_series_thumb">Slike sličica serije</string> + <string name="display_preset_episode_thumbnails">Slike sličica epizoda</string> + <string name="volume_lowest">Najniža</string> + <string name="volume_low">Niska</string> + <string name="volume_medium">Srednja</string> + <string name="volume_high">Visoka</string> + <string name="volume_full">Puna</string> + <string name="purple">Ljubičasta</string> + <string name="orange">Narančasta</string> + <string name="bold_blue">Jarko plava</string> + <string name="black">Crna</string> + <string name="brown">Smeđa</string> + <string name="skip_ignore">Zanemari</string> + <string name="skip_automatically">Automatski preskoči</string> + <string name="skip_ask">Pitaj za preskakanje</string> + <string name="ffmpeg_fallback">Koristi FFmpeg samo ako ne postoji ugrađeni dekoder</string> </resources> From c26fa32aa97d4ec2c802e61de604d13432d283cc Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Sun, 16 Aug 2026 10:35:24 +0000 Subject: [PATCH 379/475] Translated using Weblate (Croatian) Currently translated at 91.7% (591 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 0e11d563..1a3f0526 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -611,4 +611,68 @@ <string name="skip_automatically">Automatski preskoči</string> <string name="skip_ask">Pitaj za preskakanje</string> <string name="ffmpeg_fallback">Koristi FFmpeg samo ako ne postoji ugrađeni dekoder</string> + <string name="api_key">API ključ</string> + <string name="seerr_login">Prijava na Seerr poslužitelj</string> + <string name="seerr_jellyfin_user">Jellyfin korisnik</string> + <string name="seerr_local_user">Lokalni korisnik</string> + <string name="search_and_download_subtitles"><![CDATA[Pretraži i preuzmi titlove]]></string> + <string name="no_subtitles_found">Nisu pronađeni udaljeni titlovi</string> + <string name="series_continueing">U tijeku</string> + <string name="in_app_screensaver">Koristi ugrađeni čuvar zaslona</string> + <string name="delete_item">Sigurno želite izbrisati ovu stavku?</string> + <string name="show_media_management">Prikaži opcije upravljanja medijima</string> + <string name="search_for">Pretraži %s</string> + <string name="actor">Glumac</string> + <string name="composer">Skladatelj</string> + <string name="writer">Scenarist</string> + <string name="guest_star">Gostujuća zvijezda</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstopisac</string> + <string name="arranger">Aranžer</string> + <string name="engineer">Tonski inženjer</string> + <string name="mixer">Miks majstor</string> + <string name="creator">Autor</string> + <string name="artist">Izvođač</string> + <string name="album_artist">Izvođač albuma</string> + <string name="album">Album</string> + <string name="popular_songs">Popularne pjesme</string> + <string name="play_next">Reproduciraj sljedeće</string> + <string name="music_videos">Glazbeni spotovi</string> + <string name="lyrics">Tekst pjesme</string> + <string name="hide_lyrics">Sakrij tekst</string> + <string name="show_lyrics">Prikaži tekst</string> + <string name="song_has_lyrics">Pjesma ima tekst</string> + <string name="remove_from_queue">Ukloni iz reda čekanja</string> + <string name="show_album_cover">Prikaži omot albuma</string> + <string name="show_visualizer">Prikaži vizualizator</string> + <string name="show_backdrop">Prikaži pozadinsku sliku</string> + <string name="play_as_type">Reproduciraj kao?</string> + <string name="visualizer_rationale">Vizualizacija zvuka koji se trenutno reproducira zahtijeva dopuštenje za snimanje zvuka.</string> + <string name="continue_string">Nastavi</string> + <string name="select_all">Odaberi sve</string> + <string name="separate_types">Odvojene vrste</string> + <string name="prefer_logos">Preferiraj prikaz logotipa za naslove</string> + <string name="updated_toast">Wholphin ažuriran na %s</string> + <string name="remove_continue_watching">Ukloni iz \"Nastavi gledanje\"</string> + <string name="remove_next_up">Ukloni seriju iz \"Slijedi\"</string> + <string name="view_removed_next_up">Prikaži serije uklonjene iz \"Slijedi\"</string> + <string name="view_more">Prikaži više</string> + <string name="discover_tv">Otkrij TV serije</string> + <string name="discover_movies">Otkrij filmove</string> + <string name="studios_in">Studiji u %1$s</string> + <string name="last_played">Zadnje reproducirano</string> + <string name="system_default">Zadano sustavom</string> + <string name="ass_subtitle_mode_libass">Izravna reprodukcija s libass</string> + <string name="ass_subtitle_mode_exoplayer">Izravna reprodukcija s ExoPlayer ugrađenim</string> + <string name="ass_subtitle_mode_transcode">Ugradi/transkodiraj na poslužitelju</string> + <string name="ass_subtitle_playback">Reprodukcija SSA/ASS titlova</string> + <string name="cinema_mode">Kino način</string> + <string name="show_more">Prikaži %1$s više</string> + <string name="skip_behavior">Ponašanje preskakanja</string> + <string name="skip_behavior_summary">Za uvode, špice itd.</string> + <string name="play_with">Reproduciraj pomoću</string> + <string name="transcoding">Transkodiranje</string> + <string name="user_interface_language">Jezik korisničkog sučelja</string> + <string name="display_toggles_title">Prikaz ocjena</string> </resources> From e48a8e089fc40d6878fac60f1f088928a2cdb4f0 Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Mon, 17 Aug 2026 10:45:13 +0000 Subject: [PATCH 380/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f6e0e289..f7878d42 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -724,4 +724,6 @@ <string name="audio_books">Audiolivros</string> <string name="server_version_not_supported">Versão do servidor não suportada</string> <string name="appears_on">Aparece em</string> + <string name="disable_audio_offload">Desativar a processamento de áudio por hardware</string> + <string name="disable_audio_offload_summary">Desativar o processamento de áudio por hardware pode resolver os bloqueios de reprodução em alguns dispositivos</string> </resources> From ad2a7109f73369047d28813458a744be4d4bdfed Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Mon, 17 Aug 2026 05:06:33 +0000 Subject: [PATCH 381/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index c6c2f2db..59c9fb5a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -724,4 +724,6 @@ <string name="audio_books">ספרי שמע</string> <string name="hash_matches">התאמות של פונקציית־גיבוב</string> <string name="appears_on">מופיע ב־</string> + <string name="disable_audio_offload_summary">השבתת פענוח אודיו בהורדה עשויה לתקן קפיאות בניגון במכשירים מסוימים</string> + <string name="disable_audio_offload">השבתת פענוח אודיו בהורדה</string> </resources> From 4882ff87e1fc58ec1f9f12856d7f040d8fc22f6d Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Sun, 16 Aug 2026 16:13:32 +0000 Subject: [PATCH 382/475] Translated using Weblate (Croatian) Currently translated at 100.0% (644 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 53 +++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 1a3f0526..86c92c00 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -476,7 +476,7 @@ <string name="sort_channels_recently_watched">Sortiraj kanale po nedavno gledanima</string> <string name="color_code_programs">Programi u boji</string> <string name="subtitle_delay">Kašnjenje titlova</string> - <string name="backdrop_display">Stil pozadine</string> + <string name="backdrop_display">Stil naslovne slike</string> <string name="combined_search_results">Kombinirani rezultati pretraživanja</string> <string name="combined_search_results_on">Prikaži sve rezultate na jednom popisu</string> <string name="combined_search_results_off">Prikaži rezultate po kategoriji</string> @@ -675,4 +675,55 @@ <string name="transcoding">Transkodiranje</string> <string name="user_interface_language">Jezik korisničkog sučelja</string> <string name="display_toggles_title">Prikaz ocjena</string> + <string name="display_toggles_summary">Prilagodite što prikazati</string> + <string name="profile_protection">Zaštita profila</string> + <string name="continue_watching_click_behavior">Ponašanje klika za Nastavi gledanje/Slijedi</string> + <string name="continue_watching_click_summary_on">Reproduciraj na klik</string> + <string name="continue_watching_click_summary_off">Prikaži pojedinosti na klik</string> + <string name="stars">Zvjezdice</string> + <string name="tomatoes">Rajčice</string> + <string name="sort">Sortiraj</string> + <string name="split_into_separate_rows">Podijeli u odvojene redove</string> + <string name="genre_movies">%1$s filmovi</string> + <string name="genre_tv_shows">%1$s TV serije</string> + <string name="quality_profile">Profil kvalitete</string> + <string name="is_default">Zadano</string> + <string name="root_folder">Korijenska mapa</string> + <string name="popularity">Popularnost</string> + <string name="production_status">Status produkcije</string> + <string name="different_for_hdr">Koristi drugačiji stil za HDR</string> + <string name="experimental_settings">Eksperimentalne postavke</string> + <string name="confirm_enable_experimental_title">Omogućiti eksperimentalne postavke?</string> + <string name="confirm_enable_experimental_body">Eksperimentalne postavke mogu biti nestabilne!\n\n Mogu biti uklonjene ili promijenjene u bilo kojem izdanju.</string> + <string name="video_tunneling">Video tunneling</string> + <string name="d_pad_seek_mode_title">Način trake za premotavanje D-Padom</string> + <string name="dpad_seek_mode_minimal">Minimalno</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Mala traka za premotavanje uz dno zaslona (zadano)</string> + <string name="dpad_seek_mode_trickplay_summary">Traka za premotavanje s trickplay slikama pretpregleda</string> + <string name="dpad_seek_mode_none_summary">Bez trake za premotavanje</string> + <string name="blocklisted">Na popisu blokiranih</string> + <string name="unavailable">Nedostupno</string> + <string name="subtitle_mode">Način titlova</string> + <string name="use_user_profile">Koristi postavku vašeg profila</string> + <string name="subtitle_mode_default">Zadano</string> + <string name="subtitle_mode_smart">Pametno</string> + <string name="subtitle_mode_only_forced">Samo prisilni</string> + <string name="subtitle_mode_always">Uvijek</string> + <string name="subtitle_mode_none">Ništa</string> + <string name="preferred_subtitle_language">Preferirani jezik titlova</string> + <string name="preferred_audio_language">Preferirani jezik zvuka</string> + <string name="override_user_profile_settings">Više postavki korisničkog profila</string> + <string name="override_user_profile_settings_summary">Nadjačaj postavke korisničkog profila s poslužitelja</string> + <string name="any_language">Bilo koji jezik</string> + <string name="hash_matches">Podudaranja hasha</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programi</string> + <string name="recordings">Snimke</string> + <string name="folders_title">Mape</string> + <string name="user_views">Korisnički pogledi</string> + <string name="books">Knjige</string> + <string name="audio_books">Audioknjige</string> + <string name="server_version_not_supported">Verzija poslužitelja nije podržana</string> + <string name="appears_on">Pojavljuje se na</string> </resources> From 78a3f3b929a755792e6356ea45ec19348803deb7 Mon Sep 17 00:00:00 2001 From: m4st3r-0day <m4st3r-0day@noreply.codeberg.org> Date: Mon, 17 Aug 2026 17:19:56 +0000 Subject: [PATCH 383/475] Added translation using Weblate (Albanian) --- app/src/main/res/values-sq/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-sq/strings.xml diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-sq/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> \ No newline at end of file From df3d6e2a78ac882348f006d6647b9ba86e678353 Mon Sep 17 00:00:00 2001 From: m4st3r-0day <m4st3r-0day@noreply.codeberg.org> Date: Mon, 17 Aug 2026 17:41:09 +0000 Subject: [PATCH 384/475] Translated using Weblate (Albanian) Currently translated at 6.0% (39 of 644 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sq/ --- app/src/main/res/values-sq/strings.xml | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 55344e51..2781d45b 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -1,3 +1,36 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file + <string name="active_recordings">Rregjistrime aktive</string> + <string name="add_favorite">Shto si të preferuar</string> + <string name="add_server">Shto server</string> + <string name="add_user">Shto përdorues</string> + <string name="collection">Koleksion</string> + <string name="confirm">Konfirmo</string> + <string name="continue_watching">Vazhdo shikimin</string> + <string name="delete">Fshij</string> + <string name="download_and_update"><![CDATA[Shkarko & Përditëso]]></string> + <string name="decimal_seconds">%.2f sekonda</string> + <string name="duration_hours_minutes_seconds">%1$dh%2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh%2$dm</string> + <string name="duration_minutes_seconds">%1$dm%2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="directed_by">Drejtuar nga %1$s</string> + <string name="downloading">Duke shkarkuar…</string> + <string name="enabled">Aktivizuar</string> + <string name="ends_at">Përfundon në %1$s</string> + <string name="enter_server_url">Vendos IP-në ose URL-në e serverit</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programe TV</string> + <string name="recordings">Rregjistrime</string> + <string name="books">Libra</string> + <string name="user_views">Shikimet e përdoruesit</string> + <string name="appears_on">Shfaqet në</string> + <string name="audio_books">Audiolibra</string> + <string name="server_version_not_supported">Versioni i serverit nuk suportohet</string> + <string name="folders_title">Dosje</string> + <string name="any_language">Çdo gjuhë</string> + <string name="hash_matches">Hash-i përputhet</string> + <string name="override_user_profile_settings_summary">Ndrysho cilësimet e profilit tënd të përdoruesit nga serveri</string> +</resources> From 9acc8251e22de3a1fdf6f77f504e760a1279f60b Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Tue, 18 Aug 2026 13:06:01 +0000 Subject: [PATCH 385/475] Translated using Weblate (Slovak) Currently translated at 100.0% (647 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index e6d66f83..bd4c36d9 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -748,4 +748,7 @@ <string name="appears_on">Objavuje sa v</string> <string name="disable_audio_offload">Zakázať hardvérové odovzdávanie zvuku</string> <string name="disable_audio_offload_summary">Zakázanie odovzdávania dekódovania zvuku môže vyriešiť zamŕzanie prehrávania na niektorých zariadeniach</string> + <string name="dim_after_delay">Stlmiť obrazovku po uplynutí času</string> + <string name="dim_percentage">Úroveň stlmenia</string> + <string name="dim_screen">Stlmenie obrazovky</string> </resources> From f06dba281dffc18eef42ab5c630715429fc83b44 Mon Sep 17 00:00:00 2001 From: rgabbo <rgabbo@noreply.codeberg.org> Date: Tue, 18 Aug 2026 17:12:56 +0000 Subject: [PATCH 386/475] Translated using Weblate (Italian) Currently translated at 99.5% (644 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f17baea6..973cde7a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -520,7 +520,7 @@ <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="prefer_mpv">Preferisci MPV</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> From 1978947b25a3e1a5ee6ae4aae16cf1094cb29279 Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Wed, 19 Aug 2026 03:55:53 +0000 Subject: [PATCH 387/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (647 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 366304aa..613a5cbd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -682,4 +682,7 @@ <string name="appears_on">出现在</string> <string name="disable_audio_offload">禁用音频硬件分流</string> <string name="disable_audio_offload_summary">禁用分流音频解码可能会解决某些设备上的播放卡顿问题</string> + <string name="dim_after_delay">延迟后调暗屏幕</string> + <string name="dim_percentage">调暗百分比</string> + <string name="dim_screen">调暗屏幕</string> </resources> From 41443dd85d76b28440e473246a64f1a97ae5166b Mon Sep 17 00:00:00 2001 From: Jaeensson <jaeensson@noreply.codeberg.org> Date: Wed, 19 Aug 2026 18:57:48 +0000 Subject: [PATCH 388/475] Translated using Weblate (Swedish) Currently translated at 90.5% (586 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 93c86111..4f29ade6 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -622,4 +622,30 @@ <string name="no_max">Ingen övre gräns</string> <string name="include_types">Inkludera typer</string> <string name="load_from_server">Ladda från serverns användarprofil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Stäng av hårdvaruavlastning</string> + <string name="disable_audio_offload_summary">Stänga av ljudavlastning kan hjälpa uppspelning som fryser på vissa enheter</string> + <string name="codec">Codec</string> + <string name="anamorphic">Anamorfisk</string> + <string name="nal">NAL</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</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> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direct play Dolby Vision Profil 7</string> + <string name="software_decoding_av1">AV1 mjukvaruavkodning</string> + <string name="slideshow_duration">Bildspelslängd</string> + <string name="play_videos_during_slideshow">Spela videor under bildspel</string> + <string name="prefer_ac3_for_surround">Använd AC3 för surroundljud</string> + <string name="brown">Brun</string> + <string name="show_visualizer">Visa visualizer</string> + <string name="show_backdrop">Visa bakgrund</string> </resources> From 797170d758e7f0a405a1e59235324455144189be Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Wed, 19 Aug 2026 17:17:36 +0000 Subject: [PATCH 389/475] Translated using Weblate (Polish) Currently translated at 99.6% (645 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index de796d6d..796d5fd7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -746,4 +746,7 @@ <string name="audio_books">Audiobooki</string> <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> <string name="appears_on">Wystąpił w</string> + <string name="dim_screen">Przyciemnienie ekranu</string> + <string name="dim_after_delay">Opóźnienie przyciemniana ekranu</string> + <string name="dim_percentage">Procent przyciemniane</string> </resources> From 97b8c1f2e69ad408c35f96f000d2f2986573211f Mon Sep 17 00:00:00 2001 From: m_ber <m_ber@noreply.codeberg.org> Date: Wed, 19 Aug 2026 15:08:58 +0000 Subject: [PATCH 390/475] Translated using Weblate (Czech) Currently translated at 78.9% (511 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 49 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 97223729..7de6e9ac 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -400,7 +400,7 @@ <string name="commercial">Komerční</string> <string name="download_and_update">Download & Update</string> <string name="immediate">Bezprostředně</string> - <string name="search_and_download">Search & Download</string> + <string name="search_and_download"><![CDATA[Najít & Stáhnout]]></string> <string name="runtime_sort">Trvání</string> <plurals name="samples"> <item quantity="one">Vzorky</item> @@ -417,7 +417,7 @@ <item quantity="other">%s Stažení</item> </plurals> <string name="combine_continue_next">Combine Continue Watching & Next Up</string> - <string name="direct_play_ass">Direct play ASS titulky</string> + <string name="direct_play_ass">Použít libass pro ASS titulky</string> <string name="direct_play_pgs">Direct play PGS titulky</string> <string name="player_backend">Přehrávání Backendu</string> <string name="cast_and_crew">Cast & Crew</string> @@ -538,4 +538,49 @@ <string name="search_and_download_subtitles">Hledat a stáhnout titulky</string> <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> <string name="series_continueing">Současnost</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Filmy</string> + <string name="people_title">Osoby</string> + <string name="profile_specific_settings_from_server">Nastavení uživatelského profilu ze serveru</string> + <string name="time_left">%1$s zbývá</string> + <string name="trailers_title">Ukázky</string> + <string name="tv_shows_title">TV Seriály</string> + <string name="other_extras_title">Jiné</string> + <string name="behind_the_scenes_title">Ze zákulisí</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystřižené scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukázky</string> + <string name="featurettes_title">Film o filmu</string> + <string name="shorts_title">Krátké filmy</string> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minut</item> + <item quantity="many">%s minut</item> + <item quantity="other">%s minut</item> + </plurals> + <string name="require_login">Požaduje přihlášení pro profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Vypnout hardwarové zpracování zvuku</string> + <string name="disable_audio_offload_summary">Vypnutí hardwarového dekódování zvuku může u některých zařízení vyřešit zamrzání přehrávání</string> + <string name="combined_search_results">Společné výsledky vyhledávání</string> + <string name="combined_search_results_on">Zobraz všechny výsledky v jednom seznamu</string> + <string name="combined_search_results_off">Zobraz výsledky podle kategorie</string> + <string name="show_voice_search_button">Zobraz tlačítko pro hlasové vyhledávání</string> + <string name="visible_ui">Viditelné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="albums">Alba</string> + <string name="artists">Tvůrci</string> + <string name="songs">Skladby</string> + <string name="go_to_artist">Přejdi na tvůrce</string> + <string name="now_playing">Právě hraje</string> + <string name="go_to_album">Jdi do alba</string> + <string name="add_to_queue">Přidat do fronty</string> </resources> From 28d38258b429d0e0aee15347998dd88a4cc00077 Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Thu, 20 Aug 2026 11:36:19 +0000 Subject: [PATCH 391/475] Translated using Weblate (Turkish) Currently translated at 99.8% (646 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 133ba3b1..ba80486e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -701,4 +701,9 @@ <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> <string name="brown">Kahverengi</string> <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> + <string name="disable_audio_offload">Ses Donanım Hızlandırmasını Devre Dışı Bırak</string> + <string name="disable_audio_offload_summary">Ses kod çözme işlemlerinin donanıma devredilmesini (offload) devre dışı bırakmak, bazı cihazlardaki oynatma donmalarını düzeltebilir</string> + <string name="dim_after_delay">Belirli bir süre sonra ekranı karart</string> + <string name="dim_percentage">Karartma yüzdesi</string> + <string name="dim_screen">Ekranı karart</string> </resources> From 7cef2204eaeba08178f447a101889d87209f331f Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Wed, 19 Aug 2026 14:16:44 +0000 Subject: [PATCH 392/475] Translated using Weblate (Croatian) Currently translated at 100.0% (647 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 86c92c00..dfdb469a 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -726,4 +726,7 @@ <string name="audio_books">Audioknjige</string> <string name="server_version_not_supported">Verzija poslužitelja nije podržana</string> <string name="appears_on">Pojavljuje se na</string> + <string name="dim_after_delay">Zatamni zaslon nakon odgode</string> + <string name="dim_percentage">Postotak zatamnjenja</string> + <string name="dim_screen">Zatamni zaslon</string> </resources> From 6ab1f96acac31b661c55d3dfe7757d7502794673 Mon Sep 17 00:00:00 2001 From: alexkahler <alexkahler@noreply.codeberg.org> Date: Fri, 21 Aug 2026 00:19:34 +0000 Subject: [PATCH 393/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 73 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c13cadc6..6614c214 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -260,7 +260,7 @@ <string name="check_for_updates">Søg efter opdateringer</string> <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> <string name="combine_continue_next">Kombiner Fortsæt med at se & Næste</string> - <string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string> + <string name="direct_play_ass">Brug libass til ASS-undertekster</string> <string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string> <string name="downmix_stereo">Mix altid ned til stereo</string> <string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string> @@ -274,7 +274,7 @@ <string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string> <string name="pass_out_protection">Inaktivitetsbeskyttelse</string> <string name="play_theme_music">Afspil temamusik</string> - <string name="playback_overrides">Tilsidesættelser af afspilning</string> + <string name="playback_overrides">Afspilning tilsidesættelser</string> <string name="remember_selected_tab">Husk valgte faner</string> <string name="rewatch_next_up">Tillad gensening i \"Næste\"</string> <string name="seek_bar_steps">Trinlængde for søgelinje</string> @@ -469,8 +469,75 @@ <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> <string name="use_thumb_images">Brug thumb-billeder</string> <string name="settings_saved">Indstillinger gemt</string> - <string name="display_presets">Skærmforudindstillinger</string> + <string name="display_presets">Design forudindstillinger</string> <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> <string name="commercial">Reklame</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Film</string> + <string name="profile_specific_settings_from_server">Brugerprofilindstillinger fra serveren</string> + <string name="time_left">%1$s tilbage</string> + <string name="trailers_title">Trailer</string> + <string name="tv_shows_title">TV-serier</string> + <string name="other_extras_title">Andet</string> + <string name="behind_the_scenes_title">Bag kulisserne</string> + <string name="theme_songs_title">Temasange</string> + <string name="theme_videos_title">Temavideoer</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Slettede scener</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Featuretter</string> + <string name="shorts_title">Kortfilm</string> + <string name="favorite_items_title">Favorit %s</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minutter</item> + </plurals> + <string name="require_login">Kræv login for profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Deaktiver offloading af lydhardware</string> + <string name="disable_audio_offload_summary">Deaktivering af offloading af lyddekodning kan muligvis afhjælpe frysninger i afspilningen på nogle enheder.</string> + <string name="combined_search_results">Kombineret søgeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i en enkelt liste</string> + <string name="combined_search_results_off">Vis resultater efter kategori</string> + <string name="show_voice_search_button">Vis knappen til stemmesøgning</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</string> + <string name="albums">Album</string> + <string name="artists">Kunstner</string> + <string name="songs">Sange</string> + <string name="go_to_artist">Gå til kunstner</string> + <string name="now_playing">Afspiller nu</string> + <string name="go_to_album">Gå til album</string> + <string name="add_to_queue">Tilføj til kø</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode ikke-stereolyd til AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Brug AC3 til surround sound-lyd</string> + <string name="start_after">Start efter</string> + <string name="animate">Animér</string> + <string name="max_age_rating">Maks aldersgrænse</string> + <string name="no_max">Ingen maks</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Op til alderen %s år</string> + <string name="screensaver">Pauseskærm</string> + <string name="screensaver_settings">Pauseskærms indstillinger</string> + <string name="start_screensaver">Start pauseskærm</string> + <string name="duration">Varighed</string> + <string name="photos">Billeder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke typer elementer der vises billeder for</string> + <string name="content_scale_fit">Tilpas</string> + <string name="content_scale_crop">Beskær</string> + <string name="content_scale_fill">Udfyld</string> + <string name="content_scale_fill_width">Fyld bredde</string> + <string name="content_scale_fill_height">Fyld højde</string> + <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_series_thumb">Serie Thumb-billeder</string> </resources> From e1a6fcc2f52927589d8438bea229388d62549f59 Mon Sep 17 00:00:00 2001 From: Majkic666 <majkic666@noreply.codeberg.org> Date: Fri, 21 Aug 2026 00:31:28 +0000 Subject: [PATCH 394/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6614c214..b7f782a4 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -539,5 +539,31 @@ <string name="content_scale_fill_height">Fyld højde</string> <string name="display_preset_default">Wholphin Standard</string> <string name="display_preset_compact">Wholphin Kompakt</string> - <string name="display_preset_series_thumb">Serie Thumb-billeder</string> + <string name="display_preset_series_thumb">Serie Thumb Billeder</string> + <string name="display_preset_episode_thumbnails">Episode Thumb Billeder</string> + <string name="volume_lowest">Laveste</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Medium</string> + <string name="volume_high">Høj</string> + <string name="purple">Lilla</string> + <string name="orange">Orange</string> + <string name="black">Sort</string> + <string name="skip_automatically">Spring over automatisk</string> + <string name="skip_ask">Spørg om at springe over</string> + <string name="ffmpeg_fallback">Anvend kun FFmpeg, hvis der ikke findes en indbygget dekoder</string> + <string name="ffmpeg_prefer">Fortræk at bruge FFmpeg over indbygget dekoder</string> + <string name="ffmpeg_never">Brug aldrig FFmpeg dekoder</string> + <string name="next_up_playback_end">Ved afslutningen af afspilningen</string> + <string name="next_up_outro">Under Rulleteksterne/Eftertekster</string> + <string name="white">Hvid</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyanblå</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Omrids</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Indpakning</string> + <string name="background_style_boxed">Indrammet</string> + <string name="prefer_mpv">Foretrækker MPV</string> </resources> From 18174b6e910185f012b263bd39022df39acef5cf Mon Sep 17 00:00:00 2001 From: Majkic666 <majkic666@noreply.codeberg.org> Date: Fri, 21 Aug 2026 00:33:46 +0000 Subject: [PATCH 395/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b7f782a4..e9270062 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -566,4 +566,5 @@ <string name="background_style_wrap">Indpakning</string> <string name="background_style_boxed">Indrammet</string> <string name="prefer_mpv">Foretrækker MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer for HDR afspilning</string> </resources> From f00622be133bfa813d3b759555354461ecf95032 Mon Sep 17 00:00:00 2001 From: Majkic666 <majkic666@noreply.codeberg.org> Date: Fri, 21 Aug 2026 00:35:50 +0000 Subject: [PATCH 396/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e9270062..4649e101 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -567,4 +567,9 @@ <string name="background_style_boxed">Indrammet</string> <string name="prefer_mpv">Foretrækker MPV</string> <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer for HDR afspilning</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Primære</string> </resources> From f84ea0e758b77cdb8e7be31f0e90e035523d8f88 Mon Sep 17 00:00:00 2001 From: alexkahler <alexkahler@noreply.codeberg.org> Date: Fri, 21 Aug 2026 01:19:02 +0000 Subject: [PATCH 397/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 59 ++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4649e101..b42c866d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -256,7 +256,7 @@ <string name="app_theme">Tema</string> <string name="auto_check_for_updates">Søg automatisk efter opdateringer</string> <string name="auto_play_next_delay">Forsinkelse før næste afspilning</string> - <string name="auto_play_next">Afspil næste automatisk</string> + <string name="auto_play_next">Spil næste automatisk</string> <string name="check_for_updates">Søg efter opdateringer</string> <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> <string name="combine_continue_next">Kombiner Fortsæt med at se & Næste</string> @@ -467,7 +467,7 @@ <string name="suggestions_for">Forslag til %1$s</string> <string name="increase_all_cards_size">Øg størrelsen på alle kort</string> <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> - <string name="use_thumb_images">Brug thumb-billeder</string> + <string name="use_thumb_images">Brug miniaturebilleder</string> <string name="settings_saved">Indstillinger gemt</string> <string name="display_presets">Design forudindstillinger</string> <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> @@ -510,8 +510,8 @@ <string name="visible_ui">Synlig</string> <string name="hidden_ui">Skjult</string> <string name="results">Resultater</string> - <string name="albums">Album</string> - <string name="artists">Kunstner</string> + <string name="albums">Albummer</string> + <string name="artists">Kunstnere</string> <string name="songs">Sange</string> <string name="go_to_artist">Gå til kunstner</string> <string name="now_playing">Afspiller nu</string> @@ -539,8 +539,8 @@ <string name="content_scale_fill_height">Fyld højde</string> <string name="display_preset_default">Wholphin Standard</string> <string name="display_preset_compact">Wholphin Kompakt</string> - <string name="display_preset_series_thumb">Serie Thumb Billeder</string> - <string name="display_preset_episode_thumbnails">Episode Thumb Billeder</string> + <string name="display_preset_series_thumb">Seriens Miniaturebilleder</string> + <string name="display_preset_episode_thumbnails">Episode Miniaturebilleder</string> <string name="volume_lowest">Laveste</string> <string name="volume_low">Lav</string> <string name="volume_medium">Medium</string> @@ -565,11 +565,52 @@ <string name="subtitle_edge_shadow">Skygge</string> <string name="background_style_wrap">Indpakning</string> <string name="background_style_boxed">Indrammet</string> - <string name="prefer_mpv">Foretrækker MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer for HDR afspilning</string> + <string name="prefer_mpv">Foretræk MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer til HDR afspilning</string> <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> - <string name="image_type_primary">Primære</string> + <string name="image_type_primary">Primær</string> + <string name="bold_blue">Kraftig Blå</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="next_up_never">Aldrig</string> + <string name="external_player">Ekstern afspiller</string> + <string name="image_type_thumb">Miniature</string> + <string name="view_options_type_grid">Gitter</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt liste</string> + <string name="enter_url_api_key"><![CDATA[Indtast URL og API-nøgle]]></string> + <string name="api_key">API-nøgle</string> + <string name="search_and_download_subtitles"><![CDATA[Søg og download undertekster]]></string> + <string name="no_subtitles_found">Fandt ingen eksterne undertekster</string> + <string name="series_continueing">Nu</string> + <string name="delete_item">Er du sikker på, at du vil slette dette element?</string> + <string name="show_media_management">Vis muligheder for mediehåndtering</string> + <string name="search_for">Søg %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Manuskriptforfatter</string> + <string name="guest_star">Gæstestjerne</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Tekniker</string> + <string name="mixer">Mixer</string> + <string name="creator">Skaber</string> + <string name="artist">Kunstner</string> + <string name="album">Album</string> + <string name="album_artist">Albumkunstner</string> + <string name="any_language">Ethvert sprog</string> + <string name="popular_songs">Populære sange</string> + <string name="play_next">Spil næste</string> + <string name="play_as_type">Spil som?</string> + <string name="visualizer_rationale">Visualisering af den aktuelt afspillede lyd kræver tilladelse til at optage lyd.</string> + <string name="last_played">Sidst spillet</string> + <string name="ass_subtitle_mode_libass">Direkte afspilning med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med indbygget ExoPlayer</string> + <string name="ass_subtitle_playback">SSA/ASS undertekstafspilning</string> + <string name="play_with">Afspil med</string> </resources> From adf299e1ee540c20839b512a77e9edb20f6da881 Mon Sep 17 00:00:00 2001 From: Majkic666 <majkic666@noreply.codeberg.org> Date: Fri, 21 Aug 2026 00:46:37 +0000 Subject: [PATCH 398/475] Translated using Weblate (Danish) Currently translated at 85.4% (553 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b42c866d..8962366c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -613,4 +613,9 @@ <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med indbygget ExoPlayer</string> <string name="ass_subtitle_playback">SSA/ASS undertekstafspilning</string> <string name="play_with">Afspil med</string> + <string name="backdrop_style_dynamic">Billede med dynamisk farve</string> + <string name="backdrop_style_image">Kun Billede</string> + <string name="seerr_login">Log ind på Seerr-serveren</string> + <string name="seerr_jellyfin_user">Jellyfin-bruger</string> + <string name="seerr_local_user">Lokal bruger</string> </resources> From ec67d5193675cab4bd0572b111855b88ff5265aa Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 21 Aug 2026 08:09:05 +0000 Subject: [PATCH 399/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (647 of 647 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 398292b2..673402e3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -682,4 +682,7 @@ <string name="appears_on">出現於</string> <string name="disable_audio_offload">停用音訊硬體卸載</string> <string name="disable_audio_offload_summary">停用音訊解碼卸載或許可以解決某些裝置的播放卡住問題</string> + <string name="dim_after_delay">閒置後調暗螢幕</string> + <string name="dim_percentage">調暗程度</string> + <string name="dim_screen">螢幕調暗</string> </resources> From bdef33c97370bda79d86fce2c69e3aee3d61309b Mon Sep 17 00:00:00 2001 From: alexkahler <alexkahler@noreply.codeberg.org> Date: Fri, 21 Aug 2026 03:46:11 +0000 Subject: [PATCH 400/475] Translated using Weblate (Danish) Currently translated at 99.5% (644 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 369 +++++++++++++++---------- 1 file changed, 229 insertions(+), 140 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 8962366c..ad04be00 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -45,19 +45,19 @@ <string name="genres">Genrer</string> <string name="go_to_series">Gå til serien</string> <string name="go_to">Gå til</string> - <string name="hide_controller_timeout">Skjul afspilningsknapper</string> + <string name="hide_controller_timeout">Skjul afspilningskontroller</string> <string name="hide_debug_info">Skjul fejlfindingsoplysninger</string> <string name="hide">Skjul</string> <string name="home">Hjem</string> - <string name="immediate">Umiddelbar</string> + <string name="immediate">Med det samme</string> <string name="intro">Intro</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string> <string name="library">Bibliotek</string> <string name="license_info">Licensoplysninger</string> <string name="live_tv">Live-tv</string> <string name="loading">Indlæser…</string> - <string name="mark_entire_series_as_played">Markér hele serien som afspillet?</string> - <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke afspillet?</string> + <string name="mark_entire_series_as_played">Markér hele serien som set?</string> + <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke set?</string> <string name="mark_unwatched">Markér som uset</string> <string name="mark_watched">Markér som set</string> <string name="max_bitrate">Maks. bitrate</string> @@ -76,20 +76,20 @@ <string name="no_update_available">Ingen opdatering tilgængelig</string> <string name="none">Ingen</string> <string name="only_forced_subtitles">Kun tvungne undertekster</string> - <string name="outro">Eftertekster</string> + <string name="outro">Outro</string> <string name="path">Filsti</string> <plurals name="people"> - <item quantity="one">Personer</item> + <item quantity="one">Person</item> <item quantity="other">Personer</item> </plurals> <string name="play_count">Antal afspilninger</string> <string name="play_from_here">Afspil herfra</string> <string name="play">Afspil</string> - <string name="playback_debug_info">Vis debugoplysninger for afspilning</string> + <string name="playback_debug_info">Vis fejlfindingsoplysninger for afspilning</string> <string name="playback_speed">Afspilningshastighed</string> <string name="playback">Afspilning</string> - <string name="playlist">Playlist</string> - <string name="playlists">Playlists</string> + <string name="playlist">Playliste</string> + <string name="playlists">Playlister</string> <string name="preview">Forhåndsvisning</string> <string name="profile_specific_settings">Profilindstillinger</string> <string name="queue">Kø</string> @@ -97,7 +97,7 @@ <string name="recently_added_in">Nyligt tilføjet i %1$s</string> <string name="recently_added">Nyligt tilføjet</string> <string name="recently_recorded">Nyligt optaget</string> - <string name="recently_released">Nyligt udkommet</string> + <string name="recently_released">Nyligt udgivet</string> <string name="recommended">Anbefalet</string> <string name="record_program">Optag program</string> <string name="record_series">Optag serie</string> @@ -111,7 +111,7 @@ <string name="voice_search">Stemmesøgning</string> <string name="voice_starting">Starter</string> <string name="voice_search_prompt">Tal for at søge</string> - <string name="processing">Arbejder</string> + <string name="processing">Behandler</string> <string name="press_back_to_cancel">Tryk tilbage for at afbryde</string> <string name="voice_error_audio">Fejl ved lydoptagelse</string> <string name="voice_error_client">Fejl ved stemmegenkendelse</string> @@ -119,7 +119,7 @@ <string name="voice_error_network">Netværksfejl</string> <string name="voice_error_network_timeout">Netværkstimeout</string> <string name="voice_error_no_match">Ingen tale genkendt</string> - <string name="voice_error_busy">Stemmegenkendelse optaget</string> + <string name="voice_error_busy">Stemmegenkendelsen er allerede i brug</string> <string name="voice_error_server">Serverfejl</string> <string name="voice_error_speech_timeout">Ingen tale registreret</string> <string name="voice_error_unknown">Ukendt fejl</string> @@ -129,12 +129,12 @@ <string name="select_server">Vælg server</string> <string name="select_user">Vælg bruger</string> <string name="show_debug_info">Vis fejlfindingsoplysninger</string> - <string name="show_next_up_when">Vis næste</string> + <string name="show_next_up_when">Vis Næste</string> <string name="show">Vis</string> <string name="shuffle">Bland</string> <string name="skip">Spring over</string> <string name="sort_by_date_added">Dato tilføjet</string> - <string name="sort_by_date_episode_added">Dato episode tilføjet</string> + <string name="sort_by_date_episode_added">Dato episoden blev tilføjet</string> <string name="sort_by_date_played">Dato afspillet</string> <string name="sort_by_date_released">Udgivelsesdato</string> <string name="sort_by_name">Navn</string> @@ -145,11 +145,11 @@ <string name="subtitle">Undertekst</string> <string name="subtitles">Undertekster</string> <string name="suggestions">Forslag</string> - <string name="switch_servers">Skift servere</string> + <string name="switch_servers">Skift server</string> <string name="switch_user">Skift</string> <string name="top_unwatched">Bedst bedømte usete</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> + <item quantity="one">Trailer</item> <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> @@ -157,18 +157,19 @@ <string name="tv_season">Sæson</string> <string name="tv_seasons">Sæsoner</string> <plurals name="tv_shows"> - <item quantity="one">TV-serier</item> + <item quantity="one">TV-serie</item> <item quantity="other">TV-serier</item> </plurals> - <string name="ui_interface">Grænseflade</string> + <string name="ui_interface">Brugerflade</string> <string name="unknown">Ukendt</string> <string name="updates">Opdateringer</string> <string name="version">Version</string> <string name="video_scale">Videoskalering</string> <string name="video">Video</string> <string name="videos">Videoer</string> - <string name="watch_live">Se direkte</string> + <string name="watch_live">Se live</string> <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> <item quantity="other">%1$d år gammel</item> </plurals> <string name="play_with_transcoding">Afspil med transkodning</string> @@ -177,7 +178,7 @@ <string name="aired_episode_order">Udsendelsesrækkefølge</string> <string name="create_playlist">Opret ny playliste</string> <string name="add_to_playlist">Føj til playliste</string> - <string name="one_click_pause">Pause med ét klik</string> + <string name="one_click_pause">Sæt på pause med ét klik</string> <string name="one_click_pause_summary_on">Tryk på midten af D-Pad\'en for at pause/afspille</string> <string name="italic_font">Kursiv skrift</string> <string name="font">Skrifttype</string> @@ -191,15 +192,15 @@ <item quantity="other">Andet</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Bag kulisserne</item> - <item quantity="other">Bag kulisserne</item> + <item quantity="one">Bag scenen</item> + <item quantity="other">Bag scenerne</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasange</item> + <item quantity="one">Temasang</item> <item quantity="other">Temasange</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> + <item quantity="one">Temavideo</item> <item quantity="other">Temavideoer</item> </plurals> <plurals name="clips"> @@ -207,28 +208,28 @@ <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> + <item quantity="one">Slettet scene</item> <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Interview</item> - <item quantity="other">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> + <item quantity="one">Scene</item> <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Prøver</item> - <item quantity="other">Prøver</item> + <item quantity="one">Smagsprøve</item> + <item quantity="other">Smagsprøver</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Specialindslag</item> - <item quantity="other">Specialindslag</item> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilm</item> - <item quantity="other">Kortfilm</item> + <item quantity="one">Kort</item> + <item quantity="other">Kort</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -252,70 +253,70 @@ </plurals> <string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string> <string name="advanced_settings">Avancerede indstillinger</string> - <string name="advanced_ui">Avanceret brugergrænseflade</string> + <string name="advanced_ui">Avanceret brugerflade</string> <string name="app_theme">Tema</string> <string name="auto_check_for_updates">Søg automatisk efter opdateringer</string> - <string name="auto_play_next_delay">Forsinkelse før næste afspilning</string> - <string name="auto_play_next">Spil næste automatisk</string> + <string name="auto_play_next_delay">Forsinkelse før Næste afspilles</string> + <string name="auto_play_next">Afspil Næste automatisk</string> <string name="check_for_updates">Søg efter opdateringer</string> <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> - <string name="combine_continue_next">Kombiner Fortsæt med at se & Næste</string> + <string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se og Næste]]></string> <string name="direct_play_ass">Brug libass til ASS-undertekster</string> - <string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string> + <string name="direct_play_pgs">Afspil PGS-undertekster direkte</string> <string name="downmix_stereo">Mix altid ned til stereo</string> <string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string> <string name="global_content_scale">Standard skalering</string> <string name="install_update">Installer opdatering</string> <string name="installed_version">Installeret version</string> - <string name="max_homepage_items">Maks antal elementer per række på startsiden</string> - <string name="nav_drawer_pins_summary">Vælg de standardelementer, der skal vises, andre vil blive skjult</string> + <string name="max_homepage_items">Maks. antal elementer pr. række på startsiden</string> + <string name="nav_drawer_pins_summary">Vælg de elementer, der skal vises som standard. Andre skjules</string> <string name="nav_drawer_pins">Tilpas elementer i navigationsmenuen</string> - <string name="nav_drawer_switch_on_focus_summary_off">Klik for at skifte side</string> + <string name="nav_drawer_switch_on_focus_summary_off">Tryk for at skifte side</string> <string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string> <string name="pass_out_protection">Inaktivitetsbeskyttelse</string> <string name="play_theme_music">Afspil temamusik</string> - <string name="playback_overrides">Afspilning tilsidesættelser</string> + <string name="playback_overrides">Overstyring af afspilning</string> <string name="remember_selected_tab">Husk valgte faner</string> - <string name="rewatch_next_up">Tillad gensening i \"Næste\"</string> - <string name="seek_bar_steps">Trinlængde for søgelinje</string> + <string name="rewatch_next_up">Tillad gensening i Næste</string> + <string name="seek_bar_steps">Trin på afspilningslinjen</string> <string name="send_app_logs_summary">Nyttig til fejlfinding</string> <string name="send_app_logs">Send app-logfiler til den aktuelle server</string> - <string name="send_crash_reports_summary">Vil forsøge at sende til den sidst forbundne server</string> + <string name="send_crash_reports_summary">Vil forsøge at sende til den sidst tilsluttede server</string> <string name="send_crash_reports">Send nedbrudsrapporter</string> <string name="settings">Indstillinger</string> <string name="skip_back_on_resume_preference">Spring tilbage, når afspilningen genoptages</string> <string name="skip_back_preference">Spring tilbage</string> - <string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string> + <string name="skip_commercials_behavior">Hvordan reklamer springes over</string> <string name="skip_forward_preference">Spring fremad</string> - <string name="skip_intro_behavior">Adfærd ved at springe intro over</string> - <string name="skip_outro_behavior">Adfærd ved at springe eftertekster over</string> - <string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string> - <string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string> + <string name="skip_intro_behavior">Hvordan introer springes over</string> + <string name="skip_outro_behavior">Hvordan outroer springes over</string> + <string name="skip_previews_behavior">Hvordan forhåndsvisninger springes over</string> + <string name="skip_recap_behavior">Hvordan opsummeringer springes over</string> <string name="update_available">Opdatering tilgængelig</string> - <string name="update_url_summary">URL brugt til at søge efter appopdateringer</string> + <string name="update_url_summary">URL, der bruges til at søge efter appopdateringer</string> <string name="update_url">URL til opdatering</string> - <string name="font_size">Tekststørrelse</string> - <string name="font_color">Tekstfarve</string> - <string name="font_opacity">Tekstgennemsigtighed</string> + <string name="font_size">Skriftstørrelse</string> + <string name="font_color">Skriftfarve</string> + <string name="font_opacity">Skriftgennemsigtighed</string> <string name="bold_font">Fed skrift</string> <string name="edge_style">Kantstil</string> <string name="edge_color">Kantfarve</string> - <string name="background_opacity">Baggrundsopacitet</string> + <string name="background_opacity">Baggrundsgennemsigtighed</string> <string name="background_style">Baggrundsstil</string> <string name="subtitle_style">Undertekststil</string> <string name="background_color">Baggrundsfarve</string> <string name="reset">Nulstil</string> - <string name="player_backend">Afspilning Backend</string> + <string name="player_backend">Afspilningsmotor</string> <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</string> - <string name="disable_if_crash">Deaktiver, hvis du oplever fejl</string> - <string name="mpv_options">MPV indstillinger</string> - <string name="exoplayer_options">ExoPlayer indstillinger</string> - <string name="skip_segment_unknown">Spring over segmentet</string> - <string name="skip_segment_commercial">Spring over reklamer</string> - <string name="skip_segment_preview">Spring over forhåndsvisning</string> - <string name="skip_segment_recap">Spring over opsummering</string> - <string name="skip_segment_outro">Spring over eftertekster</string> - <string name="skip_segment_intro">Spring over intro</string> + <string name="disable_if_crash">Deaktiver, hvis appen går ned</string> + <string name="mpv_options">MPV-indstillinger</string> + <string name="exoplayer_options">ExoPlayer-indstillinger</string> + <string name="skip_segment_unknown">Spring segment over</string> + <string name="skip_segment_commercial">Spring reklamer over</string> + <string name="skip_segment_preview">Spring forhåndsvisning over</string> + <string name="skip_segment_recap">Spring opsummering over</string> + <string name="skip_segment_outro">Spring outro over</string> + <string name="skip_segment_intro">Spring intro over</string> <string name="played">Afspillet</string> <string name="filter">Filter</string> <string name="year">År</string> @@ -326,30 +327,30 @@ <string name="mpv_use_gpu_next">MPV: Brug gpu-next</string> <string name="mpv_conf">Rediger mpv.conf</string> <string name="discard_change">Kassér ændringer?</string> - <string name="subtitle_margin">Margin</string> + <string name="subtitle_margin">Margen</string> <string name="verbose_logging">Detaljeret logning</string> - <string name="enter_pin">Indtast PIN</string> + <string name="enter_pin">Indtast PIN-kode</string> <string name="sign_in_auto">Log ind automatisk</string> - <string name="use_server_credentials">Log ind via server</string> + <string name="use_server_credentials">Log ind via serveren</string> <string name="press_enter_to_confirm">Tryk på midten for at bekræfte</string> <string name="require_pin_code">Kræv PIN-kode til profil</string> - <string name="confirm_pin">Bekræft PIN</string> - <string name="incorrect">Fejl</string> - <string name="pin_too_short">PIN-koden skal være 4 cifre eller længere</string> - <string name="will_remove_pin">Vil fjerne PIN</string> - <string name="image_cache_size">Størrelse på billeddiskcache (MB)</string> - <string name="view_options">Vis muligheder</string> + <string name="confirm_pin">Bekræft PIN-kode</string> + <string name="incorrect">Forkert</string> + <string name="pin_too_short">PIN-koden skal være på mindst 4 cifre</string> + <string name="will_remove_pin">PIN-koden fjernes</string> + <string name="image_cache_size">Billedcache på disk (MB)</string> + <string name="view_options">Visningsindstillinger</string> <string name="columns">Kolonner</string> - <string name="spacing">Mellemrum</string> - <string name="aspect_ratio">Aspektforhold</string> + <string name="spacing">Afstand</string> + <string name="aspect_ratio">Billedformat</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Billedtype</string> - <string name="cast_and_crew">Skuespillere og filmhold</string> - <string name="guest_stars">Gæsteroller</string> + <string name="cast_and_crew"><![CDATA[Medvirkende og filmhold]]></string> + <string name="guest_stars">Gæsteskuespillere</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Skift af billedopdateringsfrekvens</string> <string name="automatic">Automatisk</string> - <string name="username_or_password">Brug brugernavn/adgangskode</string> + <string name="username_or_password">Brug brugernavn og adgangskode</string> <string name="login">Log ind</string> <string name="general">Generelt</string> <string name="container">Container</string> @@ -359,7 +360,7 @@ <string name="level">Niveau</string> <string name="resolution">Opløsning</string> <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Interlaced</string> + <string name="interlaced">Sammenflettet</string> <string name="framerate">Billedfrekvens</string> <string name="bit_depth">Bitdybde</string> <string name="video_range">Videoområde</string> @@ -387,15 +388,15 @@ <string name="show_titles">Vis titler</string> <string name="live_tv_repeat">Gentag</string> <string name="favorite_channels_at_beginning">Vis favoritkanaler først</string> - <string name="sort_channels_recently_watched">Sorter kanaler efter nyligt set</string> + <string name="sort_channels_recently_watched">Sorter kanaler efter senest sete</string> <string name="color_code_programs">Farvekod programmer</string> <string name="subtitle_delay">Undertekstforsinkelse</string> - <string name="backdrop_display">Stil for baggrundsbillede</string> + <string name="backdrop_display">Baggrundsbilledstil</string> <string name="resolution_switching">Skift af opløsning</string> <string name="local">Lokal</string> <string name="play_trailer">Afspil trailer</string> <string name="no_trailers">Ingen trailere</string> - <string name="clear_track_choices">Ryd valg af spor</string> + <string name="clear_track_choices">Ryd sporvalg</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> <string name="dts_hd_ma">DTS:HD MA</string> @@ -403,28 +404,28 @@ <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> <string name="dts">DTS</string> - <string name="force_dovi_profile_7">Direkte afspilning af Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7">Afspil Dolby Vision-profil 7 direkte</string> <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</string> - <string name="discover">Opdag</string> - <string name="request">Anmod</string> + <string name="discover">Udforsk</string> + <string name="request">Ønsk</string> <string name="pending">Afventer</string> - <string name="seerr_integration">Seerr integration</string> - <string name="remove_seerr_server">Fjern Seerr server</string> - <string name="seerr_server_added">Seerr server tilføjet</string> + <string name="seerr_integration">Seerr-integration</string> + <string name="remove_seerr_server">Fjern Seerr-server</string> + <string name="seerr_server_added">Seerr-server tilføjet</string> <string name="quick_connect">Quick Connect</string> <string name="quick_connect_summary">Godkend en anden enhed til at logge ind på din konto</string> <string name="quick_connect_code">Indtast Quick Connect-kode</string> <string name="quick_connect_code_error">Koden skal være på 6 cifre</string> - <string name="quick_connect_success">Enheden er godkendt</string> + <string name="quick_connect_success">Enheden blev godkendt</string> <string name="password">Adgangskode</string> <string name="username">Brugernavn</string> <string name="url">URL</string> <string name="trending">Populært lige nu</string> <string name="upcoming_movies">Kommende film</string> <string name="upcoming_tv">Kommende tv-serier</string> - <string name="request_4k">Anmod i 4K</string> - <string name="software_decoding_av1">AV1 software afkodning</string> - <string name="upgrade_mpv_toast">MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne.</string> + <string name="request_4k">Ønsk i 4K</string> + <string name="software_decoding_av1">AV1-softwareafkodning</string> + <string name="upgrade_mpv_toast">MPV er nu standardafspilleren, undtagen ved HDR.\nDu kan ændre dette i indstillingerne.</string> <string name="hdr_subtitle_style">HDR-undertekststil</string> <string name="image_subtitle_opacity">Billedunderteksters gennemsigtighed</string> <string name="brightness">Lysstyrke</string> @@ -435,7 +436,7 @@ <string name="green">Grøn</string> <string name="blue">Blå</string> <string name="blur">Sløring</string> - <string name="save_for_album">Gem i album</string> + <string name="save_for_album">Gem for album</string> <string name="play_slideshow">Afspil diasshow</string> <string name="stop_slideshow">Stop diasshow</string> <string name="slideshow_at_beginning">Fra begyndelsen</string> @@ -451,13 +452,13 @@ <string name="max_days_next_up">Maks. antal dage i Næste</string> <string name="add_row">Tilføj række</string> <string name="genres_in">Genrer i %1$s</string> - <string name="recently_released_in">Nyligt udkommet i %1$s</string> + <string name="recently_released_in">Nyligt udgivet i %1$s</string> <string name="height">Højde</string> <string name="apply_all_rows">Anvend på alle rækker</string> <string name="customize_home">Tilpas startsiden</string> <string name="home_rows">Rækker på startsiden</string> - <string name="load_from_server">Indlæs fra brugerprofilen på serveren</string> - <string name="save_to_server">Gem i brugerprofilen på serveren</string> + <string name="load_from_server">Indlæs fra serverens brugerprofil</string> + <string name="save_to_server">Gem i serverens brugerprofil</string> <string name="load_from_web_client">Indlæs fra webklienten</string> <string name="use_series">Brug seriens billede</string> <string name="add_row_for">Tilføj række for %1$s</string> @@ -469,8 +470,8 @@ <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> <string name="use_thumb_images">Brug miniaturebilleder</string> <string name="settings_saved">Indstillinger gemt</string> - <string name="display_presets">Design forudindstillinger</string> - <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> + <string name="display_presets">Layoutforudindstillinger</string> + <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at tilpasse alle rækker</string> <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> <string name="commercial">Reklame</string> <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> @@ -480,20 +481,20 @@ <string name="duration_minutes">%1$dm</string> <string name="duration_seconds">%1$ds</string> <string name="movies_title">Film</string> - <string name="profile_specific_settings_from_server">Brugerprofilindstillinger fra serveren</string> + <string name="profile_specific_settings_from_server">Profilindstillinger fra serveren</string> <string name="time_left">%1$s tilbage</string> - <string name="trailers_title">Trailer</string> + <string name="trailers_title">Trailere</string> <string name="tv_shows_title">TV-serier</string> <string name="other_extras_title">Andet</string> - <string name="behind_the_scenes_title">Bag kulisserne</string> + <string name="behind_the_scenes_title">Bag scenerne</string> <string name="theme_songs_title">Temasange</string> <string name="theme_videos_title">Temavideoer</string> <string name="clips_title">Klip</string> <string name="deleted_scenes_title">Slettede scener</string> <string name="interviews_title">Interviews</string> <string name="scenes_title">Scener</string> - <string name="featurettes_title">Featuretter</string> - <string name="shorts_title">Kortfilm</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Kort</string> <string name="favorite_items_title">Favorit %s</string> <plurals name="minutes"> <item quantity="one">%s minut</item> @@ -501,9 +502,9 @@ </plurals> <string name="require_login">Kræv login for profil</string> <string name="pin_digits_hint">[0–9]</string> - <string name="disable_audio_offload">Deaktiver offloading af lydhardware</string> - <string name="disable_audio_offload_summary">Deaktivering af offloading af lyddekodning kan muligvis afhjælpe frysninger i afspilningen på nogle enheder.</string> - <string name="combined_search_results">Kombineret søgeresultater</string> + <string name="disable_audio_offload">Deaktiver hardware-offload af lyd</string> + <string name="disable_audio_offload_summary">Deaktivering af hardware-offload for lyddekodning kan afhjælpe frysninger under afspilning på nogle enheder</string> + <string name="combined_search_results">Kombinerede søgeresultater</string> <string name="combined_search_results_on">Vis alle resultater i en enkelt liste</string> <string name="combined_search_results_off">Vis resultater efter kategori</string> <string name="show_voice_search_button">Vis knappen til stemmesøgning</string> @@ -513,37 +514,37 @@ <string name="albums">Albummer</string> <string name="artists">Kunstnere</string> <string name="songs">Sange</string> - <string name="go_to_artist">Gå til kunstner</string> + <string name="go_to_artist">Gå til kunstneren</string> <string name="now_playing">Afspiller nu</string> - <string name="go_to_album">Gå til album</string> - <string name="add_to_queue">Tilføj til kø</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode ikke-stereolyd til AC3/Dolby Digital]]></string> - <string name="prefer_ac3_for_surround">Brug AC3 til surround sound-lyd</string> + <string name="go_to_album">Gå til albummet</string> + <string name="add_to_queue">Føj til kø</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkod ikke-stereolyd til AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Brug AC3 til surroundlyd</string> <string name="start_after">Start efter</string> <string name="animate">Animér</string> - <string name="max_age_rating">Maks aldersgrænse</string> - <string name="no_max">Ingen maks</string> + <string name="max_age_rating">Maks. aldersgrænse</string> + <string name="no_max">Ingen maks.</string> <string name="for_all_ages">For alle aldre</string> - <string name="up_to_age">Op til alderen %s år</string> + <string name="up_to_age">Op til %s år</string> <string name="screensaver">Pauseskærm</string> - <string name="screensaver_settings">Pauseskærms indstillinger</string> + <string name="screensaver_settings">Pauseskærmsindstillinger</string> <string name="start_screensaver">Start pauseskærm</string> <string name="duration">Varighed</string> <string name="photos">Billeder</string> <string name="include_types">Inkluder typer</string> - <string name="include_types_summary">Hvilke typer elementer der vises billeder for</string> + <string name="include_types_summary">Hvilke typer elementer der skal vises billeder for</string> <string name="content_scale_fit">Tilpas</string> <string name="content_scale_crop">Beskær</string> - <string name="content_scale_fill">Udfyld</string> + <string name="content_scale_fill">Fyld</string> <string name="content_scale_fill_width">Fyld bredde</string> <string name="content_scale_fill_height">Fyld højde</string> - <string name="display_preset_default">Wholphin Standard</string> - <string name="display_preset_compact">Wholphin Kompakt</string> - <string name="display_preset_series_thumb">Seriens Miniaturebilleder</string> - <string name="display_preset_episode_thumbnails">Episode Miniaturebilleder</string> + <string name="display_preset_default">Wholphin-standard</string> + <string name="display_preset_compact">Wholphin kompakt</string> + <string name="display_preset_series_thumb">Seriens miniaturebilleder</string> + <string name="display_preset_episode_thumbnails">Episode-miniaturebilleder</string> <string name="volume_lowest">Laveste</string> <string name="volume_low">Lav</string> - <string name="volume_medium">Medium</string> + <string name="volume_medium">Mellem</string> <string name="volume_high">Høj</string> <string name="purple">Lilla</string> <string name="orange">Orange</string> @@ -551,10 +552,10 @@ <string name="skip_automatically">Spring over automatisk</string> <string name="skip_ask">Spørg om at springe over</string> <string name="ffmpeg_fallback">Anvend kun FFmpeg, hvis der ikke findes en indbygget dekoder</string> - <string name="ffmpeg_prefer">Fortræk at bruge FFmpeg over indbygget dekoder</string> - <string name="ffmpeg_never">Brug aldrig FFmpeg dekoder</string> + <string name="ffmpeg_prefer">Foretræk FFmpeg frem for indbyggede dekodere</string> + <string name="ffmpeg_never">Brug aldrig FFmpeg-dekodere</string> <string name="next_up_playback_end">Ved afslutningen af afspilningen</string> - <string name="next_up_outro">Under Rulleteksterne/Eftertekster</string> + <string name="next_up_outro">Under rulletekster/outro</string> <string name="white">Hvid</string> <string name="light_gray">Lysegrå</string> <string name="dark_gray">Mørkegrå</string> @@ -563,16 +564,16 @@ <string name="magenta">Magenta</string> <string name="subtitle_edge_outline">Omrids</string> <string name="subtitle_edge_shadow">Skygge</string> - <string name="background_style_wrap">Indpakning</string> + <string name="background_style_wrap">Ombrydning</string> <string name="background_style_boxed">Indrammet</string> <string name="prefer_mpv">Foretræk MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer til HDR afspilning</string> + <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer til HDR-afspilning</string> <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> <string name="image_type_primary">Primær</string> - <string name="bold_blue">Kraftig Blå</string> + <string name="bold_blue">Kraftig blå</string> <string name="brown">Brun</string> <string name="skip_ignore">Ignorer</string> <string name="next_up_never">Aldrig</string> @@ -592,30 +593,118 @@ <string name="actor">Skuespiller</string> <string name="composer">Komponist</string> <string name="writer">Manuskriptforfatter</string> - <string name="guest_star">Gæstestjerne</string> - <string name="producer">Producent</string> + <string name="guest_star">Gæsteskuespiller</string> + <string name="producer">Producer</string> <string name="conductor">Dirigent</string> <string name="lyricist">Tekstforfatter</string> <string name="arranger">Arrangør</string> - <string name="engineer">Tekniker</string> - <string name="mixer">Mixer</string> + <string name="engineer">Lydtekniker</string> + <string name="mixer">Mixtekniker</string> <string name="creator">Skaber</string> <string name="artist">Kunstner</string> <string name="album">Album</string> <string name="album_artist">Albumkunstner</string> <string name="any_language">Ethvert sprog</string> <string name="popular_songs">Populære sange</string> - <string name="play_next">Spil næste</string> - <string name="play_as_type">Spil som?</string> - <string name="visualizer_rationale">Visualisering af den aktuelt afspillede lyd kræver tilladelse til at optage lyd.</string> - <string name="last_played">Sidst spillet</string> + <string name="play_next">Afspil næste</string> + <string name="play_as_type">Afspil som?</string> + <string name="visualizer_rationale">Visualizeren kræver tilladelse til at optage den lyd, der afspilles.</string> + <string name="last_played">Sidst afspillet</string> <string name="ass_subtitle_mode_libass">Direkte afspilning med libass</string> - <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med indbygget ExoPlayer</string> - <string name="ass_subtitle_playback">SSA/ASS undertekstafspilning</string> + <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med ExoPlayers indbyggede understøttelse</string> + <string name="ass_subtitle_playback">Afspilning af SSA/ASS-undertekster</string> <string name="play_with">Afspil med</string> <string name="backdrop_style_dynamic">Billede med dynamisk farve</string> - <string name="backdrop_style_image">Kun Billede</string> + <string name="backdrop_style_image">Kun billede</string> <string name="seerr_login">Log ind på Seerr-serveren</string> <string name="seerr_jellyfin_user">Jellyfin-bruger</string> <string name="seerr_local_user">Lokal bruger</string> + <string name="people_title">Personer</string> + <string name="samples_title">Smagsprøver</string> + <string name="instant_mix">Hurtigt mix</string> + <string name="volume_full">Maks.</string> + <string name="in_app_screensaver">Brug appens egen pauseskærm</string> + <string name="music_videos">Musikvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Sangen har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumcover</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis baggrundsbillede</string> + <string name="continue_string">Fortsæt</string> + <string name="select_all">Vælg alle</string> + <string name="separate_types">Adskil typer</string> + <string name="prefer_logos">Foretræk logoer som titler</string> + <string name="updated_toast">Wholphin er opdateret til %s</string> + <string name="remove_continue_watching">Fjern fra Fortsæt med at se</string> + <string name="remove_next_up">Fjern serien fra Næste</string> + <string name="view_removed_next_up">Vis serier fjernet fra Næste</string> + <string name="view_more">Vis mere</string> + <string name="discover_tv">Udforsk TV-serier</string> + <string name="discover_movies">Udforsk film</string> + <string name="studios_in">Studier i %1$s</string> + <string name="system_default">Systemstandard</string> + <string name="ass_subtitle_mode_transcode">Brænd ind/transkod på serveren</string> + <string name="cinema_mode">Biograftilstand</string> + <string name="show_more">Vis %1$s mere</string> + <string name="skip_behavior">Indstillinger for overspringning</string> + <string name="skip_behavior_summary">For introer, rulletekster osv.</string> + <string name="transcoding">Transkodning</string> + <string name="user_interface_language">Sprog for brugerflade</string> + <string name="display_toggles_title">Visning af bedømmelser</string> + <string name="display_toggles_summary">Vælg, hvilke der skal vises</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Handling ved tryk på Fortsæt med at se/Næste</string> + <string name="continue_watching_click_summary_on">Afspil ved tryk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved tryk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sortér</string> + <string name="split_into_separate_rows">Opdel i separate rækker</string> + <string name="genre_movies">%1$s-film</string> + <string name="genre_tv_shows">%1$s-TV-serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Rodmappe</string> + <string name="popularity">Popularitet</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Brug en anden stil til HDR</string> + <string name="experimental_settings">Eksperimentelle indstillinger</string> + <string name="confirm_enable_experimental_title">Aktivér eksperimentelle indstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle indstillinger kan være ustabile!\n\nDe kan blive fjernet eller ændret ved enhver udgivelse.</string> + <string name="video_tunneling">Videotunneling</string> + <string name="d_pad_seek_mode_title">D-Pad-tilstand for afspilningslinje</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Lille afspilningslinje nederst på skærmen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Afspilningslinje med Trickplay-forhåndsvisningsbilleder</string> + <string name="dpad_seek_mode_none_summary">Ingen afspilningslinje</string> + <string name="blocklisted">På blokeringslisten</string> + <string name="unavailable">Ikke tilgængelig</string> + <string name="subtitle_mode">Underteksttilstand</string> + <string name="use_user_profile">Brug din profilindstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Kun tvungne</string> + <string name="subtitle_mode_always">Altid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstsprog</string> + <string name="preferred_audio_language">Foretrukket lydsprog</string> + <string name="override_user_profile_settings">Flere profilindstillinger</string> + <string name="override_user_profile_settings_summary">Tilsidesæt dine profilindstillinger fra serveren</string> + <string name="hash_matches">Hash-match</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-programmer</string> + <string name="recordings">Optagelser</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brugervisninger</string> + <string name="books">Bøger</string> + <string name="audio_books">Lydbøger</string> + <string name="server_version_not_supported">Serverversionen understøttes ikke</string> + <string name="appears_on">Medvirker på</string> + <string name="dim_after_delay">Dæmp skærmen efter ventetid</string> + <string name="dim_percentage">Dæmpning (%)</string> + <string name="dim_screen">Dæmp skærmen</string> </resources> From 2b9af1e8bc11f4ee9767e0733177972cfb57a28e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 21 Aug 2026 18:04:09 -0400 Subject: [PATCH 401/475] Release v1.0.7 From f91bca18ffe64e03179c1abeb149fe6371158b0d Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 26 Aug 2026 14:39:02 -0400 Subject: [PATCH 402/475] Add more searchable types & choose what to search (#1857) ## Description This PR adds several more data types to search. It also adds a setting (per use profile) to choose which types to include/exclude when searching. The combined results now uses the same types instead of a separate list. A user can only search for types that they have access to. For example, they need Live TV access for TV channels/programs or need access to a movies-type library to search movies. Added types to search: - Photos & Photo albums - Playlists - People - TV channels - Live TV programs Clicking a TV channel will play it. Clicking a TV program shows the program dialog allowing for recording or watching live if it's currently airing. On the main search page you can also toggle whether to search Seerr or not. This does not apply to the discover search tab. Dev note: Some minor refactoring to move setting up/canceling live tv recordings into a new service so it can be shared between with the live tv & search pages. Also search related files are moved into the same package now. ### Related issues Closes #409 Closes #646 Closes #1668 Closes #1750 ### Testing Emulator using users with different permissions and library access ## Screenshots <img width="475" height="584" alt="image" src="https://github.com/user-attachments/assets/ebcff697-07ba-4ddd-8c26-57f147cfa02a" /> ## AI or LLM usage None --- .../wholphin/data/model/HomeRowConfig.kt | 2 +- .../wholphin/services/LiveTvService.kt | 94 ++ .../damontecres/wholphin/ui/Formatting.kt | 6 +- .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../wholphin/ui/cards/SeasonCard.kt | 70 +- .../ui/components/CollectionFolderGrid.kt | 10 +- .../wholphin/ui/detail/livetv/DvrSchedule.kt | 11 +- .../ui/detail/livetv/LiveTvViewModel.kt | 75 +- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 9 +- .../ui/discover/DiscoverSearchPage.kt | 4 +- .../wholphin/ui/main/SearchPage.kt | 1323 ----------------- .../ui/main/settings/HomeSettingsPage.kt | 2 +- .../wholphin/ui/nav/DestinationContent.kt | 2 +- .../ui/{detail => }/search/SearchForDialog.kt | 3 +- .../{detail => }/search/SearchForViewModel.kt | 3 +- .../wholphin/ui/search/SearchPage.kt | 770 ++++++++++ .../wholphin/ui/search/SearchPageCard.kt | 149 ++ .../ui/search/SearchTypeOptionsDialog.kt | 100 ++ .../wholphin/ui/search/SearchViewModel.kt | 628 ++++++++ .../ui/search/SearchViewOptionsDialog.kt | 121 ++ app/src/main/res/values/strings.xml | 1 + 21 files changed, 1968 insertions(+), 1416 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/{detail => }/search/SearchForDialog.kt (98%) rename app/src/main/java/com/github/damontecres/wholphin/ui/{detail => }/search/SearchForViewModel.kt (97%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 74a6c6d1..231cb670 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -243,7 +243,7 @@ data class HomeRowViewOptions( val liveTvDefault = HomeRowViewOptions( - heightDp = 96, + heightDp = Cards.HEIGHT_LIVE_TV, aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.FIT, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt new file mode 100644 index 00000000..d89916f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt @@ -0,0 +1,94 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.model.api.TimerInfoDto +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Functions for recording live tv programs + */ +@Singleton +class LiveTvService + @Inject + constructor( + private val api: ApiClient, + ) { + /** + * Fetch a tv program by ID + */ + suspend fun fetchProgramForDialog(programId: UUID): BaseItem { + val result = + api.liveTvApi + .getProgram(programId.toServerString()) + .content + .let { BaseItem(it) } + return result + } + + /** + * Cancel a recording by [timerId] + * + * @param series whether the timerId is a series recording or not + * @param timerId the recording ID + */ + suspend fun cancelRecording( + series: Boolean, + timerId: String?, + ): Boolean = + if (timerId != null) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) + } + true + } else { + false + } + + /** + * Record tv program + * + * @param programId the tv program ID + * @param series whether to set up series recording or just a single recording + */ + suspend fun record( + programId: UUID, + series: Boolean, + ) { + val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) + if (series) { + api.liveTvApi.createSeriesTimer(d) + } else { + val payload = + TimerInfoDto( + id = d.id, + type = d.type, + serverId = d.serverId, + externalId = d.externalId, + channelId = d.channelId, + externalChannelId = d.externalChannelId, + channelName = d.channelName, + programId = d.programId, + externalProgramId = d.externalProgramId, + name = d.name, + overview = d.overview, + startDate = d.startDate, + endDate = d.endDate, + serviceName = d.serviceName, + priority = d.priority, + prePaddingSeconds = d.prePaddingSeconds, + postPaddingSeconds = d.postPaddingSeconds, + isPrePaddingRequired = d.isPrePaddingRequired, + isPostPaddingRequired = d.isPostPaddingRequired, + keepUntil = d.keepUntil, + ) + api.liveTvApi.createTimer(payload) + } + } + } 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 aeb22e95..4b2f0b8b 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 @@ -220,6 +220,10 @@ fun listToDotString( } } +@get:StringRes +val BaseItemKind.titleStringRes: Int + get() = formatTypeName(this) + @StringRes fun formatTypeName(type: BaseItemKind): Int = when (type) { @@ -239,7 +243,7 @@ fun formatTypeName(type: BaseItemKind): Int = BaseItemKind.MUSIC_GENRE -> R.string.genres BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.PHOTO -> R.string.photos - BaseItemKind.PHOTO_ALBUM -> R.string.photos + BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums BaseItemKind.PROGRAM -> R.string.programs BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.SEASON -> R.string.tv_seasons 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 a37eb2f5..2e57b407 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 @@ -94,6 +94,7 @@ object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp const val HEIGHT_EPISODE = 128 + const val HEIGHT_LIVE_TV = 96 val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index a6f69071..1992cab4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -51,37 +51,7 @@ fun SeasonCard( showImageOverlay: Boolean = false, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, ) { - val imageUrlService = LocalImageUrlService.current - val density = LocalDensity.current - val imageUrl = - remember(item, imageHeight, imageWidth, density) { - if (item != null) { - val fillHeight = - if (imageHeight.isSpecified) { - with(density) { - imageHeight.roundToPx() - } - } else { - null - } - val fillWidth = - if (imageWidth.isSpecified) { - with(density) { - imageWidth.roundToPx() - } - } else { - null - } - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = fillWidth, - fillHeight = fillHeight, - ) - } else { - null - } - } + val imageUrl = rememberImageUrl(item, imageHeight, imageWidth) SeasonCard( title = item?.title, subtitle = item?.subtitle, @@ -103,6 +73,44 @@ fun SeasonCard( ) } +@Composable +fun rememberImageUrl( + item: BaseItem?, + imageHeight: Dp, + imageWidth: Dp = Dp.Unspecified, +): String? { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + return remember(item, imageHeight, imageWidth, density) { + if (item != null) { + val fillHeight = + if (imageHeight.isSpecified) { + with(density) { + imageHeight.roundToPx() + } + } else { + null + } + val fillWidth = + if (imageWidth.isSpecified) { + with(density) { + imageWidth.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } +} + private val spaceBetweenFocused = 4.dp private val spaceBetweenUnfocused = 12.dp 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 0493629b..024eeea4 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 @@ -206,7 +206,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind> } CollectionType.TVSHOWS -> { - listOf(BaseItemKind.SERIES) + listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } CollectionType.HOMEVIDEOS -> { @@ -221,6 +221,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind> ) } + CollectionType.MUSICVIDEOS -> { + listOf(BaseItemKind.MUSIC_VIDEO) + } + CollectionType.BOXSETS -> { listOf(BaseItemKind.BOX_SET) } @@ -229,6 +233,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind> listOf(BaseItemKind.PLAYLIST) } + CollectionType.PHOTOS -> { + listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) + } + else -> { listOf() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index d413f190..5de558c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -178,6 +179,7 @@ fun DvrSchedule( .focusRequester(focusRequester), ) showDialog?.let { item -> + val context = LocalContext.current ProgramDialog( state = DataLoadingState.Success(item), canRecord = true, @@ -185,13 +187,18 @@ fun DvrSchedule( showDialog = null }, onWatch = { program -> - program.data.channelId?.let { + val channelId = program.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { _, _ -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index a2a4bd01..987aeebb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LiveTvService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn @@ -25,7 +26,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -51,7 +52,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -81,6 +81,7 @@ class LiveTvViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val favoriteWatchManager: FavoriteWatchManager, + private val liveTvService: LiveTvService, ) : ViewModel() { private lateinit var channelsIdToIndex: Map<UUID, Int> private val mutex = Mutex() @@ -430,12 +431,10 @@ class LiveTvViewModel _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } viewModelScope.launchDefault { try { - val result = - api.liveTvApi - .getProgram(programId.toServerString()) - .content - .let { BaseItem(it) } + val result = liveTvService.fetchProgramForDialog(programId) _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Error fetching program $programId") _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } @@ -447,16 +446,19 @@ class LiveTvViewModel series: Boolean, timerId: String?, ) { - if (timerId != null) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - if (series) { - api.liveTvApi.cancelSeriesTimer(timerId) - } else { - api.liveTvApi.cancelTimer(timerId) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + if (result) { + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -466,37 +468,16 @@ class LiveTvViewModel series: Boolean, ) { viewModelScope.launchIO { - val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) - if (series) { - api.liveTvApi.createSeriesTimer(d) - } else { - val payload = - TimerInfoDto( - id = d.id, - type = d.type, - serverId = d.serverId, - externalId = d.externalId, - channelId = d.channelId, - externalChannelId = d.externalChannelId, - channelName = d.channelName, - programId = d.programId, - externalProgramId = d.externalProgramId, - name = d.name, - overview = d.overview, - startDate = d.startDate, - endDate = d.endDate, - serviceName = d.serviceName, - priority = d.priority, - prePaddingSeconds = d.prePaddingSeconds, - postPaddingSeconds = d.postPaddingSeconds, - isPrePaddingRequired = d.isPrePaddingRequired, - isPostPaddingRequired = d.isPostPaddingRequired, - keepUntil = d.keepUntil, - ) - api.liveTvApi.createTimer(payload) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + try { + liveTvService.record(programId, series) + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } 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 c9b72031..aafaac87 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 @@ -211,13 +211,18 @@ fun TvGuideGrid( onDismissRequest = onDismissRequest, onWatch = { onDismissRequest.invoke() - it.data.channelId?.let { + val channelId = it.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { program, series -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index 135b9324..fccf32ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -50,10 +50,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.SearchCombinedResults -import com.github.damontecres.wholphin.ui.main.SearchResult import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.search.SearchCombinedResults +import com.github.damontecres.wholphin.ui.search.SearchResult import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException 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 deleted file mode 100644 index 1709e588..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ /dev/null @@ -1,1323 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import android.content.Context -import android.view.Gravity -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -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.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.datastore.core.DataStore -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.ListItem -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Switch -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.preferences.updateSearchPreferences -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.SeerrService -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.Cards -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.ItemRowTitle -import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.ContextMenuProvider -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.SearchEditTextBox -import com.github.damontecres.wholphin.ui.components.TabDetails -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.components.rememberContextMenu -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.detail.GridItemDetails -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -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.onMain -import com.github.damontecres.wholphin.ui.preferences.SwitchColors -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.SearchRelevance -import com.github.damontecres.wholphin.util.WholphinDispatchers -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -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.itemsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -@HiltViewModel -class SearchViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - val api: ApiClient, - val navigationManager: NavigationManager, - private val appPreferences: DataStore<AppPreferences>, - private val seerrService: SeerrService, - val voiceInputManager: VoiceInputManager, - val userPreferencesService: UserPreferencesService, - private val serverRepository: ServerRepository, - private val favoriteWatchManager: FavoriteWatchManager, - private val mediaManagementService: MediaManagementService, - private val mediaReportService: MediaReportService, - ) : ViewModel(), - ContextMenuProvider { - val seerrActive = seerrService.active - - private val _state = MutableStateFlow(SearchState()) - val state: StateFlow<SearchState> = _state - val position = MutableStateFlow(RowColumn(0, 0)) - - private var currentQuery: String? = null - private var combinedMode = false - - fun search( - query: String?, - combined: Boolean = false, - ) { - if (currentQuery == query && combinedMode == combined) { - return - } - currentQuery = query - combinedMode = combined - if (query.isNotNullOrBlank()) { - _state.update { SearchState.searchingState } - if (combined) { - searchCombined(query) - } else { - searchInternal( - query, - BaseItemKind.MOVIE, - ) { result, state -> state.copy(movies = result) } - searchInternal( - query, - BaseItemKind.SERIES, - ) { result, state -> state.copy(series = result) } - searchInternal(query, BaseItemKind.EPISODE) { result, state -> - state.copy( - episodes = result, - ) - } - searchInternal(query, BaseItemKind.BOX_SET) { result, state -> - state.copy( - collections = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state -> - state.copy( - albums = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state -> - state.copy( - artists = result, - ) - } - searchInternal( - query, - BaseItemKind.AUDIO, - ) { result, state -> state.copy(songs = result) } - } - searchSeerr(query) - } else { - _state.update { SearchState() } - } - } - - private fun searchInternal( - query: String, - type: BaseItemKind, - update: (SearchResult, SearchState) -> SearchState, - ) { - viewModelScope.launchIO { - try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 50, - ) - val result = api.itemsApi.getItems(request).content - val items = - result.items.map { - BaseItem(it, false) - } - val sorted = - items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.sortName }, - ) - _state.update { update.invoke(SearchResult.Success(sorted), it) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Exception searching for $type") - _state.update { update.invoke(SearchResult.Error(ex), it) } - } - } - } - - private fun searchCombined(query: String) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = - listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - BaseItemKind.BOX_SET, - ), - fields = SlimItemFields, - limit = 50, - ) - - val result = api.itemsApi.getItems(request).content - val items = - result.items.map { - BaseItem(it, false) - } - val sorted = - items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, - ) - _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } - } catch (ex: Exception) { - Timber.e(ex, "Exception in combined search") - _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } - } - } - } - - fun setCombinedResults(enabled: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - combinedSearchResults = enabled - } - } - } - } - - fun setVoiceSearchButtonVisible(visible: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - showVoiceSearchButton = visible - } - } - } - } - - private fun searchSeerr(query: String) { - viewModelScope.launchIO { - if (seerrService.active.first()) { - _state.update { it.copy(seerrResults = SearchResult.Searching) } - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } - } - } - } - - init { - addCloseable(voiceInputManager) - } - - override fun navigateTo(destination: Destination) { - navigationManager.navigateTo(destination) - } - - override fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - override fun deleteItem( - index: Int, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - refreshItem(item.id) - } - } - } - - private suspend fun refreshItem(itemId: UUID) { - try { - val position = position.value - val searchResult = - if (combinedMode) { - state.value.combinedResults - } else { - when (position.row) { - MOVIE_ROW -> state.value.movies - SERIES_ROW -> state.value.series - EPISODE_ROW -> state.value.episodes - COLLECTION_ROW -> state.value.collections - ALBUM_ROW -> state.value.albums - ARTIST_ROW -> state.value.artists - SONG_ROW -> state.value.songs - SEERR_ROW -> null - else -> null - } - } ?: return - val items = (searchResult as? SearchResult.Success)?.items ?: return - - Timber.v("Item refresh: position=%s", position) - val item = items.getOrNull(position.column) - // Exact item deleted (eg a movie) or deleted item was within the series - if (item != null && item.id == itemId) { - val newItem = - api.userLibraryApi - .getItem(item.id) - .content - .let { BaseItem(it) } - val newList = - SearchResult.Success( - items.toMutableList().apply { - set(position.column, newItem) - }, - ) - _state.update { - if (combinedMode) { - it.copy( - combinedResults = newList, - ) - } else { - when (position.row) { - MOVIE_ROW -> it.copy(movies = newList) - SERIES_ROW -> it.copy(series = newList) - EPISODE_ROW -> it.copy(episodes = newList) - COLLECTION_ROW -> it.copy(collections = newList) - ALBUM_ROW -> it.copy(albums = newList) - ARTIST_ROW -> it.copy(artists = newList) - SONG_ROW -> it.copy(songs = newList) - SEERR_ROW -> it - else -> it - } - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error refreshing item %s", itemId) - showToast(context, "Error refreshing") - } - } - - override fun setWatched( - position: Int, - itemId: UUID, - played: Boolean, - ) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - refreshItem(itemId) - } - } - - override fun setFavorite( - position: Int, - itemId: UUID, - favorite: Boolean, - ) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - refreshItem(itemId) - } - } - - override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true - - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) - } - -sealed interface SearchResult { - data object NoQuery : SearchResult - - data object Searching : SearchResult - - data class Error( - val ex: Exception, - ) : SearchResult - - data class Success( - val items: List<BaseItem?>, - ) : SearchResult - - data class SuccessSeerr( - val items: List<DiscoverItem>, - ) : SearchResult -} - -data class SearchState( - val movies: SearchResult = SearchResult.NoQuery, - val series: SearchResult = SearchResult.NoQuery, - val episodes: SearchResult = SearchResult.NoQuery, - val collections: SearchResult = SearchResult.NoQuery, - val albums: SearchResult = SearchResult.NoQuery, - val artists: SearchResult = SearchResult.NoQuery, - val songs: SearchResult = SearchResult.NoQuery, - val seerrResults: SearchResult = SearchResult.NoQuery, - val combinedResults: SearchResult = SearchResult.NoQuery, -) { - companion object { - val searchingState = - SearchState( - movies = SearchResult.Searching, - series = SearchResult.Searching, - episodes = SearchResult.Searching, - collections = SearchResult.Searching, - albums = SearchResult.Searching, - artists = SearchResult.Searching, - songs = SearchResult.Searching, - seerrResults = SearchResult.Searching, - combinedResults = SearchResult.Searching, - ) - } -} - -private const val SEARCH_ROW = 0 -private const val TAB_ROW = SEARCH_ROW + 1 -private const val MOVIE_ROW = TAB_ROW + 1 -private const val SERIES_ROW = MOVIE_ROW + 1 -private const val EPISODE_ROW = SERIES_ROW + 1 -private const val COLLECTION_ROW = EPISODE_ROW + 1 -private const val ALBUM_ROW = COLLECTION_ROW + 1 -private const val ARTIST_ROW = ALBUM_ROW + 1 -private const val SONG_ROW = ARTIST_ROW + 1 -private const val SEERR_ROW = SONG_ROW + 1 - -private const val COMBINED_ROW = TAB_ROW + 1 - -@Composable -fun SearchPage( - initialQuery: String, - userPreferences: UserPreferences, - modifier: Modifier = Modifier, - viewModel: SearchViewModel = hiltViewModel(), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val state by viewModel.state.collectAsState() - - // Start with current preferences, but collect updates when view options change - val prefs = - viewModel.userPreferencesService.flow - .collectAsState(userPreferences) - .value.appPreferences.interfacePreferences.searchPreferences - val combinedMode = prefs.combinedSearchResults - val voiceSearchButtonVisible = prefs.showVoiceSearchButton - -// val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf(initialQuery) } - val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - - val seerrActive by viewModel.seerrActive.collectAsState(initial = false) - var selectedTab by rememberSaveable { mutableIntStateOf(0) } - var showViewOptions by rememberSaveable { mutableStateOf(false) } - var searchClicked by rememberSaveable(query) { mutableStateOf(false) } - var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } - - val position by viewModel.position.collectAsState() - - fun setPosition(pos: RowColumn) { - Timber.v("pos=%s", pos) - viewModel.position.value = pos - } - - val contextMenu = rememberContextMenu(userPreferences, viewModel) - - LifecycleResumeEffect(Unit) { - onPauseOrDispose { - viewModel.voiceInputManager.stopListening() - } - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - searchClicked = true - viewModel.search(searchQuery, combinedMode) - } - - LaunchedEffect(query, combinedMode) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750.milliseconds) - viewModel.search(query, combinedMode) - } - } - } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - val onClickItem = { _: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> - setPosition(RowColumn(rowIndex, index)) - contextMenu.showContextMenu(index, item) - } - val onPlayItem = { _: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - } - - val onClickDiscover = { _: Int, item: DiscoverItem -> - val dest = - if (item.jellyfinItemId != null && item.type.baseItemKind != null) { - Destination.MediaItem( - itemId = item.jellyfinItemId, - type = item.type.baseItemKind, - ) - } else { - Destination.DiscoveredItem(item) - } - viewModel.navigationManager.navigateTo(dest) - } - - var showHeader by rememberSaveable { mutableStateOf(true) } - val positionCallback = { columns: Int, index: Int -> - showHeader = index < columns - } - val showTabs = seerrActive && query.isNotBlank() && showHeader && combinedMode - val isLibraryTab = selectedTab == 0 - - LaunchedEffect(seerrActive, query) { - if (!seerrActive || query.isBlank()) { - selectedTab = 0 - } - } - - LaunchedEffect( - searchClicked, - state, - combinedMode, - selectedTab, - seerrActive, - ) { - if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect - - withContext(WholphinDispatchers.IO) { - // Want to focus on the first successful row after all the ones before it are finished searching - val results = - if (isLibraryTab) { - if (combinedMode) { - listOf(state.combinedResults) - } else { - listOf( - state.movies, - state.series, - state.episodes, - state.collections, - state.albums, - state.artists, - state.songs, - ) - } - } else { - listOf(state.seerrResults) - } - val firstSuccess = - results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } - if (firstSuccess >= 0) { - val anyBeforeSearching = - results.subList(0, firstSuccess).any { it is SearchResult.Searching } - if (!anyBeforeSearching) { - val targetRow = - if (isLibraryTab) { - if (combinedMode) { - COMBINED_ROW - } else { - MOVIE_ROW + firstSuccess - } - } else { - SEERR_ROW - } -// setPosition(RowColumn(targetRow, 0)) - onMain { focusRequesters[targetRow].tryRequestFocus() } - } - } - } - } - - val tabs = - remember { - listOf( - TabDetails(R.string.library), - TabDetails(R.string.discover), - ) - } - - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - var isSearchActive by remember { mutableStateOf(false) } - var isTextFieldFocused by remember { mutableStateOf(false) } - val textFieldFocusRequester = remember { FocusRequester() } - - BackHandler(isTextFieldFocused) { - when { - isSearchActive -> { - isSearchActive = false - keyboardController?.hide() - } - - else -> { - focusManager.moveFocus(FocusDirection.Next) - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(0.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .focusGroup() - .focusRestorer(textFieldFocusRequester) - .focusRequester(focusRequesters[SEARCH_ROW]), - ) { - AnimatedVisibility( - visible = voiceSearchButtonVisible, - enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), - exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), - ) { - VoiceSearchButton( - onSpeechResult = { spokenText -> - query = spokenText - triggerImmediateSearch(spokenText) - }, - voiceInputManager = viewModel.voiceInputManager, - modifier = Modifier.padding(end = 12.dp), - ) - } - - SearchEditTextBox( - value = query, - onValueChange = { - isSearchActive = true - query = it - }, - onSearchClick = { triggerImmediateSearch(query) }, - readOnly = !isSearchActive, - modifier = - Modifier - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - isTextFieldFocused = state.isFocused - if (!state.isFocused) isSearchActive = false - }.onPreviewKeyEvent { event -> - val isActivationKey = - event.key in listOf(Key.DirectionCenter, Key.Enter) - if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { - isSearchActive = true - keyboardController?.show() - true - } else { - false - } - }, - ) - - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = { showViewOptions = true }, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - AnimatedVisibility( - visible = showTabs, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTab, - tabs = tabs, - onClick = { - selectedTab = it - val row = - when (selectedTab) { - 0 -> COMBINED_ROW - else -> SEERR_ROW - } - setPosition(RowColumn(row, 0)) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp) - .onFocusChanged { - if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) - }, - ) - } - Box( - modifier = Modifier.fillMaxSize(), - ) { - SideEffect { - Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) - } - when { - isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.combinedResults, - focusRequester = focusRequesters[COMBINED_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(COMBINED_ROW, index, item) - }, - onPlayItem = onPlayItem, - onClickPosition = { setPosition(it) }, - onClickDiscover = onClickDiscover, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - ) - } - - !isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.seerrResults, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - onPlayItem = onPlayItem, - onClickPosition = { setPosition(it) }, - onClickDiscover = onClickDiscover, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - ) - } - - isLibraryTab -> { - LazyColumn( - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 44.dp, - ), - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.focusGroup(), - ) { - searchResultRow( - title = R.string.movies_title, - result = state.movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(MOVIE_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.tv_shows_title, - result = state.series, - rowIndex = SERIES_ROW, - position = position, - focusRequester = focusRequesters[SERIES_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(SERIES_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.episodes, - result = state.episodes, - rowIndex = EPISODE_ROW, - position = position, - focusRequester = focusRequesters[EPISODE_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(EPISODE_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - BannerCardWithTitle( - title = item?.title, - subtitle = item?.subtitle, - item = item, - onClick = { - setPosition(RowColumn(EPISODE_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - modifier = mod.padding(horizontal = 8.dp), - cardHeight = Cards.heightEpisode, - ) - }, - ) - searchResultRow( - title = R.string.collections, - result = state.collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(COLLECTION_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.albums, - result = state.albums, - rowIndex = ALBUM_ROW, - position = position, - focusRequester = focusRequesters[ALBUM_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(ALBUM_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(ALBUM_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.artists, - result = state.artists, - rowIndex = ARTIST_ROW, - position = position, - focusRequester = focusRequesters[ARTIST_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(ARTIST_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(ARTIST_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.songs, - result = state.songs, - rowIndex = SONG_ROW, - position = position, - focusRequester = focusRequesters[SONG_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(SONG_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(SONG_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.discover, - result = state.seerrResults, - rowIndex = SEERR_ROW, - position = position, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - onClickDiscover = onClickDiscover, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } - contextMenu.Compose() - } - - if (showViewOptions) { - SearchViewOptionsDialog( - combinedResults = combinedMode, - onCombinedResultsChange = viewModel::setCombinedResults, - voiceSearchButtonVisible = voiceSearchButtonVisible, - onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, - onDismissRequest = { showViewOptions = false }, - ) - } -} - -@Composable -fun SearchViewOptionsDialog( - combinedResults: Boolean, - onCombinedResultsChange: (Boolean) -> Unit, - voiceSearchButtonVisible: Boolean, - onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, - onDismissRequest: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.setGravity(Gravity.CENTER) - - Box( - modifier = - Modifier - .width(400.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - RoundedCornerShape(28.dp), - ).padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - - ListItem( - selected = false, - headlineContent = { - Text(stringResource(R.string.combined_search_results)) - }, - supportingContent = { - Text( - if (combinedResults) { - stringResource(R.string.combined_search_results_on) - } else { - stringResource(R.string.combined_search_results_off) - }, - ) - }, - trailingContent = { - Switch( - checked = combinedResults, - onCheckedChange = onCombinedResultsChange, - colors = SwitchColors(), - ) - }, - onClick = { onCombinedResultsChange(!combinedResults) }, - modifier = Modifier.fillMaxWidth(), - ) - - ListItem( - selected = false, - headlineContent = { - Text(stringResource(R.string.show_voice_search_button)) - }, - supportingContent = { - Text( - if (voiceSearchButtonVisible) { - stringResource(R.string.visible_ui) - } else { - stringResource(R.string.hidden_ui) - }, - ) - }, - trailingContent = { - Switch( - checked = voiceSearchButtonVisible, - onCheckedChange = onVoiceSearchButtonVisibleChange, - colors = SwitchColors(), - ) - }, - onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -fun SearchCombinedResults( - result: SearchResult, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onPlayItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - onClickDiscover: (Int, DiscoverItem) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - when (result) { - SearchResult.NoQuery -> {} - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.searching), - modifier = modifier.padding(16.dp), - ) - } - - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = result.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = modifier.padding(16.dp), - ) - } - - is SearchResult.Success -> { - if (result.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = result.items, - focusRequester = focusRequester, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onPlayItem = onPlayItem, - onClickPosition = onClickPosition, - positionCallback = positionCallback, - cardContent = { details -> - GridCard( - item = details.item, - onClick = details.onClick, - onLongClick = details.onLongClick, - modifier = details.mod, - fillWidth = details.widthPx, - ) - }, - modifier = modifier, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (result.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = result.items, - focusRequester = focusRequester, - onClickItem = onClickDiscover, - onLongClickItem = { _, _ -> }, - onPlayItem = { _, _ -> }, - onClickPosition = onClickPosition, - positionCallback = positionCallback, - cardContent = { details -> - DiscoverItemCard( - item = details.item, - onClick = details.onClick, - onLongClick = details.onLongClick, - modifier = details.mod, - ) - }, - modifier = modifier, - ) - } - } - } -} - -@Composable -private fun <T : CardGridItem> SearchGrid( - items: List<T?>, - focusRequester: FocusRequester, - onClickItem: (Int, T) -> Unit, - onLongClickItem: (Int, T) -> Unit, - onPlayItem: (Int, T) -> Unit, - onClickPosition: (RowColumn) -> Unit, - cardContent: @Composable (GridItemDetails<T>) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = modifier, - ) { - ItemRowTitle(stringResource(R.string.results)) - - CardGrid( - pager = items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) - onClickItem.invoke(index, item) - }, - onLongClickItem = onLongClickItem, - onClickPlay = { index, item -> - onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) - onPlayItem.invoke(index, item) - }, - letterPosition = { -1 }, - gridFocusRequester = focusRequester, - showJumpButtons = false, - showLetterButtons = false, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - cardContent = cardContent, - ) - } -} - -fun LazyListScope.searchResultRow( - @StringRes title: Int, - result: SearchResult, - rowIndex: Int, - position: RowColumn, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - modifier: Modifier = Modifier, - onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, - cardContent: @Composable ( - index: Int, - item: BaseItem?, - modifier: Modifier, - onClick: () -> Unit, - onLongClick: () -> Unit, - ) -> Unit = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - showImageOverlay = true, - modifier = mod, - ) - }, -) { - item { - when (val r = result) { - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(title), - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = Modifier, - ) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - modifier = modifier.focusRequester(focusRequester), - cardContent = cardContent, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClickDiscover?.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - ) - } - } - } - } -} - -@Composable -fun SearchResultPlaceholder( - title: String, - message: String, - modifier: Modifier = Modifier, - messageColor: Color = MaterialTheme.colorScheme.onBackground, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.padding(bottom = 32.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = messageColor, - ) - } -} 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 582bd69d..e3bea235 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 @@ -36,12 +36,12 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.search.SearchForDialog import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job 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 7677c6aa..f8497a8f 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 @@ -42,13 +42,13 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid import com.github.damontecres.wholphin.ui.main.HomePage -import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage +import com.github.damontecres.wholphin.ui.search.SearchPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind 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/search/SearchForDialog.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt index de47d732..17beee6c 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/search/SearchForDialog.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup @@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.main.SearchResult import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt similarity index 97% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt index 3ab03300..e34697ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.SearchResult import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt new file mode 100644 index 00000000..1bde1c4c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -0,0 +1,770 @@ +package com.github.damontecres.wholphin.ui.search + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.titleStringRes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +internal const val SEARCH_ROW = 0 +internal const val TAB_ROW = SEARCH_ROW + 1 +internal const val SEERR_ROW = TAB_ROW + 1 +internal const val COMBINED_ROW = SEERR_ROW +internal const val RESULTS_START = SEERR_ROW + 1 + +@Composable +fun SearchPage( + initialQuery: String, + userPreferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + val programDialogState by viewModel.programDialogState.collectAsState() + + // Start with current preferences, but collect updates when view options change + val prefs = + viewModel.userPreferencesService.flow + .collectAsState(userPreferences) + .value.appPreferences.interfacePreferences.searchPreferences + val combinedMode = prefs.combinedSearchResults + val voiceSearchButtonVisible = prefs.showVoiceSearchButton + +// val query = rememberTextFieldState() + var query by rememberSaveable { mutableStateOf(initialQuery) } + val focusRequesters = + remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } } + + val seerrActive by viewModel.seerrActive.collectAsState(initial = false) + var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) } + var searchClicked by rememberSaveable(query) { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + var showProgramDialog by remember { mutableStateOf(false) } + + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery, combinedMode) + } + + LaunchedEffect(query, combinedMode) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750.milliseconds) + viewModel.search(query, combinedMode) + } + } + } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + val onClickItem = { _: Int, item: BaseItem -> + Timber.v("Clicked %s, type=%s", item.id, item.type) + if (item.type == BaseItemKind.TV_PROGRAM || item.type == BaseItemKind.PROGRAM || item.type == BaseItemKind.LIVE_TV_PROGRAM) { + viewModel.fetchProgramForDialog(item.id) + showProgramDialog = true + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } + } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } + val onPlayItem = { _: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } + + val onClickDiscover = { _: Int, item: DiscoverItem -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + val positionCallback = { columns: Int, index: Int -> + showHeader = index < columns + } + val showTabs = + seerrActive && state.discoverEnabled && query.isNotBlank() && showHeader && combinedMode + val isLibraryTab = selectedTab == 0 + + LaunchedEffect(seerrActive, query) { + if (!seerrActive || query.isBlank()) { + selectedTab = 0 + } + } + + LaunchedEffect( + searchClicked, + state, + combinedMode, + selectedTab, + seerrActive, + ) { + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect + + withContext(WholphinDispatchers.IO) { + // Want to focus on the first successful row after all the ones before it are finished searching + val results = + if (isLibraryTab) { + if (combinedMode) { + listOf(state.combinedResults) + } else { + state.includedSearchableTypes.map { state.results[it] } + } + } else { + listOf(state.seerrResults) + } + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + val targetRow = + if (isLibraryTab) { + if (combinedMode) { + COMBINED_ROW + } else { + firstSuccess + } + } else { + SEERR_ROW + } +// setPosition(RowColumn(targetRow, 0)) + onMain { focusRequesters.getOrNull(targetRow)?.tryRequestFocus() } + } + } + } + } + + val tabs = + remember(seerrActive, state.discoverEnabled) { + buildList { + add(TabDetails(R.string.library)) + if (seerrActive && state.discoverEnabled) { + add(TabDetails(R.string.discover)) + } + } + } + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + AnimatedVisibility( + visible = voiceSearchButtonVisible, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + modifier = Modifier.padding(end = 12.dp), + ) + } + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + AnimatedVisibility( + visible = showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTab, + tabs = tabs, + onClick = { + selectedTab = it + val row = + when (selectedTab) { + 0 -> COMBINED_ROW + else -> SEERR_ROW + } + setPosition(RowColumn(row, 0)) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, + ) + } + Box( + modifier = Modifier.fillMaxSize(), + ) { +// SideEffect { +// Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) +// } + when { + isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.combinedResults, + focusRequester = focusRequesters[COMBINED_ROW], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + !isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.seerrResults, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + isLibraryTab -> { + LazyColumn( + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 44.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusGroup(), + ) { + itemsIndexed(state.includedSearchableTypes) { index, type -> + val rowIndex = RESULTS_START + index + val result = state.results.getOrDefault(type, SearchResult.Searching) + SearchRowResult( + title = type.titleStringRes, + result = result, + rowIndex = rowIndex, + position = position, + focusRequester = focusRequesters[rowIndex], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(rowIndex, index, item) + }, + onClickPosition = { setPosition(it) }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SearchPageCard( + item = item, + type = type, + onClick = { + setPosition(RowColumn(rowIndex, index)) + onClick.invoke() + }, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + + if (seerrActive && state.discoverEnabled) { + item { + SearchRowResult( + title = R.string.discover, + result = state.seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onClickDiscover = onClickDiscover, + onClickPosition = { setPosition(it) }, + cardContent = { _, _, _, _, _ -> }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + contextMenu.Compose() + } + + if (showViewOptions) { + SearchViewOptionsDialog( + combinedResults = combinedMode, + onCombinedResultsChange = viewModel::setCombinedResults, + voiceSearchButtonVisible = voiceSearchButtonVisible, + onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, + onClickFilterTypes = { showFilterTypeDialog = true }, + onDismissRequest = { showViewOptions = false }, + ) + } + + if (showFilterTypeDialog) { + SearchTypeOptionsDialog( + onDismissRequest = { showFilterTypeDialog = false }, + searchableTypes = state.possibleSearchableTypes, + excludedSearchableTypes = state.excludedSearchableTypes, + discoverAvailable = seerrActive, + discoverEnabled = state.discoverEnabled, + onClick = viewModel::onClickExcludeSearchableType, + onClickDiscover = viewModel::onClickExcludeDiscover, + ) + } + + if (showProgramDialog) { + val context = LocalContext.current + val onDismissRequest = { showProgramDialog = false } + ProgramDialog( + state = programDialogState.loading, + canRecord = true, + onDismissRequest = onDismissRequest, + onWatch = { + onDismissRequest.invoke() + val channelId = it.data.channelId + if (channelId != null) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ), + ) + } else { + Toast.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG).show() + } + }, + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) + onDismissRequest.invoke() + }, + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + timerId = if (series) program.data.seriesTimerId else program.data.timerId, + ) + onDismissRequest.invoke() + }, + ) + } +} + +@Composable +fun SearchCombinedResults( + result: SearchResult, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPlayItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (result) { + SearchResult.NoQuery -> {} + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.searching), + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = result.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Success -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onPlayItem = onPlayItem, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + GridCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + fillWidth = details.widthPx, + ) + }, + modifier = modifier, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, + onPlayItem = { _, _ -> }, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + DiscoverItemCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + ) + }, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun <T : CardGridItem> SearchGrid( + items: List<T?>, + focusRequester: FocusRequester, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, + onPlayItem: (Int, T) -> Unit, + onClickPosition: (RowColumn) -> Unit, + cardContent: @Composable (GridItemDetails<T>) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier, + ) { + ItemRowTitle(stringResource(R.string.results)) + + CardGrid( + pager = items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onClickItem.invoke(index, item) + }, + onLongClickItem = onLongClickItem, + onClickPlay = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onPlayItem.invoke(index, item) + }, + letterPosition = { -1 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + cardContent = cardContent, + ) + } +} + +@Composable +fun SearchRowResult( + @StringRes title: Int, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, + cardContent: @Composable ( + index: Int, + item: BaseItem?, + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) -> Unit, +) { + when (val r = result) { + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(title), + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(title), + message = stringResource(R.string.searching), + modifier = modifier, + ) + } + + is SearchResult.Success -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = modifier.focusRequester(focusRequester), + cardContent = cardContent, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier.focusRequester(focusRequester), + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } + } +} + +@Composable +fun SearchResultPlaceholder( + title: String, + message: String, + modifier: Modifier = Modifier, + messageColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + style = MaterialTheme.typography.titleMedium, + color = messageColor, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt new file mode 100644 index 00000000..9b82f24c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.ui.search + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.PersonCard +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.cards.personRowCardWidth +import com.github.damontecres.wholphin.ui.cards.rememberImageUrl +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import java.time.OffsetDateTime + +@Composable +fun SearchPageCard( + item: BaseItem?, + type: BaseItemKind, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (type) { + BaseItemKind.EPISODE -> { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.padding(horizontal = 8.dp), + cardHeight = Cards.heightEpisode, + ) + } + + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = modifier, + ) + } + + BaseItemKind.PERSON -> { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + ) + } + PersonCard( + name = item?.name ?: "", + role = null, + imageUrl = imageUrl, + favorite = item?.favorite ?: false, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.width(personRowCardWidth), + ) + } + + BaseItemKind.TV_CHANNEL -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + modifier = modifier, + ) + } + + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> { + val subtitle = + remember(item) { + val startDate = item?.data?.startDate + val endDate = item?.data?.endDate + if (startDate != null && endDate != null) { + DateUtils.formatDateRange( + context, + startDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + endDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY, + ) + } else { + null + } + } + + SeasonCard( + title = item?.title, + subtitle = subtitle, + name = item?.name, + imageUrl = rememberImageUrl(item, Cards.HEIGHT_LIVE_TV.dp, Dp.Unspecified), + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + imageWidth = Dp.Unspecified, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + ) + } + + else -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + showImageOverlay = true, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt new file mode 100644 index 00000000..f8a6eb2e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.main.settings.TitleText +import com.github.damontecres.wholphin.ui.preferences.SwitchColors +import com.github.damontecres.wholphin.ui.titleStringRes +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchTypeOptionsDialog( + onDismissRequest: () -> Unit, + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + SearchTypeOptionsDialogContent( + searchableTypes = searchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + discoverAvailable = discoverAvailable, + discoverEnabled = discoverEnabled, + onClick = onClick, + onClickDiscover = onClickDiscover, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun SearchTypeOptionsDialogContent( + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + TitleText(stringResource(R.string.include_types)) + + LazyColumn { + items(searchableTypes) { searchableType -> + val checked = searchableType !in excludedSearchableTypes + ListItem( + enabled = true, + selected = false, + onClick = { onClick.invoke(searchableType) }, + headlineContent = { + Text(stringResource(searchableType.titleStringRes)) + }, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + if (discoverAvailable) { + item { + ListItem( + enabled = true, + selected = false, + onClick = onClickDiscover, + headlineContent = { + Text(stringResource(R.string.discover)) + }, + trailingContent = { + Switch( + checked = discoverEnabled, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..d0c55c05 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -0,0 +1,628 @@ +package com.github.damontecres.wholphin.ui.search + +import android.content.Context +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.datastore.core.DataStore +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.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateSearchPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.LiveTvService +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.SeerrService +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.ProgramItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialogState +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +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.showToast +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.SearchRelevance +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val api: ApiClient, + val navigationManager: NavigationManager, + val appPreferences: DataStore<AppPreferences>, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, + val userPreferencesService: UserPreferencesService, + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val mediaReportService: MediaReportService, + private val liveTvService: LiveTvService, + private val keyValueService: KeyValueService, + private val navDrawerService: NavDrawerService, + ) : ViewModel(), + ContextMenuProvider { + val seerrActive = seerrService.active + + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow<SearchState> = _state + val position = MutableStateFlow(RowColumn(0, 0)) + + private var currentQuery: String? = null + private var combinedMode = false + + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow<ProgramDialogState> = _programDialogState + + private var userLibraryTypes: Set<BaseItemKind> = emptySet() + + init { + init() + } + + private fun init() { + viewModelScope.launchDefault { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + userLibraryTypes = + serverRepository.currentUser + ?.id + ?.let { userId -> + navDrawerService + .getAllUserLibraries(userId, tvAccess) + .flatMap { it.collectionType.baseItemKinds } + .toSet() + }.orEmpty() + + val excludedSearchableTypes = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get<List<BaseItemKind>>( + userId, + EXCLUDED_SEARCHABLE_TYPES_KEY, + emptyList(), + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + emptyList() + } + } ?: emptyList() + val discoverEnabled = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get( + userId, + INCLUDE_DISCOVER_KEY, + true, + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + true + } + } ?: true + val searchableTypes = determineSearchableTypes(excludedSearchableTypes) + val possibleSearchableTypes = determineSearchableTypes(emptyList()) + _state.update { + it.copy( + discoverEnabled = discoverEnabled, + includedSearchableTypes = searchableTypes, + possibleSearchableTypes = possibleSearchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + searchableTypes.forEach { put(it, SearchResult.NoQuery) } + }, + ) + } + } + } + + private fun determineSearchableTypes(excludedSearchableTypes: List<BaseItemKind>): List<BaseItemKind> { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + val searchableTypes = + allSearchableTypes.filter { + val excluded = excludedSearchableTypes.contains(it) + // Only include if there's a relevant, accessible library + val hasLibrary = userLibraryTypes.contains(it) + when (it) { + // No library type for person + BaseItemKind.PERSON -> !excluded + + // Remove live tv search if user doesn't have access + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.PROGRAM, + -> tvAccess && !excluded + + else -> hasLibrary && !excluded + } + } + return searchableTypes + } + + fun search( + query: String?, + combined: Boolean = false, + force: Boolean = false, + ) { + if (currentQuery == query && combinedMode == combined && !force) { + return + } + currentQuery = query + combinedMode = combined + if (query.isNotNullOrBlank()) { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.Searching) + } + }, + combinedResults = SearchResult.Searching, + ) + } + if (combined) { + searchCombined(query) + } else { + state.value.includedSearchableTypes.forEach { type -> + searchType(query, type) + } + } + searchSeerr(query) + } else { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.NoQuery) + } + }, + combinedResults = SearchResult.NoQuery, + ) + } + } + } + + private fun searchType( + query: String, + type: BaseItemKind, + ) { + viewModelScope.launchIO { + try { + val items: List<BaseItem> = + when (type) { + BaseItemKind.LIVE_TV_PROGRAM -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = ProgramItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + + BaseItemKind.MUSIC_ARTIST -> { + api.artistsApi + .getArtists( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ).toBaseItems(api, false) + } + + BaseItemKind.PERSON -> { + api.personsApi + .getPersons( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ).toBaseItems(api, false) + } + + else -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) } + .thenBy { it.sortName }, + ) + Timber.v("Search finished for %s, %s results", type, sorted.size) + _state.value.results[type] = SearchResult.Success(sorted) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") + _state.value.results[type] = SearchResult.Error(ex) + } + } + } + + private fun searchCombined(query: String) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + try { + Timber.v("Starting searchCombined") + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = state.value.includedSearchableTypes, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ) + + val result = api.itemsApi.getItems(request).content + val items = + result.items.map { + BaseItem(it, false) + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) } + .thenBy { it.name ?: "" }, + ) + Timber.v("searchCombined complete %s results", sorted.size) + _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } + } catch (ex: Exception) { + Timber.e(ex, "Exception in combined search") + _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } + } + } + } + + fun setCombinedResults(enabled: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + combinedSearchResults = enabled + } + } + } + } + + fun setVoiceSearchButtonVisible(visible: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + showVoiceSearchButton = visible + } + } + } + } + + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrActive.first() && state.value.discoverEnabled) { + _state.update { it.copy(seerrResults = SearchResult.Searching) } + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } + } + } + } + + init { + addCloseable(voiceInputManager) + } + + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { + state.value.results[it] + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + if (combinedMode) { + _state.update { + it.copy( + combinedResults = newList, + ) + } + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { type -> + state.value.results[type] = newList + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = liveTvService.fetchProgramForDialog(programId) + _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching program $programId") + _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + + fun cancelRecording( + series: Boolean, + timerId: String?, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + // TODO update program card? + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun record( + programId: UUID, + series: Boolean, + ) { + viewModelScope.launchIO { + try { + liveTvService.record(programId, series) + // TODO update program card? + refreshItem(programId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeSearchableType(type: BaseItemKind) { + viewModelScope.launchIO { + try { + val state = state.value + val updateSearch: Boolean + val newExcludes = + state.excludedSearchableTypes.toMutableList().apply { + if (type in this) { + updateSearch = true + remove(type) + } else { + // If combined, need to update, otherwise just hide the newly excluded results + updateSearch = combinedMode + add(type) + } + } + Timber.v("newExcludes=%s", newExcludes) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, EXCLUDED_SEARCHABLE_TYPES_KEY, newExcludes) + } + val searchableTypes = determineSearchableTypes(newExcludes) + _state.update { + it.copy( + includedSearchableTypes = searchableTypes, + excludedSearchableTypes = newExcludes, + ) + } + if (updateSearch && currentQuery.isNotNullOrBlank()) { + Timber.d("Need to update search") + search(currentQuery, combinedMode, true) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception toggling %s", type) + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeDiscover() { + viewModelScope.launchIO { + try { + val newIncludeDiscover = state.value.discoverEnabled.not() + Timber.v("newIncludeDiscover=%s", newIncludeDiscover) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, INCLUDE_DISCOVER_KEY, newIncludeDiscover) + } + _state.update { + it.copy( + discoverEnabled = newIncludeDiscover, + ) + } + if (newIncludeDiscover) { + currentQuery?.takeIf { it.isNotNullOrBlank() }?.let { query -> + searchSeerr(query) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception discover") + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + companion object { + private const val EXCLUDED_SEARCHABLE_TYPES_KEY = "excludedSearchableTypes" + private const val INCLUDE_DISCOVER_KEY = "searchIncludeDiscover" + } + } + +sealed interface SearchResult { + data object NoQuery : SearchResult + + data object Searching : SearchResult + + data class Error( + val ex: Exception, + ) : SearchResult + + data class Success( + val items: List<BaseItem?>, + ) : SearchResult + + data class SuccessSeerr( + val items: List<DiscoverItem>, + ) : SearchResult +} + +data class SearchState( + val results: SnapshotStateMap<BaseItemKind, SearchResult> = SnapshotStateMap(), + val seerrResults: SearchResult = SearchResult.NoQuery, + val combinedResults: SearchResult = SearchResult.NoQuery, + val possibleSearchableTypes: List<BaseItemKind> = emptyList(), + val includedSearchableTypes: List<BaseItemKind> = emptyList(), + val excludedSearchableTypes: List<BaseItemKind> = emptyList(), + val discoverEnabled: Boolean = true, +) + +private val allSearchableTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +private const val SEARCH_LIMIT = 50 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt new file mode 100644 index 00000000..cdbd69e1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt @@ -0,0 +1,121 @@ +package com.github.damontecres.wholphin.ui.search + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.preferences.SwitchColors + +@Composable +fun SearchViewOptionsDialog( + combinedResults: Boolean, + onCombinedResultsChange: (Boolean) -> Unit, + voiceSearchButtonVisible: Boolean, + onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, + onClickFilterTypes: () -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.CENTER) + + Box( + modifier = + Modifier + .width(400.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + RoundedCornerShape(28.dp), + ).padding(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.combined_search_results)) + }, + supportingContent = { + Text( + if (combinedResults) { + stringResource(R.string.combined_search_results_on) + } else { + stringResource(R.string.combined_search_results_off) + }, + ) + }, + trailingContent = { + Switch( + checked = combinedResults, + onCheckedChange = onCombinedResultsChange, + colors = SwitchColors(), + ) + }, + onClick = { onCombinedResultsChange(!combinedResults) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.show_voice_search_button)) + }, + supportingContent = { + Text( + if (voiceSearchButtonVisible) { + stringResource(R.string.visible_ui) + } else { + stringResource(R.string.hidden_ui) + }, + ) + }, + trailingContent = { + Switch( + checked = voiceSearchButtonVisible, + onCheckedChange = onVoiceSearchButtonVisibleChange, + colors = SwitchColors(), + ) + }, + onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.include_types)) + }, + onClick = onClickFilterTypes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 990d28f8..bfb3caac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,4 +913,5 @@ <string name="dim_after_delay">Dim screen after delay</string> <string name="dim_percentage">Dim percentage</string> <string name="dim_screen">Dim screen</string> + <string name="photo_albums">Photo albums</string> </resources> From 786b95a3938a641b4f4c5f3dd1900143c6e4f5f7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 26 Aug 2026 21:46:48 -0400 Subject: [PATCH 403/475] Fix white flash on app start (#1909) ## Description This fixes the brief full screen white flash when the app starts or reloads. I bisected to find the cause which was #1653. The newly added `Surface` uses `MaterialTheme` to set its colors. `MaterialTheme` defaults to a generic _light_ color scheme. So this PR applies the Wholphin's default purple theme, which will then be overridden once the app preferences are loaded. ### Related issues Mentioned in https://github.com/damontecres/Wholphin/issues/1576#issuecomment-5376373039 ### Testing Used emulator with "Don't keep activities" and repeatedly opened & closed the app to see if the white flash visually occurred ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 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 0baaefdc..f7d995ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack 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.AppPreferences @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler @@ -228,56 +230,54 @@ class MainActivity : AppCompatActivity() { viewModel.appStart(intent) setContent { - Surface( - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - val userPreferences by userPreferencesService.flow.collectAsState(null) - if (userPreferences == null) { - // Show loading page if it is taking a while to get app preferences - var showLoading by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - delay(500.milliseconds) - Timber.i("Showing loading page") - showLoading = true - } - if (showLoading) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - LoadingPage() + MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) { + Surface(Modifier.fillMaxSize()) { + val userPreferences by userPreferencesService.flow.collectAsState(null) + if (userPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500.milliseconds) + Timber.i("Showing loading page") + showLoading = true } - } - } else { - userPreferences?.let { userPreferences -> - val appPreferences = userPreferences.appPreferences - CoilConfig( - prefs = appPreferences, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), ) { - ProvideLocalClock { - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - userPreferences = userPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - modifier = Modifier.fillMaxSize(), - ) + LoadingPage() + } + } + } else { + userPreferences?.let { userPreferences -> + val appPreferences = userPreferences.appPreferences + CoilConfig( + prefs = appPreferences, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, + ) + LaunchedEffect(appPreferences.debugLogging) { + DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging + } + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, + ) { + ProvideLocalClock { + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + userPreferences = userPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + modifier = Modifier.fillMaxSize(), + ) + } } } } From fdde98602921b8ecd7f52b3d18f32d3965ba8ab7 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 26 Aug 2026 21:50:13 -0400 Subject: [PATCH 404/475] Update favorites page with more types (#1864) ## Description This PR adds many more data types as tabs to the favorites page. Additionally, only tabs for the types the user has favorites for will appear. For example, if you have no favorited music videos, that tab will not show. Added types: - Collections - TV Channels - Music albums - Music artists - Music videos - Songs - Home videos - Photos - Photo albums Dev note: The tabs are implemented using keys instead of indexes which could be useful for the series overview season tabs. Plus it maybe even be adapted for the episode list to eliminate the terrible index search logic. A major difference is that the possible favorites tabs are known/static list where seasons/episodes are queried & paginated. ### Related issues Closes #1041 Closes #1844 ### Testing Manually on emulator, plus unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/HomeRowConfig.kt | 10 +- .../wholphin/services/HomeSettingsService.kt | 140 ++-- .../wholphin/services/NavDrawerService.kt | 2 + .../damontecres/wholphin/ui/Extensions.kt | 3 + .../ui/components/CollectionFolderGrid.kt | 24 +- .../ui/components/CollectionFolderView.kt | 126 ++- .../wholphin/ui/components/TabRow.kt | 76 ++ .../wholphin/ui/components/TabbedPage.kt | 102 ++- .../ui/components/ViewOptionsDialog.kt | 25 + .../wholphin/ui/detail/FavoritesPage.kt | 352 +++----- .../wholphin/ui/detail/FavoritesViewModel.kt | 774 ++++++++++++++++++ .../ui/main/settings/HomeRowPresets.kt | 44 + .../main/settings/HomeSettingsFavoriteList.kt | 20 +- .../ui/main/settings/HomeSettingsPage.kt | 4 + .../ui/main/settings/HomeSettingsViewModel.kt | 21 +- .../wholphin/ui/search/SearchViewModel.kt | 12 +- app/src/main/res/values/strings.xml | 1 + .../damontecres/wholphin/test/TestUtils.kt | 17 + .../ui/detail/TestFavoritesViewModel.kt | 427 ++++++++++ 19 files changed, 1783 insertions(+), 397 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 231cb670..4914248f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -109,15 +109,7 @@ sealed interface HomeRowConfig { @SerialName("Favorite") data class Favorite( val kind: BaseItemKind, - override val viewOptions: HomeRowViewOptions = - if (kind == BaseItemKind.EPISODE) { - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - aspectRatio = AspectRatio.WIDE, - ) - } else { - HomeRowViewOptions() - }, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) } 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 934e440d..ff7d7411 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 @@ -15,8 +15,8 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.HomeItemFields import com.github.damontecres.wholphin.ui.ProgramItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.formatTypeName 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 @@ -26,6 +26,7 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler @@ -65,6 +66,7 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest @@ -507,7 +509,7 @@ class HomeSettingsService val name = ResProviderStringProvider( R.string.favorite_items_title, - ResStringProvider(favoriteOptions[config.kind]!!), + ResStringProvider(formatTypeName(config.kind)), ) HomeRowConfigDisplay(id, name, config) } @@ -959,68 +961,86 @@ class HomeSettingsService val title = ResProviderStringProvider( R.string.favorite_items_title, - ResStringProvider(favoriteOptions[row.kind]!!), + ResStringProvider(formatTypeName(row.kind)), ) - if (row.kind == BaseItemKind.PERSON) { - val request = - GetPersonsRequest( - userId = userDto.id, - limit = limit, - fields = HomeItemFields, - isFavorite = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY), - ) - GetPersonsHandler - .execute(api, request) - .content.items - .map { BaseItem(it, true) } - .let { - Success( - title, - it, - row.viewOptions, - showViewMore = it.size >= limit, - ) + val resultList = + when (row.kind) { + BaseItemKind.PERSON -> { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + + GetPersonsHandler.execute(api, request).toBaseItems() } - } else { - val fields = - if (row.kind == BaseItemKind.BOX_SET) { - HomeItemFieldsBoxSets - } else { - HomeItemFields + + BaseItemKind.MUSIC_ARTIST -> { + val request = + GetArtistsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetArtistsHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetArtistsHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + + else -> { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = fields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } } - val request = - GetItemsRequest( - userId = userDto.id, - recursive = true, - limit = limit, - fields = fields, - includeItemTypes = listOf(row.kind), - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) } + resultList.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) } } 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 a4716ac9..6b8701ee 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 @@ -245,6 +245,7 @@ class NavDrawerService it.copy( items = items, moreItems = moreItems, + allLibraries = allLibraries, ) } } @@ -255,6 +256,7 @@ data class NavDrawerItemState( val moreItems: List<NavDrawerItem> = emptyList(), val nowPlayingEnabled: Boolean = false, val nowPlayingTitle: String? = null, + val allLibraries: List<Library> = emptyList(), ) val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true 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 2097dd9e..312bc6cc 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 @@ -427,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } +fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) = + this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) } + /** * Check if this, coalescing nulls to zero, is greater than that */ 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 024eeea4..1df5ee90 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 @@ -237,7 +237,29 @@ val CollectionType.baseItemKinds: List<BaseItemKind> listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) } - else -> { + CollectionType.LIVETV -> { + listOf( + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + ) + } + + CollectionType.TRAILERS -> { + listOf(BaseItemKind.TRAILER) + } + + CollectionType.BOOKS -> { + listOf(BaseItemKind.BOOK) + } + + CollectionType.FOLDERS -> { + listOf(BaseItemKind.FOLDER) + } + + CollectionType.UNKNOWN -> { listOf() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index f2b17325..c1566b61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -127,7 +127,8 @@ class CollectionFolderViewModel @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, @Assisted defaultViewOptions: ViewOptions, ) : ViewModel(), - ContextMenuProvider { + ContextMenuProvider, + CollectionFolderViewActions { @AssistedFactory interface Factory { fun create( @@ -252,7 +253,7 @@ class CollectionFolderViewModel } } - fun saveViewOptions(viewOptions: ViewOptions) { + override fun saveViewOptions(viewOptions: ViewOptions) { position = 0 _state.update { it.copy(viewOptions = viewOptions) } viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { @@ -263,7 +264,7 @@ class CollectionFolderViewModel } } - fun onFilterChange( + override fun onFilterChange( newFilter: GetItemsFilter, recursive: Boolean, ) { @@ -278,7 +279,7 @@ class CollectionFolderViewModel ) } - fun onSortChange( + override fun onSortChange( sortAndDirection: SortAndDirection, recursive: Boolean, filter: GetItemsFilter, @@ -459,7 +460,7 @@ class CollectionFolderViewModel return request } - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = filterOptionCache.getFilterOptionValues( itemId.toUUID(), filterOption, @@ -469,7 +470,7 @@ class CollectionFolderViewModel * The count must come from the same endpoint that [createPager] pages through, otherwise * the index refers to a different result set and lands past the end of the list. */ - suspend fun positionOfLetter(letter: Char): Int? = + override suspend fun positionOfLetter(letter: Char): Int? = withContext(WholphinDispatchers.IO) { val filter = state.value.filter when (filter.override) { @@ -541,7 +542,7 @@ class CollectionFolderViewModel } } - fun updateBackdrop(item: BaseItem) { + override fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) } @@ -714,9 +715,83 @@ fun CollectionFolderView( }, ) { val state by viewModel.state.collectAsState() - var position by rememberInt(viewModel.position) + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() - val contextMenu = rememberContextMenu(preferences, viewModel) + onPauseOrDispose { + viewModel.release() + } + } + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = itemId, + initialFilter = initialFilter, + recursive = recursive, + actions = actions, + sortOptions = sortOptions, + playEnabled = playEnabled, + defaultViewOptions = defaultViewOptions, + viewActions = viewModel, + modifier = modifier, + provider = viewModel, + showTitle = showTitle, + positionCallback = { columns, index -> + viewModel.position = index + positionCallback?.invoke(columns, index) + }, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, + ) +} + +interface CollectionFolderViewActions { + fun navigateTo(destination: Destination) + + fun updateBackdrop(item: BaseItem) + + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) + + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> + + suspend fun positionOfLetter(letter: Char): Int? + + fun saveViewOptions(viewOptions: ViewOptions) +} + +@Composable +fun CollectionFolderViewContent( + preferences: UserPreferences, + state: CollectionFolderState, + savedPosition: Int, + itemId: String, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + viewActions: CollectionFolderViewActions, + modifier: Modifier = Modifier, + provider: ContextMenuProvider, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) { + var position by rememberInt(savedPosition) + + val contextMenu = rememberContextMenu(preferences, provider) var showViewOptions by rememberSaveable { mutableStateOf(false) } var filterDropdownShowing by remember { mutableStateOf(false) } val headerRowFocusRequester = remember { FocusRequester() } @@ -760,7 +835,7 @@ fun CollectionFolderView( filter = state.filter, ) } - viewModel.navigateTo(destination) + viewActions.navigateTo(destination) } Unit }, @@ -779,7 +854,7 @@ fun CollectionFolderView( } else { Destination.Playback(item) } - viewModel.navigateTo(destination) + viewActions.navigateTo(destination) }, ) } @@ -803,13 +878,6 @@ fun CollectionFolderView( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Column(modifier = Modifier.fillMaxSize()) { - LifecycleResumeEffect(itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } - } var showHeader by rememberSaveable { mutableStateOf(true) } val gridFocusRequester = remember { FocusRequester() } val pager = remember(state.items) { state.items.successValue } @@ -817,7 +885,7 @@ fun CollectionFolderView( val focusedItem = pager?.getOrNull(position) if (state.viewOptions.showBackdrop) { LaunchedEffect(focusedItem) { - focusedItem?.let(viewModel::updateBackdrop) + focusedItem?.let(viewActions::updateBackdrop) } } CollectionFolderHeader( @@ -827,15 +895,15 @@ fun CollectionFolderView( title = title, sortAndDirection = state.sortAndDirection, onSortChange = { - viewModel.onSortChange(it, recursive, state.filter) + viewActions.onSortChange(it, recursive, state.filter) }, sortOptions = sortOptions, currentFilter = state.filter, onFilterChange = { - viewModel.onFilterChange(it, recursive) + viewActions.onFilterChange(it, recursive) }, getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) + viewActions.getFilterOptionValues(it) }, filterOptions = filterOptions, onClickPlayAll = gridActions.onClickPlayAll!!, @@ -875,7 +943,7 @@ fun CollectionFolderView( CollectionFolderGrid( preferences = preferences, collectionType = item?.data?.collectionType, - initialPosition = viewModel.position, + initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), @@ -883,12 +951,11 @@ fun CollectionFolderView( onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, positionCallback = { columns, pos -> - viewModel.position = pos showHeader = pos < columns position = pos positionCallback?.invoke(columns, pos) }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, @@ -897,7 +964,7 @@ fun CollectionFolderView( CollectionFolderList( preferences = preferences, collectionType = item?.data?.collectionType, - initialPosition = viewModel.position, + initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), @@ -905,12 +972,11 @@ fun CollectionFolderView( onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, positionCallback = { columns, pos -> - viewModel.position = pos showHeader = pos < columns position = pos positionCallback?.invoke(columns, pos) }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, @@ -946,9 +1012,9 @@ fun CollectionFolderView( defaultViewOptions = defaultViewOptions, onDismissRequest = { showViewOptions = false - viewModel.saveViewOptions(state.viewOptions) + viewActions.saveViewOptions(state.viewOptions) }, - onViewOptionsChange = viewModel::saveViewOptions, + onViewOptionsChange = viewActions::saveViewOptions, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 293005dc..f2f60ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -43,6 +45,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.StringStringProvider import timber.log.Timber +import java.util.SortedMap @Composable fun TabRow( @@ -109,6 +112,79 @@ fun TabRow( } } +@Composable +fun <T> KeyedTabRow( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + onClick: (T) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + LaunchedEffect(selectedTabKey) { + tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView() + } + var rowHasFocus by remember { mutableStateOf(false) } + val currentSelectedTabKey by rememberUpdatedState(selectedTabKey) + val currentOnClick by rememberUpdatedState(onClick) + + val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() } + + LazyRow( + state = state, + modifier = + modifier + .onFocusChanged { + rowHasFocus = it.hasFocus + }.focusGroup() + .focusProperties { + onEnter = { + // If entering from left or right, use last or first tab + // Otherwise use the selected tab + val currentKey = currentSelectedTabKey +// val requesters = currentFocusRequesters + Timber.v( + "onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s", + requestedFocusDirection, + currentSelectedTabKey, + ) + val key = + if (requestedFocusDirection == FocusDirection.Left) { + tabs.lastKey() + } else if (requestedFocusDirection == FocusDirection.Right) { + tabs.firstKey() + } else { + currentKey + } + val tab = tabs[key] + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() + } + }, + ) { + items(tabKeyList, key = { it }) { tabKey -> + tabs[tabKey]?.let { tab -> + val interactionSource = remember { MutableInteractionSource() } + val onTabClick = + remember(tabKey) { + { + currentOnClick(tabKey) + } + } + Tab( + title = tab.title.getString(), + selected = tabKey == selectedTabKey, + rowActive = rowHasFocus, + interactionSource = interactionSource, + onClick = onTabClick, + modifier = + Modifier + .focusRequester(tab.tabFocusRequester) + .bringIntoViewRequester(tab.bringIntoViewRequester), + ) + } + } + } +} + @Composable fun Tab( title: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt index 28e163a0..0749c068 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -1,14 +1,21 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp @@ -27,13 +34,15 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import timber.log.Timber +import java.util.SortedMap @HiltViewModel(assistedFactory = TabViewModel.Factory::class) class TabViewModel @AssistedInject constructor( - private val userPreferencesService: UserPreferencesService, + val userPreferencesService: UserPreferencesService, private val rememberedTabService: RememberedTabService, private val backdropService: BackdropService, @param:Assisted private val itemId: String, @@ -50,6 +59,9 @@ class TabViewModel private val _state = MutableStateFlow<Int>(UNSET) val state: StateFlow<Int> = _state + val isShowClock = + userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock } + init { viewModelScope.launchIO { val startingTab = @@ -91,6 +103,7 @@ data class TabDetails( val title: StringProvider, val tabFocusRequester: FocusRequester = FocusRequester(), val contentFocusRequester: FocusRequester = FocusRequester(), + val bringIntoViewRequester: BringIntoViewRequester = BringIntoViewRequester(), ) { constructor( @StringRes stringResId: Int, @@ -118,7 +131,36 @@ fun TabbedPage( tabContent: @Composable (Int, TabDetails) -> Unit, ) { val selectedTabIndex by viewModel.state.collectAsState() + val isShowClock by viewModel.isShowClock.collectAsState(true) + TabbedPage( + selectedTabIndex = selectedTabIndex, + tabs = tabs, + updateSelectedTabIndex = viewModel::updateSelectedTabIndex, + isShowClock = isShowClock, + modifier = modifier, + showTabs = showTabs, + tabContent = tabContent, + ) +} +@Composable +fun TabbedPage( + selectedTabIndex: Int, + tabs: List<TabDetails>, + updateSelectedTabIndex: (Int) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } Column( modifier = modifier, ) { @@ -131,13 +173,13 @@ fun TabbedPage( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(vertical = 16.dp), + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), tabs = tabs, - onClick = viewModel::updateSelectedTabIndex, + onClick = updateSelectedTabIndex, ) } selectedTabIndex.let { tabIndex -> - if (tabIndex >= 0) { + if (tabIndex in tabs.indices) { tabContent.invoke(tabIndex, tabs[tabIndex]) } else { DelayedLoadingPage(focusEnabled = false) @@ -145,3 +187,55 @@ fun TabbedPage( } } } + +@Composable +fun <T> KeyedTabbedPage( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + updateSelectedTabKey: (T) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (T, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + KeyedTabRow( + selectedTabKey = selectedTabKey, + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), + tabs = tabs, + onClick = updateSelectedTabKey, + ) + } + AnimatedContent( + targetState = selectedTabKey, + transitionSpec = { + (fadeIn(animationSpec = tween(300, delayMillis = 100))) + .togetherWith(fadeOut(animationSpec = tween(200))) + }, + ) { tabKey -> + val tab = tabs[tabKey] + if (tab != null) { + tabContent.invoke(tabKey, tab) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index c9c8f98b..a1237acd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.preferences.ComposablePreference import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType /** @@ -313,3 +314,27 @@ enum class ViewOptionsType { LIST, DENSE_LIST, } + +val BaseItemKind.defaultViewOptions: ViewOptions + get() = + when (this) { + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.PLAYLIST, + -> ViewOptionsSquare + + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> ViewOptionsWide + + else -> ViewOptionsPoster + } 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 b8e40895..0e46999f 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,51 +1,37 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box 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.saveable.rememberSaveable 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.focusRequester -import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions 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.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.components.CollectionFolderView +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GridClickActions -import com.github.damontecres.wholphin.ui.components.TabDetails -import com.github.damontecres.wholphin.ui.components.TabbedPage -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare -import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions -import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import dagger.hilt.android.lifecycle.HiltViewModel -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import javax.inject.Inject - -@HiltViewModel -class FavoritesViewModel - @Inject - constructor( - val navigationManager: NavigationManager, - ) : ViewModel() +import com.github.damontecres.wholphin.ui.components.KeyedTabbedPage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.rememberSortOptions +import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun FavoritesPage( @@ -53,235 +39,91 @@ fun FavoritesPage( modifier: Modifier = Modifier, viewModel: FavoritesViewModel = hiltViewModel(), ) { - val tabs = - remember { - listOf( - TabDetails(R.string.movies_title), - TabDetails(R.string.tv_shows_title), - TabDetails(R.string.episodes), - TabDetails(R.string.videos), - TabDetails(R.string.playlists), - TabDetails(R.string.people_title), - ) - } - val actions = - remember { - GridClickActions( - onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, - ) + OneTimeLaunchedEffect { viewModel.init() } + val state by viewModel.state.collectAsState() + + var showTabs by rememberSaveable { mutableStateOf(true) } + + when (val s = state.loadingState) { + is FavoritesLoadingState.Error -> { + ErrorMessage(s.message, s.exception, modifier) } - var showHeader by rememberSaveable { mutableStateOf(true) } + FavoritesLoadingState.Loading, + FavoritesLoadingState.Pending, + -> { + LoadingPage(modifier) + } + FavoritesLoadingState.NoFavorites -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .focusRequester(focusRequester) + .focusable(), + ) { + Text( + text = stringResource(R.string.no_favorites_found), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier, + ) + } + } + + FavoritesLoadingState.Success -> { // LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - TabbedPage( - itemId = NavDrawerItem.Favorites.id, - tabs = tabs, - showTabs = showHeader, - modifier = modifier, - ) { tabIndex, tabDetails -> - // TODO playEnabled = true for movies & episodes - when (tabIndex) { - // Movies - 0 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_movies", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = MovieSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } + KeyedTabbedPage( + selectedTabKey = state.tabKey, + updateSelectedTabKey = viewModel::updateSelectedTabKey, + tabs = state.tabs, + showTabs = showTabs, + isShowClock = state.isShowClock, + modifier = modifier, + ) { type, tabDetails -> + val collectionState = state.favorites[type] + if (collectionState != null) { + val provider = remember(type) { viewModel.createTypedProvider(type) } + val contextMenu = rememberContextMenu(preferences, provider) + val actions = + remember(type) { + GridClickActions( + onClickItem = { _, item -> + viewModel.navigationManager.navigateTo( + item.destination(), + ) + }, + onLongClickItem = contextMenu::showContextMenu, + ) + } + CollectionFolderViewContent( + preferences = preferences, + state = collectionState, + savedPosition = 0, + itemId = remember { viewModel.libraryDisplayItemId(type) }, + initialFilter = CollectionFolderFilter(), + recursive = true, + actions = actions, + sortOptions = rememberSortOptions(type), + // TODO playEnabled = true for movies & episodes + playEnabled = false, + defaultViewOptions = type.defaultViewOptions, + viewActions = provider, + provider = provider, + showTitle = false, + positionCallback = { columns, index -> + showTabs = index < columns + }, + focusRequesterOnEmpty = null, + filterOptions = DefaultForFavoritesFilterOptions, + ) - // TV - 1 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_series", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.SERIES), - ), - ), - showTitle = false, - recursive = true, - sortOptions = SeriesSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // Episodes - 2 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_episodes", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = EpisodeSortOptions, - defaultViewOptions = ViewOptionsWide, - useSeriesForPrimary = false, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // Videos - 3 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_videos", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.VIDEO), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsWide, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // Playlists - 4 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_playlists", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.PLAYLIST), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // People - 5 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_people", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - override = GetItemsFilterOverride.PERSON, - ), - ), - initialSortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - showTitle = false, - recursive = true, - sortOptions = listOf(), - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = listOf(), - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - else -> { - ErrorMessage("Invalid tab index $tabIndex", null) + contextMenu.Compose() + } else { + ErrorMessage("Invalid tab $type", null) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt new file mode 100644 index 00000000..734ac3d0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -0,0 +1,774 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +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.RememberedTabService +import com.github.damontecres.wholphin.services.StreamChoiceService +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.collectLatestIn +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +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.showToast +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import timber.log.Timber +import java.util.SortedMap +import java.util.TreeMap +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, + private val rememberedTabService: RememberedTabService, + ) : ViewModel() { + private val _state = MutableStateFlow(FavoritesPageState()) + val state: StateFlow<FavoritesPageState> = _state + + fun init() { + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + setupPossibleTypes() + + val favoriteTypeOptions = state.value.favoriteTypeOptions + if (favoriteTypeOptions.isNotEmpty()) { + val tabKey = + if (rememberTabs) { + val rememberedTab = + rememberedTabService + .getRememberedTab(NavDrawerItem.Favorites.id) + ?.let { + BaseItemKind.entries[it] + } + if (rememberedTab != null && rememberedTab in favoriteTypeOptions) { + rememberedTab + } else { + favoriteTypeOptions.first() + } + } else { + favoriteTypeOptions.first() + } + val jobs = + favoriteTypeOptions.associateWith { type -> initialFetchForType(type) } + initialFetchAndWait(tabKey, jobs) + } else { + Timber.w("No available types to check for favorites!") + _state.update { + it.copy(loadingState = FavoritesLoadingState.NoFavorites) + } + } + + userPreferencesService.flow + .map { it.appPreferences.interfacePreferences.showClock } + .collectLatestIn(viewModelScope) { isShowClock -> + _state.update { it.copy(isShowClock = isShowClock) } + } + } + } + + @VisibleForTesting + internal fun setupPossibleTypes() { + val availableTypes = + navDrawerService.state.value.allLibraries + .possibleFavoriteTypes(false) + .toSet() + val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes } + _state.update { + it.copy( + favoriteTypeOptions = favoriteTypeOptions, + ) + } + } + + /** + * Starts queries for favorites, waiting for [firstTabKey] to complete + * + * Exposed for testing + */ + internal suspend fun initialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + ) { + val completed = + jobs[firstTabKey]?.let { + when (val result = it.await()) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> false + + is DataLoadingState.Error, + -> true + + is DataLoadingState.Success<List<BaseItem?>> -> result.data.isNotEmpty() + } + } == true + if (completed) { + Timber.v("Got completed for %s", firstTabKey) + _state.update { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstTabKey, + ) + } + } else { + // If the first key wasn't successful, wait in order until first successful & non-empty + val favoriteTypeOptions = state.value.favoriteTypeOptions + val firstCompleted = + favoriteTypeOptions.firstOrNull { type -> + val job = jobs[type] ?: return@firstOrNull false + val result = job.await() + result is DataLoadingState.Error || + (result is DataLoadingState.Success<List<BaseItem?>> && result.data.isNotEmpty()) + } + _state.update { + if (firstCompleted != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstCompleted, + ) + } else { + // None were error or successful & non-empty + // Check if they were all empty which means the user has no favorites at all + val allEmpty = + jobs.all { (_, job) -> + val result = job.await() + result is DataLoadingState.Success<List<BaseItem?>> && result.data.isEmpty() + } + if (allEmpty) { + it.copy( + loadingState = FavoritesLoadingState.NoFavorites, + ) + } else { + // This means a least one failed + val firstError = + jobs.entries.firstOrNull { (_, job) -> + job.await() is DataLoadingState.Error + } + if (firstError != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstError.key, + ) + } else { + // This shouldn't happen + it.copy( + loadingState = FavoritesLoadingState.Error("An unknown error occurred"), + ) + } + } + } + } + } + } + + /** + * Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter + */ + private fun initialFetchForType(type: BaseItemKind): Deferred<DataLoadingState<List<BaseItem?>>> = + viewModelScope.async(WholphinDispatchers.IO) { + try { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + + val libraryDisplayInfo = + serverRepository.currentUser?.let { user -> + libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type)) + } + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true) + val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions + fetchType(type, sortAndDirection, filter, viewOptions) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching favorites for %s", type) + updateForException(type, ex) + DataLoadingState.Error(ex) + } + } + + /** + * Query for favorites of the specified [BaseItemKind] using the specified sort and filter + */ + @VisibleForTesting + internal suspend fun fetchType( + type: BaseItemKind, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, + viewOptions: ViewOptions, + ): DataLoadingState<List<BaseItem?>> { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + try { + val pager = createPager(type, filter, sortAndDirection) + _state.value.favorites[type] = + collectionState.copy( + item = DataLoadingState.Success(null), + items = DataLoadingState.Success(pager), + backgroundLoading = LoadingState.Success, + sortAndDirection = sortAndDirection, + filter = filter, + viewOptions = viewOptions, + ) + Timber.v("Got %s favorites for %s", pager.size, type) + _state.update { + val newTabs = + it.createMap { + if (pager.isNotEmpty()) { + put(type, TabDetails(formatTypeName(type))) + } else { + remove(type) + } + } + it.copy( + tabs = newTabs, + ) + } + return DataLoadingState.Success(pager) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + updateForException(type, ex) + return DataLoadingState.Error(ex) + } + } + + /** + * If fetching data for a type fails, this should be called to update the state with the error + */ + private fun updateForException( + type: BaseItemKind, + ex: Exception, + ) { + _state.update { + // Want to store the error for the tab + _state.value.favorites[type] = + CollectionFolderState( + item = DataLoadingState.Success(null), + items = DataLoadingState.Error(ex), + backgroundLoading = LoadingState.Success, + viewOptions = type.defaultViewOptions, + ) + // And make sure the tab is included + val newTabs = + it.createMap { + put(type, TabDetails(formatTypeName(type))) + } + it.copy(tabs = newTabs) + } + } + + private fun createGetItemsRequest( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetItemsRequest = + filter.applyTo( + GetItemsRequest( + isFavorite = true, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + overwriteIncludeTypes = false, + ) + + private fun createGetArtistsRequest( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetArtistsRequest = + filter.applyTo( + GetArtistsRequest( + isFavorite = true, + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + + private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest = + filter.applyTo( + GetPersonsRequest( + isFavorite = true, + fields = SlimItemFields, + ), + ) + + private suspend fun createPager( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): ApiRequestPager<*> = + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = createGetArtistsRequest(filter, sortAndDirection) + ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50) + } + + BaseItemKind.PERSON -> { + val request = createGetPersonsRequest(filter) + ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50) + } + + else -> { + val request = createGetItemsRequest(type, filter, sortAndDirection) + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50) + } + }.init() + + fun libraryDisplayItemId(type: BaseItemKind): String { + // Previous version used hardcoded keys before more types were supported + // So override those type names so that user's settings are maintained + val typeKey = + when (type) { + BaseItemKind.MOVIE -> "movies" + BaseItemKind.SERIES -> "series" + BaseItemKind.EPISODE -> "episodes" + BaseItemKind.VIDEO -> "videos" + BaseItemKind.PLAYLIST -> "playlists" + BaseItemKind.PERSON -> "people" + else -> type.serialName + } + return "${NavDrawerItem.Favorites.id}_$typeKey" + } + + private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type] + + fun updateSelectedTabKey(newKey: BaseItemKind) { + viewModelScope.launchDefault { backdropService.clearBackdrop() } + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + if (rememberTabs) { + rememberedTabService.saveRememberedTab( + NavDrawerItem.Favorites.id, + newKey.ordinal, + ) + } + } + _state.update { it.copy(tabKey = newKey) } + } + + private suspend fun refreshAfterMutate( + type: BaseItemKind, + position: Int, + itemId: UUID, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + pager?.refreshItem(position, itemId) + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after mutate %s", itemId) + showToast(context, "Error refreshing after item mutate") + } + } + + private suspend fun refreshAfterDelete( + type: BaseItemKind, + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id) + 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", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + private fun saveLibraryDisplayInfo( + type: BaseItemKind, + newFilter: GetItemsFilter, + newSort: SortAndDirection, + viewOptions: ViewOptions?, + ) { + serverRepository.currentUser?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = libraryDisplayItemId(type), + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + } + + private fun saveViewOptions( + type: BaseItemKind, + viewOptions: ViewOptions, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = collectionState.filter, + newSort = collectionState.sortAndDirection, + viewOptions = viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + viewOptions = viewOptions, + ) + if (!viewOptions.showBackdrop) { + backdropService.clearBackdrop() + } + } + } + } + + fun createTypedProvider(type: BaseItemKind) = TypedProvider(type) + + /** + * This provides context menu actions and other actions on a per [BaseItemKind] basis + */ + @Immutable + inner class TypedProvider( + val type: BaseItemKind, + ) : ContextMenuProvider, + CollectionFolderViewActions { + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination) + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(type, index, item) + } + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshAfterMutate(type, position, itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, favorite) + collectionStateFor(type)?.let { collectionState -> + // TODO use pager.refreshPagesAfter ?? + fetchType( + type, + collectionState.sortAndDirection, + collectionState.filter, + collectionState.viewOptions, + ) + } + } + } + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + + override fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + override fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = filter, + newSort = sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = filter, + sortAndDirection = sortAndDirection, + ) + fetchType(type, sortAndDirection, filter, collectionState.viewOptions) + } + } + } + + override fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = newFilter, + newSort = collectionState.sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = newFilter, + ) + fetchType( + type, + collectionState.sortAndDirection, + newFilter, + collectionState.viewOptions, + ) + } + } + } + + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + // TODO use includeItemTypes? + filterOptionCache.getFilterOptionValues( + null, + filterOption, + ) + + override suspend fun positionOfLetter(letter: Char): Int? = + collectionStateFor(type)?.let { collectionState -> + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = + createGetArtistsRequest( + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetArtistsHandler.execute(api, request).content.totalRecordCount + } + + BaseItemKind.PERSON -> { + null + } + + else -> { + val request = + createGetItemsRequest( + type, + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetItemsRequestHandler.execute(api, request).content.totalRecordCount + } + } + } + + override fun saveViewOptions(viewOptions: ViewOptions) { + saveViewOptions(type, viewOptions) + } + } + } + +data class FavoritesPageState( + val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending, + val favoriteTypeOptions: List<BaseItemKind> = emptyList(), + val favorites: SnapshotStateMap<BaseItemKind, CollectionFolderState> = SnapshotStateMap(), + val tabs: SortedMap<BaseItemKind, TabDetails> = TreeMap(compareBy { favoriteOptions.indexOf(it) }), + val tabKey: BaseItemKind = favoriteOptions.first(), + val isShowClock: Boolean = true, +) + +sealed interface FavoritesLoadingState { + data object Pending : FavoritesLoadingState + + data object Loading : FavoritesLoadingState + + data object Success : FavoritesLoadingState + + data object NoFavorites : FavoritesLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : FavoritesLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} + +/** + * List of supported favorite types + * + * This is also the order they will appear in the UI + */ +val favoriteOptions = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +/** + * Creates a new tab map from the current state applying the supplied block which can contain mutations + */ +private inline fun FavoritesPageState.createMap(block: TreeMap<BaseItemKind, TabDetails>.() -> Unit) = + TreeMap<BaseItemKind, TabDetails>(compareBy { favoriteOptions.indexOf(it) }) + .apply { + putAll(this@createMap.tabs) + block.invoke(this) + } + +/** + * Given the list of libraries, return the possible favorite types + */ +fun List<Library>.possibleFavoriteTypes(sort: Boolean) = + flatMap { it.collectionType.baseItemKinds } + .distinct() + .filter { it in favoriteOptions } + .let { list -> + if (sort) { + list.sortedWith(compareBy { favoriteOptions.indexOf(it) }) + } else { + list + } + } 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 383ed075..922ae3cc 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 @@ -15,10 +15,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions.Companion.genreDefault import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType data class HomeRowPresets( @@ -59,6 +61,48 @@ data class HomeRowPresets( -> HomeRowViewOptions() } + fun getByItemType(type: BaseItemKind): HomeRowViewOptions = + when (type) { + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> movieLibrary + + BaseItemKind.SERIES, + BaseItemKind.SEASON, + -> tvLibrary + + BaseItemKind.EPISODE -> continueWatching + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + -> videoLibrary + + BaseItemKind.PLAYLIST -> playlist + + BaseItemKind.PERSON -> movieLibrary + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.AUDIO, + -> musicLibrary + + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> liveTv + + BaseItemKind.GENRE, + BaseItemKind.MUSIC_GENRE, + BaseItemKind.STUDIO, + -> genreDefault.copy(heightDp = genreSize) + + else -> movieLibrary + } + companion object { val WholphinDefault by lazy { HomeRowPresets( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 72208d0d..96c7583c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -12,12 +12,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun HomeSettingsFavoriteList( + favoriteTypeOptions: List<BaseItemKind>, onClick: (BaseItemKind) -> Unit, modifier: Modifier = Modifier, firstFocus: FocusRequester = remember { FocusRequester() }, @@ -33,10 +35,10 @@ fun HomeSettingsFavoriteList( .fillMaxHeight() .focusRestorer(firstFocus), ) { - itemsIndexed(favoriteOptionsList) { index, type -> + itemsIndexed(favoriteTypeOptions) { index, type -> HomeSettingsListItem( selected = false, - headlineText = stringResource(favoriteOptions[type]!!), + headlineText = stringResource(formatTypeName(type)), onClick = { onClick.invoke(type) }, modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), ) @@ -44,17 +46,3 @@ fun HomeSettingsFavoriteList( } } } - -val favoriteOptions by lazy { - mapOf( - BaseItemKind.MOVIE to R.string.movies_title, - BaseItemKind.SERIES to R.string.tv_shows_title, - BaseItemKind.EPISODE to R.string.episodes, - BaseItemKind.VIDEO to R.string.videos, - BaseItemKind.PLAYLIST to R.string.playlists, - BaseItemKind.PERSON to R.string.people_title, - BaseItemKind.MUSIC_ARTIST to R.string.artists, - BaseItemKind.MUSIC_ALBUM to R.string.albums, - ) -} -val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } 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 e3bea235..5a2d6c78 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 @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.possibleFavoriteTypes import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType @@ -256,7 +257,10 @@ fun HomeSettingsPage( } HomeSettingsDestination.ChooseFavorite -> { + val favoriteTypeOptions = + remember { state.libraries.possibleFavoriteTypes(true) } HomeSettingsFavoriteList( + favoriteTypeOptions = favoriteTypeOptions, onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, 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 ff8099c7..6faf855b 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 @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.getRecentlyAddedTitle import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast @@ -415,9 +416,13 @@ class HomeSettingsViewModel title = ResProviderStringProvider( R.string.favorite_items_title, - ResStringProvider(favoriteOptions[type]!!), + ResStringProvider(formatTypeName(type)), + ), + config = + HomeRowConfig.Favorite( + kind = type, + viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type), ), - config = HomeRowConfig.Favorite(type), ) updateState { it.copy( @@ -709,17 +714,7 @@ class HomeSettingsViewModel } is HomeRowConfig.Favorite -> { - val viewOptions = - when (it.config.kind) { - BaseItemKind.MOVIE -> preset.movieLibrary - BaseItemKind.SERIES -> preset.tvLibrary - BaseItemKind.EPISODE -> preset.continueWatching - BaseItemKind.VIDEO -> preset.videoLibrary - BaseItemKind.PLAYLIST -> preset.playlist - BaseItemKind.PERSON -> preset.movieLibrary - BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary - else -> preset.movieLibrary - } + val viewOptions = preset.getByItemType(it.config.kind) it.config.updateViewOptions(viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt index d0c55c05..b9f8a9de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -99,16 +99,10 @@ class SearchViewModel private fun init() { viewModelScope.launchDefault { - val tvAccess = serverRepository.currentUserDto?.tvAccess == true userLibraryTypes = - serverRepository.currentUser - ?.id - ?.let { userId -> - navDrawerService - .getAllUserLibraries(userId, tvAccess) - .flatMap { it.collectionType.baseItemKinds } - .toSet() - }.orEmpty() + navDrawerService.state.value.allLibraries + .flatMap { it.collectionType.baseItemKinds } + .toSet() val excludedSearchableTypes = serverRepository.currentUser?.id?.let { userId -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfb3caac..549614c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -914,4 +914,5 @@ <string name="dim_percentage">Dim percentage</string> <string name="dim_screen">Dim screen</string> <string name="photo_albums">Photo albums</string> + <string name="no_favorites_found">No favorites found!</string> </resources> diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 21841c25..7f51e994 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -80,6 +80,23 @@ fun playlist( genreItems = genres, ) +/** + * Create a simple [BaseItemDto] of the specified type + */ +fun item( + type: BaseItemKind, + id: UUID = UUID.randomUUID(), + name: String = "Test $type", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = type, + name = name, + seriesId = null, + genreItems = genres, + ) + fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") fun user( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt new file mode 100644 index 00000000..6066cae4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt @@ -0,0 +1,427 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavDrawerItemState +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.item +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.util.DataLoadingState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.operations.ArtistsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.PersonsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestFavoritesViewModel { + private val context: Context = mockk() + private val api: ApiClient = mockk() + private val navigationManager: NavigationManager = mockk() + private val serverRepository: ServerRepository = mockk() + private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk() + private val favoriteWatchManager: FavoriteWatchManager = mockk() + private val backdropService: BackdropService = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + private val mediaManagementService: MediaManagementService = mockk() + private val streamChoiceService: StreamChoiceService = mockk() + private val mediaReportService: MediaReportService = mockk() + private val filterOptionCache: FilterOptionCache = mockk() + private val rememberedTabService: RememberedTabService = mockk() + private val navDrawerService: NavDrawerService = mockk() + + private val itemsApi: ItemsApi = mockk() + private val artistsApi: ArtistsApi = mockk() + private val personsApi: PersonsApi = mockk() + + @Before + fun setup() { + every { api.itemsApi } returns itemsApi + every { api.artistsApi } returns artistsApi + every { api.personsApi } returns personsApi + every { navDrawerService.state } returns + MutableStateFlow( + NavDrawerItemState( + allLibraries = + listOf( + library(CollectionType.MOVIES), + library(CollectionType.TVSHOWS), + library(CollectionType.BOXSETS), + ), + ), + ) + } + + private fun createViewModel(): FavoritesViewModel = + FavoritesViewModel( + context = context, + api = api, + navigationManager = navigationManager, + serverRepository = serverRepository, + libraryDisplayInfoDao = libraryDisplayInfoDao, + favoriteWatchManager = favoriteWatchManager, + backdropService = backdropService, + userPreferencesService = userPreferencesService, + mediaManagementService = mediaManagementService, + streamChoiceService = streamChoiceService, + mediaReportService = mediaReportService, + filterOptionCache = filterOptionCache, + rememberedTabService = rememberedTabService, + navDrawerService = navDrawerService, + ).apply { + setupPossibleTypes() + } + + private val items = listOf(BaseItem(movie())) + + private val empty = emptyList<BaseItem>() + + private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items)) + + private fun errorJob() = CompletableDeferred(DataLoadingState.Error()) + + private suspend fun testInitialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + asserts: (FavoritesPageState) -> Unit, + ) { + createViewModel().let { viewModel -> + viewModel.initialFetchAndWait( + firstTabKey = firstTabKey, + jobs = jobs, + ) + viewModel.state.value.let { state -> + asserts.invoke(state) + } + } + } + + @Test + fun `Test tabKey for first success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(items), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for second success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for first failure`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first error`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(items), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.BOX_SET, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + } + + @Test + fun `Test fetchType basic`() = + runTest { + val movie = movie() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with error`() = + runTest { + val ex = Exception() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Error) + assertEquals(ex, (data as DataLoadingState.Error).exception) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Error) + assertEquals(ex, (st.items as DataLoadingState.Error).exception) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with empty result does not add tab`() = + runTest { + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult() + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(0, (data as DataLoadingState.Success).data.size) + viewModel.state.value.let { state -> + assertFalse(BaseItemKind.MOVIE in state.tabs) + + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + assertEquals(0, (st.items as DataLoadingState.Success).data.size) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType for artists`() = + runTest { + val item = item(BaseItemKind.MUSIC_ARTIST) + coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MUSIC_ARTIST, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs) + assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites) + state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) } + } + + @Test + fun `Test fetchType for persons`() = + runTest { + val item = item(BaseItemKind.PERSON) + coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.PERSON, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.PERSON in state.tabs) + assertTrue(BaseItemKind.PERSON in state.favorites) + state.favorites[BaseItemKind.PERSON]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) } + } +} + +private fun library(type: CollectionType) = + Library( + itemId = UUID.randomUUID(), + name = "name-${type.serialName}", + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = type, + isRecordingFolder = false, + ) From cc2e7dfdda5b62dbd2b50aa473e75c83865af8bc Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 29 Aug 2026 16:07:09 -0400 Subject: [PATCH 405/475] Fix image loading issues related to VPN and/or Fire OS 6 (#1910) ## Description This PR override Coil's built-in network connectivity checker with one that allows returns true. The built-in one seems to be unreliable on devices running Fire OS 6 (maybe other versions too) and/or devices using a VPN such as Tailscale. See more details here: https://github.com/damontecres/Wholphin/issues/1800#issuecomment-5432941761 ### Related issues Should fix #1156 Should fix #1751 Should fix #1800 ### Testing Unfortunately, I've never been able to reproduce the original issue on any of my devices, but hopefully someone who is affected can try this branch out! This change doesn't break anything on some emulators (tried Android 7.0 & Android TV 14) or NVIDIA shield ## Screenshots N/A ## AI or LLM usage None ## Try it See instructions here: https://github.com/damontecres/Wholphin/releases/tag/develop-image-fix --- .../main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index e2c4f976..703182ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -9,6 +9,7 @@ import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache import coil3.network.CacheStrategy +import coil3.network.ConnectivityChecker import coil3.network.NetworkRequest import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy @@ -95,6 +96,8 @@ fun CoilConfig( OkHttpNetworkFetcherFactory( cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, callFactory = { client }, + // Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android + connectivityChecker = { ConnectivityChecker { true } }, ), ) }.build() From 5363db0b80387ffb87037f4e7cfe580bf7aa3031 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 29 Aug 2026 21:35:58 -0400 Subject: [PATCH 406/475] Update API level to Android 17 (#1908) ## Description Updates the compile & target SDKs to 37 (Android 17). The only required change is to support the new `ACCESS_LOCAL_NETWORK` permission. This is required for Android 17 devices to auto discover Jellyfin servers on the local network. Wholphin will prompt for permission and if granted, use the discover servers feature. If permission is not granted, the user must enter the server URL manually. ### Related issues N/A, but several dependency renovate PRs are blocked by this ### Testing I tested with a regular Android 17 emulator. There are no Android TV 17 images (or probably even devices) available yet. ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 8 ++++-- app/src/main/AndroidManifest.xml | 1 + .../wholphin/ui/setup/SwitchServerContent.kt | 28 ++++++++++++++++++- .../ui/setup/SwitchServerViewModel.kt | 15 ++++++++++ .../wholphin/services/IntentServiceTest.kt | 2 ++ .../ui/playback/PlaybackViewModelTests.kt | 2 ++ gradle/libs.versions.toml | 4 +++ wholphin-mpv-stub/build.gradle.kts | 6 ++-- 8 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8046a68..e005cb6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,14 +65,16 @@ kotlin { } } +private fun Provider<String>.getInt() = get().toInt() + configure<ApplicationExtension> { namespace = "com.github.damontecres.wholphin" - compileSdk = 36 + compileSdk = libs.versions.compileSdk.getInt() defaultConfig { applicationId = "com.github.damontecres.wholphin" - minSdk = 23 - targetSdk = 36 + minSdk = libs.versions.minSdk.getInt() + targetSdk = libs.versions.targetSdk.getInt() versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6036dabd..582e000a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ android:installLocation="auto"> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> <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" /> 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 24a8e0ca..c25fe776 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 @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest import android.content.res.Resources +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -67,6 +71,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import timber.log.Timber @Composable fun SwitchServerContent( @@ -294,10 +299,31 @@ private fun AddServerDialog( val state by viewModel.state.collectAsState() var showEnterAddress by remember { mutableStateOf(false) } + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + viewModel.discoverServers() + } else { + showEnterAddress = true + } + } + LaunchedEffect(Unit) { viewModel.clearAddServerState() if (!showEnterAddress) { - viewModel.discoverServers() + if (viewModel.hasPermission) { + viewModel.discoverServers() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } else { + Timber.w( + "No ACCESS_LOCAL_NETWORK permission, but API <%s: %s", + Build.VERSION_CODES.CINNAMON_BUN, + Build.VERSION.SDK_INT, + ) + } } } 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 7098c118..53190665 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 @@ -1,7 +1,11 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -46,6 +50,17 @@ class SwitchServerViewModel private val _state = MutableStateFlow(SwitchServerState()) val state: StateFlow<SwitchServerState> = _state + val hasPermission: Boolean + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_LOCAL_NETWORK, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + fun clearAddServerState() { _state.update { it.copy(addServerState = LoadingState.Pending) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt index 17c6a313..bb4904aa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -25,8 +25,10 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) class IntentServiceTest { private val api: ApiClient = mockk() private val serverRepository: ServerRepository = mockk() diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index adb29999..cf7ee59f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -77,10 +77,12 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) class PlaybackViewModelTests { private val testDispatcher = StandardTestDispatcher() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2dbe114c..ff09b53d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,10 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" material = "1.14.0" +compileSdk = "37" +targetSdk = "37" +minSdk = "23" + [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } wholphin-extensions-ffmpeg = { module = "com.github.damontecres.wholphin.media3:decoder-ffmpeg", version.ref = "wholphin-extensions" } diff --git a/wholphin-mpv-stub/build.gradle.kts b/wholphin-mpv-stub/build.gradle.kts index 6002fba9..9017f416 100644 --- a/wholphin-mpv-stub/build.gradle.kts +++ b/wholphin-mpv-stub/build.gradle.kts @@ -2,14 +2,16 @@ plugins { alias(libs.plugins.android.library) } +private fun Provider<String>.getInt() = get().toInt() + android { namespace = "com.github.damontecres.wholphin.mpv" compileSdk { - version = release(36) + version = release(libs.versions.compileSdk.getInt()) } defaultConfig { - minSdk = 23 + minSdk = libs.versions.minSdk.getInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") From cedca9b2b1913697c9fcc4a1b091d2a8c2317813 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 1 Sep 2026 16:07:26 -0400 Subject: [PATCH 407/475] Add button to navigate to random item (#1916) ## Description Adds a random button on library tab/grid pages which navigates to a random item in the list. This goes to the item's detail page. The random item is chosen with the filters applied. So you could filter by unplayed Action movies with 7+ stars and the random button will select from those movies. Pressing back restores focus on the random button so it's easy to quickly retry. ### Related issues Closes #838 ### Testing Emulator ## Screenshots <img width="935" height="105" alt="image" src="https://github.com/user-attachments/assets/170b495c-b47d-41ce-93b5-99831f2064c7" /> <img width="936" height="114" alt="image" src="https://github.com/user-attachments/assets/3efa6a11-ebe8-42d7-b023-354b1e6eda37" /> ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 102 +++++++++++------- .../ui/components/CollectionFolderView.kt | 39 ++++++- .../wholphin/ui/detail/FavoritesViewModel.kt | 22 ++++ .../damontecres/wholphin/util/BlockingList.kt | 6 ++ app/src/main/res/values/fa_strings.xml | 4 + 5 files changed, 134 insertions(+), 39 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 1df5ee90..fc6d132a 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,7 +23,6 @@ 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.focus.focusRestorer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec @@ -288,6 +289,7 @@ data class GridClickActions( @Composable fun CollectionFolderHeader( + listIsNotEmpty: Boolean, showHeader: Boolean, showTitle: Boolean, playEnabled: Boolean, @@ -298,12 +300,14 @@ fun CollectionFolderHeader( getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, onClickShowViewOptions: () -> Unit, onClickPlayAll: (Boolean) -> Unit, + onClickRandom: () -> Unit, modifier: Modifier = Modifier, currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List<ItemFilterBy<*>> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, onShowFilterDropdown: ((Boolean) -> Unit)? = null, filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, + randomButtonFocusRequester: FocusRequester = remember { FocusRequester() }, ) { AnimatedVisibility( showHeader, @@ -311,6 +315,7 @@ fun CollectionFolderHeader( exit = shrinkVertically(), modifier = modifier, ) { + val focusRequester = remember { FocusRequester() } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), @@ -320,63 +325,84 @@ fun CollectionFolderHeader( } val endPadding = 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(start = 16.dp, end = endPadding) - .focusRestorer() .fillMaxWidth(), ) { - if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.focusRestorer(), - ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - if (filterOptions.isNotEmpty()) { - FilterByButton( - filterOptions = filterOptions, - current = currentFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier.focusRequester(filterButtonFocusRequester), - onShow = onShowFilterDropdown, - ) - } - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = onClickShowViewOptions, - modifier = Modifier, + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier.focusRequester(focusRequester), ) } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = + Modifier + .focusRequester(filterButtonFocusRequester) + .ifElse( + sortOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + onShow = onShowFilterDropdown, + ) + } + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickShowViewOptions, + modifier = + Modifier.ifElse( + sortOptions.isEmpty() && filterOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + ) } - if (playEnabled) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.focusRestorer(), - ) { + + Spacer(Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandableFaButton( + title = R.string.sort_by_random, + iconStringRes = R.string.fa_dice, + onClick = onClickRandom, + modifier = Modifier.focusRequester(randomButtonFocusRequester), + enabled = listIsNotEmpty, + ) + + if (playEnabled) { ExpandablePlayButton( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, onClick = { onClickPlayAll.invoke(false) }, + enabled = listIsNotEmpty, ) ExpandableFaButton( title = R.string.shuffle, iconStringRes = R.string.fa_shuffle, onClick = { onClickPlayAll.invoke(true) }, + enabled = listIsNotEmpty, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index c1566b61..732c531d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -65,6 +65,7 @@ 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.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetArtistsHandler @@ -78,6 +79,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -264,6 +266,24 @@ class CollectionFolderViewModel } } + override fun onClickRandom() { + viewModelScope.launchIO { + try { + val random = + (state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } + override fun onFilterChange( newFilter: GetItemsFilter, recursive: Boolean, @@ -767,6 +787,11 @@ interface CollectionFolderViewActions { suspend fun positionOfLetter(letter: Char): Int? fun saveViewOptions(viewOptions: ViewOptions) + + /** + * Navigate to a random item + */ + fun onClickRandom() } @Composable @@ -791,11 +816,15 @@ fun CollectionFolderViewContent( ) { var position by rememberInt(savedPosition) + // Track if leaving page due to clicking the random button so focus can be restored there + var clickedRandom by rememberSaveable { mutableStateOf(false) } + val contextMenu = rememberContextMenu(preferences, provider) var showViewOptions by rememberSaveable { mutableStateOf(false) } var filterDropdownShowing by remember { mutableStateOf(false) } val headerRowFocusRequester = remember { FocusRequester() } val filterButtonFocusRequester = remember { FocusRequester() } + val randomButtonFocusRequester = remember { FocusRequester() } val gridActions = remember(actions) { @@ -889,9 +918,10 @@ fun CollectionFolderViewContent( } } CollectionFolderHeader( + listIsNotEmpty = state.items.successValue?.isNotEmpty() == true, showHeader = showHeader || state.items !is DataLoadingState.Success, showTitle = showTitle, - playEnabled = playEnabled && state.items.successValue?.isNotEmpty() == true, + playEnabled = playEnabled, title = title, sortAndDirection = state.sortAndDirection, onSortChange = { @@ -908,9 +938,14 @@ fun CollectionFolderViewContent( filterOptions = filterOptions, onClickPlayAll = gridActions.onClickPlayAll!!, onClickShowViewOptions = { showViewOptions = true }, + onClickRandom = { + clickedRandom = true + viewActions.onClickRandom() + }, modifier = Modifier.focusRequester(headerRowFocusRequester), onShowFilterDropdown = { filterDropdownShowing = it }, filterButtonFocusRequester = filterButtonFocusRequester, + randomButtonFocusRequester = randomButtonFocusRequester, ) when (val pager = state.items) { @@ -933,10 +968,12 @@ fun CollectionFolderViewContent( when { contextMenu.isShowing -> null filterDropdownShowing -> filterButtonFocusRequester + clickedRandom -> randomButtonFocusRequester pager.data.isNotEmpty() -> gridFocusRequester else -> focusRequesterOnEmpty ?: headerRowFocusRequester } focusRequester?.tryRequestFocus() + clickedRandom = false } Box(Modifier.fillMaxSize()) { if (state.viewOptions.type == ViewOptionsType.GRID) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt index 734ac3d0..f67b322a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -44,6 +44,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetArtistsHandler @@ -51,6 +52,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException @@ -693,6 +695,26 @@ class FavoritesViewModel override fun saveViewOptions(viewOptions: ViewOptions) { saveViewOptions(type, viewOptions) } + + override fun onClickRandom() { + viewModelScope.launchIO { + try { + collectionStateFor(type)?.let { collectionState -> + val random = + (collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt index 08207e53..80272719 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.util import java.util.function.IntFunction import java.util.function.Predicate +import kotlin.random.Random /** * A [List] which has function that will wait for a result @@ -17,6 +18,11 @@ interface BlockingList<T> : List<T> { */ suspend fun indexOfBlocking(predicate: Predicate<T>): Int + suspend fun randomBlocking(): T { + val index = Random.nextInt(size) + return getBlocking(index) + } + companion object { /** * Create a [BlockingList] over a regular [List] diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 7e13f584..61c7af06 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -1,5 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <!-- Names should be prefixed with 'fa_' and use the icon name from FontAwesome --> + <!-- Uses v6 of FontAwesome: https://fontawesome.com/v6/icons/classic/solid/<name> --> + <!-- Value is '&#x' then the unicode value, then semicolon --> <string name="fa_tag" translatable="false"></string> <string name="fa_user" translatable="false"></string> <string name="fa_location_dot" translatable="false"></string> @@ -57,4 +60,5 @@ <string name="fa_repeat" translatable="false"></string> <string name="fa_compact_disc" translatable="false"></string> <string name="fa_xmark" translatable="false"></string> + <string name="fa_dice" translatable="false"></string> </resources> From 6fbf6209a1bc799f98844852810bf1dd43d2e47e Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 1 Sep 2026 17:52:47 -0400 Subject: [PATCH 408/475] Fixes to default home view options for music & videos (#1926) ## Description This PR fixes how cards are displayed initially when creating the default home page or loading it from the web config: - Ensure view options are selected based on the library type (this is how it's done already when manually adding a row) - Support more home row types such as photos and music ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/1838 ### Testing Emulator, resetting to default and loading from web config ## Screenshots N/A, music cards will be square instead of poster sized ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 81 +++++++++++++++---- .../ui/main/settings/HomeSettingsViewModel.kt | 21 +---- .../damontecres/wholphin/util/Constants.kt | 9 +-- 3 files changed, 68 insertions(+), 43 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 ff7d7411..e7b5db00 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,11 +7,14 @@ import com.github.damontecres.wholphin.data.ServerRepository 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.HomeRowViewOptions 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.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.HomeItemFields import com.github.damontecres.wholphin.ui.ProgramItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap @@ -36,7 +39,6 @@ import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success -import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +57,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind @@ -280,20 +281,25 @@ class HomeSettingsService val includedIds = libraries - .mapIndexed { index, it -> - val parentId = it.itemId - val title = getRecentlyAddedTitle(it.name) - if (it.collectionType == CollectionType.LIVETV) { + .mapIndexed { index, library -> + val parentId = library.itemId + val title = getRecentlyAddedTitle(library.name) + if (library.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { + val viewOptions = viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = index, title = title, - config = HomeRowConfig.RecentlyAdded(parentId), + config = + HomeRowConfig.RecentlyAdded( + parentId = parentId, + viewOptions = viewOptions, + ), ) } } @@ -323,12 +329,10 @@ class HomeSettingsService val userDto by api.userApi.getUserById(userId) val config = userDto.configuration ?: DefaultUserConfiguration val libraries = - api.userViewsApi - .getUserViews(userId = userId) - .content.items - .filter { - it.collectionType in supportedHomeCollectionTypes && - it.id !in config.latestItemsExcludes + navDrawerService + .getAllUserLibraries(userId, userDto.tvAccess) + .filterNot { + it.itemId in config.latestItemsExcludes } return if (customPrefs.isNotEmpty()) { @@ -401,15 +405,21 @@ class HomeSettingsService } } if (sectionType == HomeSectionType.LATEST_MEDIA) { - libraries.map { + libraries.map { library -> + val viewOptions = + viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = id++, title = ResArgStringProvider( R.string.recently_added_in, - it.name ?: "", + library.name ?: "", + ), + config = + HomeRowConfig.RecentlyAdded( + parentId = library.itemId, + viewOptions = viewOptions, ), - config = HomeRowConfig.RecentlyAdded(it.id), ) } } else if (config != null) { @@ -1261,3 +1271,42 @@ private val Library?.itemFields: List<ItemFields> } private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) + +fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = + when (collectionType) { + CollectionType.MUSIC, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + ) + } + + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + CollectionType.MUSICVIDEOS, + CollectionType.TRAILERS, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } + + CollectionType.LIVETV, + -> { + HomeRowViewOptions.liveTvDefault + } + + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.BOXSETS, + CollectionType.BOOKS, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + CollectionType.UNKNOWN, + null, + -> { + HomeRowViewOptions() + } + } 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 6faf855b..64c0308e 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 @@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.getRecentlyAddedTitle import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess -import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.services.viewOptionsForCollectionType import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -275,24 +275,7 @@ class HomeSettingsViewModel rowType: LibraryRowType, ): Job = viewModelScope.launchIO { - val viewOptions = - when (library.collectionType) { - CollectionType.MUSIC -> { - HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE) - } - - CollectionType.HOMEVIDEOS, - CollectionType.MUSICVIDEOS, - -> { - HomeRowViewOptions( - aspectRatio = AspectRatio.WIDE, - ) - } - - else -> { - HomeRowViewOptions() - } - } + val viewOptions = viewOptionsForCollectionType(library.collectionType) val id = idCounter++ val newRow = when (rowType) { 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 531a4f9a..f2078766 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 @@ -33,14 +33,7 @@ val supportedCollectionTypes = CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, CollectionType.MUSIC, - null, // Mixed - ) - -val supportedHomeCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, null, // Mixed ) From 826bbad49c694f1e0515520f14eb9918bb2d7bd9 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 2 Sep 2026 02:50:42 -0400 Subject: [PATCH 409/475] Fixes to Now Playing page (#1915) ## Description Fixes several issues on the Now Playing page - Keep controller focus after lyrics are focused - Fix the queue not visually updating until recomposition - The first focused queue shown/focused is now what's currently playing ### Related issues Fixes #1821 ### Testing Emulator ## Screenshots N/A, just behavior fixes ## AI or LLM usage None --- .../ui/detail/music/NowPlayingOverlay.kt | 42 ++++++++++++++++--- .../ui/detail/music/NowPlayingPage.kt | 20 ++++----- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index a340f66c..8d12bb33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn 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.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -48,6 +50,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -65,7 +68,6 @@ fun NowPlayingOverlay( state: NowPlayingState, player: Player, current: AudioItem?, - queue: List<AudioItem>, controllerViewState: ControllerViewState, onClickSong: (Int, AudioItem) -> Unit, onLongClickSong: (Int, AudioItem) -> Unit, @@ -78,6 +80,7 @@ fun NowPlayingOverlay( ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val currentItemFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } var queueHasFocus by remember { mutableStateOf(false) } @@ -89,13 +92,16 @@ fun NowPlayingOverlay( }, animationSpec = tween(durationMillis = 500), ) - val listState = rememberLazyListState() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = state.musicServiceState.currentIndex, + ) var showButtons by remember { mutableStateOf(true) } val firstFocusRequester = remember { FocusRequester() } BackHandler(!showButtons) { scope.launch { - listState.animateScrollToItem(0) + listState.scrollToItem(0) firstFocusRequester.tryRequestFocus() } } @@ -141,6 +147,12 @@ fun NowPlayingOverlay( .align(Alignment.CenterHorizontally), ) } + val queue = + rememberQueue( + player, + state.musicServiceState.queueVersion, + state.musicServiceState.queueSize, + ) if (queue.isEmpty()) { Text("No items") } else { @@ -158,12 +170,16 @@ fun NowPlayingOverlay( .onFocusChanged { queueHasFocus = it.hasFocus }.focusProperties { + onEnter = { + currentItemFocusRequester.tryRequestFocus() + } onExit = { if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus() } }, ) { itemsIndexed(queue, key = { _, song -> song.key }) { index, song -> + val bringIntoViewRequester = remember { BringIntoViewRequester() } Row( verticalAlignment = Alignment.CenterVertically, modifier = @@ -176,7 +192,8 @@ fun NowPlayingOverlay( .onFocusChanged { if (it.hasFocus) showButtons = index < 3 controllerViewState.pulseControls() - }.animateItem(), + }.animateItem() + .bringIntoViewRequester(bringIntoViewRequester), ) { SongListItem( title = song.title, @@ -193,6 +210,9 @@ fun NowPlayingOverlay( .ifElse( index == 0, Modifier.focusRequester(firstFocusRequester), + ).ifElse( + index == state.musicServiceState.currentIndex, + Modifier.focusRequester(currentItemFocusRequester), ), ) Row( @@ -203,12 +223,22 @@ fun NowPlayingOverlay( MoveButton( icon = R.string.fa_caret_up, enabled = index > 0, - onClick = { onMoveQueue.invoke(index, MoveDirection.UP) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.UP) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) MoveButton( icon = R.string.fa_caret_down, enabled = index < queue.lastIndex, - onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.DOWN) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) Button( onClick = { onClickMoreItem.invoke(index, song) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 30cd20ad..1c4cb71f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberCurrentMediaItemState import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -60,7 +61,6 @@ import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -91,13 +91,8 @@ fun NowPlayingPage( val context = LocalContext.current val state by viewModel.state.collectAsState() val player = viewModel.player - val queue = - rememberQueue( - player, - state.musicServiceState.queueVersion, - state.musicServiceState.queueSize, - ) - val current = queue.getOrNull(state.musicServiceState.currentIndex) + val currentMediaItem = rememberCurrentMediaItemState(player) + val current = (currentMediaItem.mediaItem?.localConfiguration?.tag as? AudioItem) val viz by viewModel.viz.collectAsState() val controllerViewState = viewModel.controllerViewState @@ -152,10 +147,12 @@ fun NowPlayingPage( LaunchedEffect(lyricsFocused) { if (lyricsFocused != null) { controllerViewState.hideControls() + delay(5.seconds) + if (!controllerViewState.controlsVisible) { + focusRequester.tryRequestFocus() + } + lyricsFocused = null } - delay(5.seconds) - lyricsFocused = null - focusRequester.tryRequestFocus() } BackHandler(lyricsFocused != null) { focusRequester.tryRequestFocus() @@ -351,7 +348,6 @@ fun NowPlayingPage( state = state, player = player, current = current, - queue = queue, controllerViewState = controllerViewState, onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, onClickMore = { showViewOptionsDialog = true }, From 1f3a861c47047be035f042588e01f5aeca8d0771 Mon Sep 17 00:00:00 2001 From: Tal Sarid <85511949+tal-sarid@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:47:06 +0300 Subject: [PATCH 410/475] Localized formatting for season and episode display (#1884) ## Description Added localized formatting for season/episode numbers across the UI because it was in English-only in every UI language. ### Related issues None ### Testing Tested on a physical Google TV Streamer 4K device. Checked manually relevant screens while browsing and during playback. ## Screenshots No frontend changes are visible in English and new translations will be contributed through Weblate for other languages later, as usual. ## AI or LLM usage Generated initial code with AI, then manually passed over it while cleaning and refactoring where needed. --------- Co-authored-by: Damontecres <damontecres@gmail.com> --- .../wholphin/data/model/BaseItem.kt | 5 +- .../damontecres/wholphin/ui/Formatting.kt | 81 ++++++++++++++----- .../ui/detail/discover/RequestSeasons.kt | 4 +- .../wholphin/ui/detail/livetv/Components.kt | 10 ++- .../ui/detail/livetv/LiveTvViewModel.kt | 3 +- .../ui/detail/series/SeriesOverviewContent.kt | 5 +- app/src/main/res/values/strings.xml | 7 ++ 7 files changed, 85 insertions(+), 30 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 50df6071..dd1236a4 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatEpisodeNumber import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -111,7 +112,7 @@ data class BaseItem( val ui = BaseItemUi( episodeCornerText = - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = if (type == BaseItemKind.SERIES || @@ -119,7 +120,7 @@ data class BaseItem( type == BaseItemKind.EPISODE || type == BaseItemKind.BOX_SET ) { - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.userData ?.unplayedItemCount ?.takeIf { it > 0 } 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 4b2f0b8b..282c161e 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 @@ -79,36 +79,74 @@ fun toLocalDate(date: String?): LocalDate? = } } +// Falls back to the default format if the application is not initialized (e.g. unit tests) +private fun formatStringWithFallback( + @StringRes resId: Int, + defaultFormat: String, + vararg args: Any, +): String { + val resources = + try { + WholphinApplication.instance.resources + } catch (_: UninitializedPropertyAccessException) { + null + } + return resources?.getString(resId, *args) + ?: String.format(Locale.getDefault(), defaultFormat, *args) +} + /** - * If the item has season & episode info, format as `S# E#` + * Format season & episode numbers using localized string resources. + * Returns null if [season] or [episode] is missing. + */ +fun formatSeasonEpisode( + season: Int?, + episode: Int?, + episodeEnd: Int? = null, + padded: Boolean = false, +): String? { + if (season == null || episode == null) return null + return if (episodeEnd != null) { + if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd) + } else { + formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd) + } + } else if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode) + } else { + formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode) + } +} + +/** Compact episode-only label, e.g. `E3`. */ +fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode) + +/** Fallback season title when the library has no season name, e.g. `Season 2`. */ +fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season) + +/** + * If the item has season & episode info, format as localized `S# E#` */ val BaseItemDto.seasonEpisode: String? get() = - if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { - "S$parentIndexNumber E$indexNumber-E$indexNumberEnd" - } else if (parentIndexNumber != null && indexNumber != null) { - "S$parentIndexNumber E$indexNumber" - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + ) /** - * If the item has season & episode info, format padded as `S## E##` + * If the item has season & episode info, format padded as localized `S##E##` */ val BaseItemDto.seasonEpisodePadded: String? get() = - if (parentIndexNumber != null && indexNumber != null) { - val season = parentIndexNumber?.toString()?.padStart(2, '0') - val episode = indexNumber?.toString()?.padStart(2, '0') - val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0') - if (endEpisode != null) { - "S${season}E$episode-E$endEpisode" - } else { - "S${season}E$episode" - } - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + padded = true, + ) val BaseItemDto.seriesProductionYears: String? get() = @@ -274,6 +312,7 @@ fun Resources.formatDuration(duration: Duration): String = when { hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes) + hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds) hours > 0 -> getString(R.string.duration_hours, hours.toInt()) minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) minutes > 0 -> getString(R.string.duration_minutes, minutes) 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 index c482484e..bc11825f 100644 --- 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 @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.PendingIndicator import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -277,7 +278,8 @@ fun SeasonListItem( Text( text = season.season.name - ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } + ?: "", ) }, supportingContent = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 41d53fd5..e5bf4e8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -29,6 +29,7 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import java.time.LocalDateTime @Composable @@ -109,10 +110,15 @@ fun Program( overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + val seasonEpisodeLabel = + formatSeasonEpisode( + program.seasonEpisode?.season, + program.seasonEpisode?.episode, + ) val subtitle = - remember(program) { + remember(program, seasonEpisodeLabel) { listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + seasonEpisodeLabel, program.subtitle, ).joinToString(" - ").ifBlank { null } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 987aeebb..470f33c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -636,7 +637,7 @@ data class TvProgram( .roundMinutes .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } } - seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + formatSeasonEpisode(seasonEpisode?.season, seasonEpisode?.episode)?.let { dot() append(it) } 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 a3ca1358..5fee7b24 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 @@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams @@ -60,6 +59,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -121,7 +121,6 @@ fun SeriesOverviewContent( val scrollConnection = rememberDelayedNestedScroll() var requestFocusAfterSeason by remember { mutableStateOf(false) } - val seasonStr = stringResource(R.string.tv_season) val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabs = @@ -130,7 +129,7 @@ fun SeriesOverviewContent( title = StringStringProvider( season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } + ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) } ?: "", ), tabFocusRequester = tabFocusRequesters[index], diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 549614c7..e8ae0187 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ <string name="decimal_seconds">%.2f seconds</string> <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> <string name="duration_minutes_seconds">%1$dm %2$ds</string> <string name="duration_hours">%1$dh</string> <string name="duration_minutes">%1$dm</string> @@ -158,6 +159,12 @@ <string name="tv_guide">Guide</string> <string name="tv_season">Season</string> <string name="tv_seasons">Seasons</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Season %1$d</string> <string name="tv_shows_title">TV Shows</string> <string name="ui_interface">Interface</string> <string name="unknown">Unknown</string> From 39239dd44467314b11ea04163b1c54b7c707bddf Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 5 Sep 2026 12:11:30 -0400 Subject: [PATCH 411/475] Add wrap around scrolling to settings pages (#1937) ## Description Add scrolling from top to bottom and vice versa. This makes it quicker to get to Advanced Settings for example. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/preferences/Extensions.kt | 59 +++++++++++++++++++ .../wholphin/ui/components/Dialogs.kt | 32 ++-------- .../ui/preferences/PreferencesContent.kt | 31 +++++++--- .../subtitle/SubtitlePreferencesContent.kt | 29 +++++++-- 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt index 0000878b..478bffb6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt @@ -1,5 +1,19 @@ package com.github.damontecres.wholphin.preferences +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration? } else { null } + +/** + * Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa + */ +@Composable +fun Modifier.lazyListWrapScrolling( + state: LazyListState, + focused: Boolean, + isFirst: Boolean, + isLast: Boolean, + firstFocusRequester: FocusRequester, + lastFocusRequester: FocusRequester, +): Modifier { + val scope = rememberCoroutineScope() + return this + .ifElse( + isFirst, + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + isLast, + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ) +} 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 f1091839..d7bd9951 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 @@ -63,15 +63,12 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.formatBitrate -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.roundMinutes -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -322,30 +319,13 @@ fun DialogPopupContent( modifier = Modifier .focusRequester(focusRequesters[index]) - .ifElse( + .lazyListWrapScrolling( + listState, + focused, 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 - }, + focusRequesters[0], + focusRequesters[dialogItems.lastIndex], ), ) } 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 d57de598..c97117b7 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 @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.experimentalPreferences +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.Release @@ -99,6 +100,9 @@ fun PreferencesContent( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } @@ -135,7 +139,7 @@ fun PreferencesContent( } } - val movementSounds = true + val movementSounds = false val installedVersion = updateVM.currentVersion val updateAvailable = remember(updateState.release) { @@ -185,6 +189,12 @@ fun PreferencesContent( } } + val showUpdate = + UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -214,11 +224,7 @@ fun PreferencesContent( contentPadding = PaddingValues(16.dp), modifier = Modifier.fillMaxSize(), ) { - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable - ) { + if (showUpdate) { item { val updateFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -237,6 +243,7 @@ fun PreferencesContent( modifier = Modifier .focusRequester(updateFocusRequester) + .focusRequester(firstFocusRequester) .playSoundOnFocus(movementSounds), ) } @@ -262,15 +269,25 @@ fun PreferencesContent( .flatten() groupPreferences.forEachIndexed { prefIndex, pref -> pref as AppPreference<AppPreferences, Any> + val isFirst = groupIndex == 0 && prefIndex == 0 && !showUpdate + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex item { val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value val focusModifier = Modifier .ifElse( groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, Modifier.focusRequester(focusRequester), + ).lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, ) - val focused = interactionSource.collectIsFocusedAsState().value LaunchedEffect(focused) { if (focused) { focusedIndex = Pair(groupIndex, prefIndex) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt index f130016f..32da9613 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -40,6 +40,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.preferences.ClickPreference @@ -64,6 +65,8 @@ fun SubtitlePreferencesContent( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() @@ -123,6 +126,9 @@ fun SubtitlePreferencesContent( groupPreferences.forEachIndexed { prefIndex, pref -> pref as AppPreference<SubtitlePreferences, Any> item { + val isFirst = groupIndex == 0 && prefIndex == 0 + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex val interactionSource = remember { MutableInteractionSource() } val bringIntoViewRequester = remember { BringIntoViewRequester() } val focused = interactionSource.collectIsFocusedAsState().value @@ -137,6 +143,21 @@ fun SubtitlePreferencesContent( bringIntoViewRequester.bringIntoView() } } + val focusModifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).bringIntoViewRequester(bringIntoViewRequester) + .lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, + ) + when (pref) { SubtitleSettings.Reset -> { ClickPreference( @@ -152,6 +173,7 @@ fun SubtitlePreferencesContent( } }, interactionSource = interactionSource, + modifier = focusModifier, ) } @@ -187,12 +209,7 @@ fun SubtitlePreferencesContent( } }, interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ).bringIntoViewRequester(bringIntoViewRequester), + modifier = focusModifier, ) } } From 1778bdb34caa699c0590232a7de709a889839765 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 5 Sep 2026 12:11:38 -0400 Subject: [PATCH 412/475] Consolidate sending media reports & app logs (#1936) ## Description Consolidates sending reports/logs to the server into `ServerReportService` (renamed from `MediaReportService`) Also ensures a consistent header is used including the build flavor. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- ...eportService.kt => ServerReportService.kt} | 54 ++++++--- .../ui/components/CollectionFolderView.kt | 6 +- .../wholphin/ui/components/ErrorMessage.kt | 16 +-- .../wholphin/ui/components/ItemGrid.kt | 6 +- .../ui/components/RecommendedContent.kt | 6 +- .../wholphin/ui/detail/DebugPage.kt | 107 +++++++----------- .../wholphin/ui/detail/FavoritesViewModel.kt | 6 +- .../wholphin/ui/detail/HomeRowGrid.kt | 6 +- .../wholphin/ui/detail/PlaylistDetails.kt | 6 +- .../ui/detail/collection/CollectionDetails.kt | 2 +- .../detail/collection/CollectionViewModel.kt | 4 +- .../ui/detail/episode/EpisodeDetails.kt | 2 +- .../ui/detail/episode/EpisodeViewModel.kt | 4 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 2 +- .../ui/detail/movie/MovieViewModel.kt | 4 +- .../ui/detail/music/AlbumDetailsPage.kt | 4 +- .../ui/detail/music/ArtistDetailsPage.kt | 6 +- .../ui/detail/series/SeriesDetails.kt | 2 +- .../ui/detail/series/SeriesOverview.kt | 2 +- .../ui/detail/series/SeriesViewModel.kt | 4 +- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- .../wholphin/ui/main/HomeViewModel.kt | 4 +- .../ui/preferences/PreferencesViewModel.kt | 9 +- .../wholphin/ui/search/SearchViewModel.kt | 6 +- .../CollectionFolderViewModelTest.kt | 2 +- .../ui/detail/TestFavoritesViewModel.kt | 6 +- 26 files changed, 131 insertions(+), 147 deletions(-) rename app/src/main/java/com/github/damontecres/wholphin/services/{MediaReportService.kt => ServerReportService.kt} (69%) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt similarity index 69% rename from app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt rename to app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt index c60e12af..97369c36 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt @@ -2,8 +2,10 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.os.Build +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -23,10 +25,10 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Send media info to the server + * Send reports to the server such as media info or app logs */ @Singleton -class MediaReportService +class ServerReportService @Inject constructor( @param:ApplicationContext private val context: Context, @@ -46,17 +48,17 @@ class MediaReportService /** * Fetch the media info and send it to the server */ - fun sendReportFor(itemId: UUID) { + fun sendMediaReportFor(itemId: UUID) { ioScope.launchIO(ExceptionHandler(autoToast = true)) { val item = api.userLibraryApi.getItem(itemId = itemId).content - sendReportFor(item) + sendMediaReportFor(item) } } /** * Send the media report for the given item */ - suspend fun sendReportFor(item: BaseItemDto) { + suspend fun sendMediaReportFor(item: BaseItemDto) { val sources = item.mediaSources ?: api.userLibraryApi .getItem(itemId = item.id) @@ -68,27 +70,43 @@ class MediaReportService deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) val deviceProfileJson = json.encodeToString(deviceProfile) val body = - """ - Send media info - serverVersion=$serverVersion - clientInfo=$clientInfo - deviceInfo=$deviceInfo - manufacturer=${Build.MANUFACTURER} - model=${Build.MODEL} - apiLevel=${Build.VERSION.SDK_INT} + buildLogHeader("Send media info") + "\n\n" + + """ + playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} - playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} - experimental=${appPreferences.experimentalPreferences.toStringOneLine()} + experimental=${appPreferences.experimentalPreferences.toStringOneLine()} - mediaSources=$sourcesJson + mediaSources=$sourcesJson - deviceProfile=$deviceProfileJson - """.trimIndent() + deviceProfile=$deviceProfileJson + """.trimIndent() body.chunked(2048).forEach { Timber.w(it) } Timber.w("End send media info") val response by api.clientLogApi.logFile(body) showToast(context, "Sent! Filename=${response.fileName}") } + + fun buildLogHeader(title: String): String { + val serverVersion = serverRepository.currentServer?.serverVersion + return """ + $title + serverVersion=$serverVersion + clientInfo=$clientInfo + flavor=${BuildConfig.FLAVOR} + deviceInfo=$deviceInfo + manufacturer=${Build.MANUFACTURER} + model=${Build.MODEL} + apiLevel=${Build.VERSION.SDK_INT} + """.trimIndent() + } + + suspend fun sendAppLogs() { + val logcat = getLogCatLines().joinToString("\n") { it.text } + val header = buildLogHeader("Send App Logs") + Timber.w(header) + val response by api.clientLogApi.logFile(header + "\n\n" + logcat) + showToast(context, "Sent! Filename=${response.fileName}") + } } private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 732c531d..52918e57 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -46,9 +46,9 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -120,7 +120,7 @@ class CollectionFolderViewModel private val mediaManagementService: MediaManagementService, private val musicService: MusicService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, @Assisted val itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -609,7 +609,7 @@ class CollectionFolderViewModel index: Int, ) = addToQueue(api, musicService, item, index) - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt index 59bb456f..b1995b3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -10,30 +9,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Inject @HiltViewModel class ErrorViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val clientInfo: ClientInfo, - private val deviceInfo: DeviceInfo, + private val serverReportService: ServerReportService, ) : ViewModel() { fun sendLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 9a188a6c..3609cdfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -21,8 +21,8 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences 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.ServerReportService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -63,7 +63,7 @@ class ItemGridViewModel private val favoriteWatchManager: FavoriteWatchManager, private val mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, @Assisted private val destination: Destination.ItemGrid<*>, ) : ViewModel(), ContextMenuProvider { @@ -173,7 +173,7 @@ class ItemGridViewModel override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) } data class ItemGridState( 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 7159fc58..ca4ae7ff 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 @@ -22,9 +22,9 @@ 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.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.services.UserPreferencesService @@ -80,7 +80,7 @@ class RecommendedViewModel private val backdropService: BackdropService, private val mediaManagementService: MediaManagementService, private val suggestionService: SuggestionService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, @Assisted private val parentId: UUID, @Assisted private val suggestionsType: BaseItemKind, @Assisted private val recommendedRows: List<RecommendedRow<*>>, @@ -390,7 +390,7 @@ fun RecommendedContent( playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 67c9be5a..7c8268b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -45,19 +45,17 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences 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 +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date @@ -211,71 +209,48 @@ class DebugViewModel } companion object { - fun getLogCatLines(): List<LogcatLine> { - val lineCount = 500 - val args = - buildList { - add("logcat") - add("-d") - add("-t") - add(lineCount.toString()) - addAll(THIRD_PARTY_TAGS) - add("*:V") - } - val process = ProcessBuilder().command(args).redirectErrorStream(true).start() - val logLines = mutableListOf<LogcatLine>() - try { - val reader = BufferedReader(InputStreamReader(process.inputStream)) - var count = 0 - - while (count < lineCount) { - val line = reader.readLine() - if (line != null) { - val level = line.split(Regex("\\s+")).getOrNull(4) - val logLevel = - when (level?.uppercase()) { - "V" -> Log.VERBOSE - "D" -> Log.DEBUG - "I" -> Log.INFO - "W" -> Log.WARN - "E" -> Log.ERROR - else -> Log.VERBOSE - } - logLines.add(LogcatLine(logLevel, line)) - } else { - break + suspend fun getLogCatLines(): List<LogcatLine> = + withContext(WholphinDispatchers.IO) { + val lineCount = 500 + val args = + buildList { + add("logcat") + add("-d") + add("-t") + add(lineCount.toString()) + addAll(THIRD_PARTY_TAGS) + add("*:V") } - count++ + val process = ProcessBuilder().command(args).redirectErrorStream(true).start() + val logLines = mutableListOf<LogcatLine>() + try { + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var count = 0 + + while (count < lineCount) { + val line = reader.readLine() + if (line != null) { + val level = line.split(Regex("\\s+")).getOrNull(4) + val logLevel = + when (level?.uppercase()) { + "V" -> Log.VERBOSE + "D" -> Log.DEBUG + "I" -> Log.INFO + "W" -> Log.WARN + "E" -> Log.ERROR + else -> Log.VERBOSE + } + logLines.add(LogcatLine(logLevel, line)) + } else { + break + } + count++ + } + } finally { + process.destroy() } - } finally { - process.destroy() + return@withContext logLines } - return logLines - } - - fun ViewModel.sendAppLogs( - context: Context, - api: ApiClient, - clientInfo: ClientInfo?, - deviceInfo: DeviceInfo?, - ) { - viewModelScope.launchIO(ExceptionHandler(true)) { - val logcat = getLogCatLines().joinToString("\n") { it.text } - val body = - """ - Send App Logs - clientInfo=$clientInfo - deviceInfo=$deviceInfo - manufacturer=${Build.MANUFACTURER} - model=${Build.MODEL} - apiLevel=${Build.VERSION.SDK_INT} - - """.trimIndent() - Timber.w(body) - val response by api.clientLogApi.logFile(body + logcat) - showToast(context, "Sent! Filename=${response.fileName}") - } - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt index f67b322a..8471c40d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -18,10 +18,10 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FilterOptionCache 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.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem @@ -89,7 +89,7 @@ class FavoritesViewModel private val userPreferencesService: UserPreferencesService, private val mediaManagementService: MediaManagementService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, private val rememberedTabService: RememberedTabService, ) : ViewModel() { @@ -590,7 +590,7 @@ class FavoritesViewModel } } - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) override fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 7948662c..1fd7ea9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -26,10 +26,10 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MusicService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -83,7 +83,7 @@ class HomeRowGridViewModel private val mediaManagementService: MediaManagementService, private val musicService: MusicService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, @Assisted private val title: StringProvider, @Assisted private val rowConfig: HomeRowConfig, ) : ViewModel(), @@ -187,7 +187,7 @@ class HomeRowGridViewModel } } - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 6b1c090d..fa3443e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -66,10 +66,10 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog @@ -144,7 +144,7 @@ class PlaylistViewModel private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, - private val mediaReportService: MediaReportService, + private val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @@ -331,7 +331,7 @@ class PlaylistViewModel } fun sendMediaReport(itemId: UUID) { - viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) } + viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 99db156f..291ef74a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -93,7 +93,7 @@ fun CollectionDetails( playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(it, position) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index bf100cff..af3f3cfd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -18,9 +18,9 @@ import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem @@ -90,7 +90,7 @@ class CollectionViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val imageUrlService: ImageUrlService, private val musicService: MusicService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, @Assisted private val itemId: UUID, ) : ViewModel() { 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 ff43eb10..b9956fb4 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 @@ -90,7 +90,7 @@ fun EpisodeDetails( playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { item, source -> 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 ef94620a..a45fbda0 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 @@ -11,8 +11,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback 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.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -52,7 +52,7 @@ class EpisodeViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, 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 db4608ac..ef0d186e 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 @@ -109,7 +109,7 @@ fun MovieDetails( playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( 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 168a392c..939d1331 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 @@ -17,10 +17,10 @@ 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 import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -62,7 +62,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index a45f45f8..f6a5d715 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -53,9 +53,9 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.ItemRowFields @@ -118,7 +118,7 @@ class AlbumViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index 4fc21483..12238441 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -49,9 +49,9 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.ItemRowFields @@ -115,7 +115,7 @@ class ArtistViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, @@ -656,7 +656,7 @@ fun ArtistDetailsPage( playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { _, _ -> }, onChooseTracks = { }, 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 fe0b5e5c..f42961ad 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 @@ -135,7 +135,7 @@ fun SeriesDetails( playlistViewModel.loadPlaylists() showPlaylistDialog = Optional.present(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( 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 77922f9d..6512e474 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 @@ -118,7 +118,7 @@ fun SeriesOverview( playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( 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 8d0bb1c5..e5eb1e3d 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 @@ -18,10 +18,10 @@ 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 import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -96,7 +96,7 @@ class SeriesViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, 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 cc06ed88..c2822b4e 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 @@ -179,7 +179,7 @@ fun HomePage( playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, 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 feaf8d04..bceccc5e 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 @@ -14,9 +14,9 @@ import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.tvAccess @@ -55,7 +55,7 @@ class HomeViewModel @param:ApplicationContext private val context: Context, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, 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 b72849a0..200bac4e 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 @@ -22,8 +22,8 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -36,8 +36,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber import javax.inject.Inject @@ -53,9 +51,8 @@ class PreferencesViewModel val screensaverService: ScreensaverService, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, - private val deviceInfo: DeviceInfo, - private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, + private val serverReportService: ServerReportService, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow.stateIn( @@ -97,7 +94,7 @@ class PreferencesViewModel } fun sendAppLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } fun resetSubtitleSettings() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt index b9f8a9de..60ba2199 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -15,10 +15,10 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.LiveTvService 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.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.tvAccess @@ -73,7 +73,7 @@ class SearchViewModel private val serverRepository: ServerRepository, private val favoriteWatchManager: FavoriteWatchManager, private val mediaManagementService: MediaManagementService, - private val mediaReportService: MediaReportService, + private val serverReportService: ServerReportService, private val liveTvService: LiveTvService, private val keyValueService: KeyValueService, private val navDrawerService: NavDrawerService, @@ -447,7 +447,7 @@ class SearchViewModel override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) fun fetchProgramForDialog(programId: UUID) { _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt index 978cd70c..bc879d24 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -115,7 +115,7 @@ class CollectionFolderViewModelTest { mediaManagementService = mockMediaManagementService, musicService = mockk(relaxed = true), streamChoiceService = mockk(relaxed = true), - mediaReportService = mockk(relaxed = true), + serverReportService = mockk(relaxed = true), filterOptionCache = mockk(relaxed = true), itemId = libraryId.toString(), initialSortAndDirection = null, diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt index 6066cae4..0b4cece1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt @@ -9,11 +9,11 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerItemState import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.test.item @@ -61,7 +61,7 @@ class TestFavoritesViewModel { private val userPreferencesService: UserPreferencesService = mockk() private val mediaManagementService: MediaManagementService = mockk() private val streamChoiceService: StreamChoiceService = mockk() - private val mediaReportService: MediaReportService = mockk() + private val serverReportService: ServerReportService = mockk() private val filterOptionCache: FilterOptionCache = mockk() private val rememberedTabService: RememberedTabService = mockk() private val navDrawerService: NavDrawerService = mockk() @@ -100,7 +100,7 @@ class TestFavoritesViewModel { userPreferencesService = userPreferencesService, mediaManagementService = mediaManagementService, streamChoiceService = streamChoiceService, - mediaReportService = mediaReportService, + serverReportService = serverReportService, filterOptionCache = filterOptionCache, rememberedTabService = rememberedTabService, navDrawerService = navDrawerService, From 4a567cbcf4f4db8da1d83653a6813cd2b24459a9 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 8 Sep 2026 22:51:46 -0400 Subject: [PATCH 413/475] Fixes to season/episode search with duplicates (#1946) ## Description Fixes cases where the server has duplicate season IDs causing the season index search to fail and default to the first season in the list. ### Related issues Fixes #1837 ### Testing Emulator & unit tests ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/series/SeriesOverview.kt | 4 +- .../ui/detail/series/SeriesViewModel.kt | 113 +++++++++++++----- .../test/TestFindIndexByNumberOrId.kt | 26 +++- 3 files changed, 108 insertions(+), 35 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 6512e474..ccc8f493 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 @@ -92,7 +92,7 @@ fun SeriesOverview( LaunchedEffect(Unit) { if (state.seasons.isNotEmpty()) { state.seasons.getOrNull(position.seasonTabIndex)?.let { - viewModel.loadEpisodes(it.id) + viewModel.loadEpisodes(it.id, it.indexNumber) } } } @@ -210,7 +210,7 @@ fun SeriesOverview( onChangeSeason = { index -> if (index != position.seasonTabIndex) { state.seasons.getOrNull(index)?.let { season -> - viewModel.loadEpisodes(season.id) + viewModel.loadEpisodes(season.id, season.indexNumber) viewModel.position.update { SeriesOverviewPosition(index, 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 e5eb1e3d..cb2146df 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 @@ -143,6 +143,7 @@ class SeriesViewModel if (seasonEpisodeIds != null) { loadEpisodesInternal( seasonEpisodeIds.seasonId, + seasonEpisodeIds.seasonNumber, seasonEpisodeIds.episodeId, seasonEpisodeIds.episodeNumber, ) @@ -150,6 +151,7 @@ class SeriesViewModel seasonsDeferred.await().firstOrNull()?.let { loadEpisodesInternal( it.id, + it.indexNumber, null, null, ) @@ -178,10 +180,11 @@ class SeriesViewModel val index = (seasons as? ApiRequestPager<*>)?.let { findIndexByNumberOrIdFast( - seasonEpisodeIds.seasonNumber, - seasonEpisodeIds.seasonId, - it, - null, + targetNum = seasonEpisodeIds.seasonNumber, + targetId = seasonEpisodeIds.seasonId, + list = it, + parentId = null, + parentIndex = null, ) } ?: 0 Timber.v("Got initial season index: $index") @@ -340,6 +343,7 @@ class SeriesViewModel private suspend fun loadEpisodesInternal( seasonId: UUID, + seasonNum: Int?, episodeId: UUID?, episodeNumber: Int?, ): EpisodeList { @@ -368,8 +372,13 @@ class SeriesViewModel pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexByNumberOrIdFast(episodeNumber, episodeId, pager, seasonId) - .coerceAtLeast(0) + findIndexByNumberOrIdFast( + targetNum = episodeNumber, + targetId = episodeId, + list = pager, + parentId = seasonId, + parentIndex = seasonNum, + ).coerceAtLeast(0) } else { // Force the first page to be fetched if (pager.isNotEmpty()) { @@ -381,7 +390,10 @@ class SeriesViewModel return EpisodeList.Success(seasonId, pager, initialIndex) } - fun loadEpisodes(seasonId: UUID) { + fun loadEpisodes( + seasonId: UUID, + seasonNum: Int?, + ) { val currentEpisodes = (state.value.episodes as? EpisodeList.Success) if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { _state.update { @@ -395,7 +407,7 @@ class SeriesViewModel viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = try { - loadEpisodesInternal(seasonId, null, null) + loadEpisodesInternal(seasonId, seasonNum, null, null) } catch (e: Exception) { Timber.e(e, "Error loading episodes for $seriesId for season $seasonId") EpisodeList.Error(e) @@ -716,12 +728,18 @@ private fun checkNumberOrId( targetId: UUID?, indexNumber: Int?, id: UUID?, -): Boolean = - if (targetId != null) { - equalsNotNull(targetId, id) - } else { - equalsNotNull(indexNumber, targetNum) - } +): Boolean = equalsNotNull(targetId, id) || equalsNotNull(indexNumber, targetNum) + +private fun checkParent( + parentId: UUID?, + parentIndex: Int?, + item: BaseItem?, +) = if (parentId != null || parentIndex != null) { + equalsNotNull(parentId, item?.data?.parentId) || + equalsNotNull(parentIndex, item?.data?.parentIndexNumber) +} else { + true +} /** * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] @@ -734,19 +752,22 @@ suspend fun findIndexByNumberOrIdFast( targetId: UUID?, list: ApiRequestPager<*>, parentId: UUID?, + parentIndex: Int?, ): Int = if (list.size <= list.pageSize) { Timber.v("Using findIndexByNumberOrIdFast indexOfBlocking method") list.indexOfBlocking { checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && - if (parentId != null) { - it?.data?.parentId == parentId - } else { - true - } + checkParent(parentId, parentIndex, it) } } else { - findIndexByNumberOrId(targetNum, targetId, list as BlockingList<BaseItem?>, parentId) + findIndexByNumberOrId( + targetNum, + targetId, + list as BlockingList<BaseItem?>, + parentId, + parentIndex, + ) } /** @@ -765,6 +786,7 @@ suspend fun findIndexByNumberOrId( targetId: UUID?, list: BlockingList<BaseItem?>, parentId: UUID? = null, + parentIndex: Int? = null, ): Int { Timber.v("Using findIndexByNumberOrId") // Adjust for 1-based numbers @@ -777,7 +799,7 @@ suspend fun findIndexByNumberOrId( checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) }.coerceAtLeast(0) } else if (listIndex != null && listIndex in list.indices) { - searchList(listIndex, targetNum, targetId, list, parentId) + searchList(listIndex, targetNum, targetId, list, parentId, parentIndex) } else { 0 } @@ -790,22 +812,42 @@ private suspend fun searchList( targetId: UUID?, list: BlockingList<BaseItem?>, parentId: UUID?, + parentIndex: Int?, ): Int { - val item = list.getBlocking(listIndex) - if (parentId != null && item?.data?.parentId != parentId) { - return if (listIndex - 1 in list.indices) { - searchList(listIndex - 1, targetNum, targetId, list, parentId) - } else if (listIndex + 1 in list.indices) { - searchList(listIndex + 1, targetNum, targetId, list, parentId) - } else { - 0 + var index = listIndex + var item = list.getBlocking(index) + if ((parentId != null || parentIndex != null) && !checkParent(parentId, parentIndex, item)) { + var leftIndex = listIndex - 1 + var rightIndex = listIndex + 1 + // Search before & after to find an episode with the right parent + while (leftIndex in list.indices || rightIndex in list.indices) { + if (leftIndex in list.indices) { + val left = list.getBlocking(leftIndex) + if (checkParent(parentId, parentIndex, left)) { + index = leftIndex + break + } + } + if (rightIndex in list.indices) { + val right = list.getBlocking(rightIndex) + if (checkParent(parentId, parentIndex, right)) { + index = rightIndex + break + } + } + leftIndex-- + rightIndex++ } + // Search for the right episode starting from this new one + item = list.getBlocking(index) } val num = item?.indexNumber if (num.lt(targetNum)) { for (i in listIndex + 1 until list.size) { val item = list.getBlocking(i) - if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { return i } } @@ -813,15 +855,22 @@ private suspend fun searchList( } else if (num.gt(targetNum)) { for (i in listIndex - 1 downTo 0) { val item = list.getBlocking(i) - if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) { + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { return i } } return 0 + } else if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return index } else { return list .indexOfBlocking { - checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + checkParent(parentId, parentIndex, item) }.coerceAtLeast(0) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt index 6e840dd8..6a801c16 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -156,7 +156,12 @@ class TestFindIndexByNumberOrId { fun `Test with special matching number`() = runTest { val list = listOf(epS02E01, epS00E02, epS02E02) - findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(list)).let { index -> + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentIndex = 2, + ).let { index -> Assert.assertEquals(2, index) } } @@ -195,4 +200,23 @@ class TestFindIndexByNumberOrId { Assert.assertEquals(4, index) } } + + @Test + fun `Test with non-matching parent ID`() = + runTest { + val altSeason2Id = UUID.randomUUID() + Assert.assertNotEquals(altSeason2Id, season2Id) + Assert.assertNotEquals(altSeason2Id, epS02E02.data.parentId) + + val list = listOf(epS02E01, epS02E02) + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentId = altSeason2Id, + parentIndex = 2, + ).let { index -> + Assert.assertEquals(1, index) + } + } } From 0b995b5404aba166a7931ade67ab1bc256f8418b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 8 Sep 2026 22:51:54 -0400 Subject: [PATCH 414/475] Configure playback controls for RTL (#1945) ## Description Reverts/fixes parts of #1404 for the playback UI. Generally the playback UI always uses left-to-right for seek bar & media controls even on RTL. Other buttons or texts will be RTL. ### Related issues Fixes #1397 ### Testing Emulator w/ forced RTL English ## Screenshots See https://github.com/damontecres/Wholphin/issues/1397#issuecomment-5553711593 ## AI or LLM usage None --- .../ui/detail/music/NowPlayingPage.kt | 6 +- .../ui/playback/PlaybackKeyHandler.kt | 15 +- .../wholphin/ui/playback/PlaybackPage.kt | 53 +++--- .../ui/playback/overlay/PlaybackControls.kt | 171 ++++++++++-------- .../ui/playback/overlay/PlaybackOverlay.kt | 63 ++++--- .../wholphin/ui/playback/overlay/SeekBar.kt | 91 +++------- 6 files changed, 185 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 1c4cb71f..e37c7ac1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -45,9 +45,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi @@ -106,11 +104,9 @@ fun NowPlayingPage( ).value.appPreferences val musicPrefs = preferences.musicPreferences - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val keyHandler = - remember(isLtr, preferences) { + remember(preferences) { PlaybackKeyHandler( - isLtr = isLtr, player = player, controlsEnabled = true, skipWithLeftRight = false, 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 71ea05e2..29bd30b2 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 @@ -16,7 +16,6 @@ import kotlin.time.Duration * Handles [KeyEvent]s during playback on [PlaybackPage] */ class PlaybackKeyHandler( - private val isLtr: Boolean, private val player: Player, private val controlsEnabled: Boolean, private val skipWithLeftRight: Boolean, @@ -53,17 +52,9 @@ class PlaybackKeyHandler( if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { - if (isLtr) { - seekBy(-seekBack) - } else { - seekBy(seekForward) - } + seekBy(-seekBack) } else if (skipWithLeftRight && isSkipForward(it)) { - if (isLtr) { - seekBy(seekForward) - } else { - seekBy(-seekBack) - } + seekBy(seekForward) } else if (isEnterKey(it) && isDpadSeekVisible()) { // If d-pad seek bar is visible, hide it clearSkipIndicator.invoke() @@ -142,7 +133,7 @@ class PlaybackKeyHandler( return false } - val isBack = if (isLtr) isSkipBack(event) else isSkipForward(event) + val isBack = isSkipBack(event) return when (event.type) { KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount 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 209e8473..6ed210a2 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 @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -243,11 +244,9 @@ fun PlaybackPageContent( ) seekBarState.onValueChange(skipPosition) } - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val keyHandler = - remember(isLtr, preferences) { + remember(preferences) { PlaybackKeyHandler( - isLtr = isLtr, player = player, controlsEnabled = state.nextUp == null, skipWithLeftRight = true, @@ -390,17 +389,19 @@ fun PlaybackPageContent( Modifier .align(Alignment.BottomCenter), ) { - DpadSeekOverlay( - player = player, - seekPositionMs = skipPosition, - trickplayInfo = state.currentMediaInfo.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 16.dp) - .fillMaxWidth(.95f), - ) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + DpadSeekOverlay( + player = player, + seekPositionMs = skipPosition, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .fillMaxWidth(.95f), + ) + } // Clear the overlay after a delay LaunchedEffect(skipIndicatorDuration) { delay(1.5.seconds) @@ -426,15 +427,23 @@ fun PlaybackPageContent( // Show a small progress bar along the bottom of the screen if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { val percent = skipPosition.toFloat() / player.duration.toFloat() - Box( - modifier = + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( Modifier - .align(Alignment.BottomStart) - .background(MaterialTheme.colorScheme.border) - .clip(RectangleShape) - .height(3.dp) - .fillMaxWidth(percent), - ) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background(MaterialTheme.colorScheme.border) + .clip(RectangleShape) + .height(3.dp) + .fillMaxWidth(percent), + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index d8d43300..ba77d8b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -191,6 +192,7 @@ fun PlaybackControls( onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) + PlaybackButtons( player = player, initialFocusRequester = initialFocusRequester, @@ -204,6 +206,7 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) + Row( modifier = Modifier.align(Alignment.CenterEnd), ) { @@ -286,31 +289,40 @@ fun SeekTimecodes( durationMs: Long, modifier: Modifier = Modifier, ) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - val resources = LocalResources.current - val positionSec = positionMs / 1000 - val remainingSec = (durationMs - positionMs) / 1000 - val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } - val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } - Text( - text = positionText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - Text( - text = remainingText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) + val currentLayoutDirection = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val resources = LocalResources.current + val positionSec = positionMs / 1000 + val remainingSec = (durationMs - positionMs) / 1000 + val positionText = + remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = + remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = positionText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = remainingText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } + } } } @@ -389,61 +401,62 @@ fun PlaybackButtons( seekForward: Duration, modifier: Modifier = Modifier, ) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - Row( - modifier = modifier.focusGroup(), - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - ) { - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_skip_previous_24 else R.drawable.baseline_skip_next_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Previous) - }, - enabled = previousEnabled, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_fast_rewind_24 else R.drawable.baseline_fast_forward_24, - onClick = { - onControllerInteraction.invoke() - player.seekBack(seekBack) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - modifier = Modifier.focusRequester(initialFocusRequester), - iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, - onClick = { - onControllerInteraction.invoke() - if (showPlay) { - player.play() - skipBackOnResume?.let { - player.seekBack(it) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + ) { + PlaybackButton( + iconRes = R.drawable.baseline_skip_previous_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Previous) + }, + enabled = previousEnabled, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_rewind_24, + onClick = { + onControllerInteraction.invoke() + player.seekBack(seekBack) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + modifier = Modifier.focusRequester(initialFocusRequester), + iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, + onClick = { + onControllerInteraction.invoke() + if (showPlay) { + player.play() + skipBackOnResume?.let { + player.seekBack(it) + } + } else { + player.pause() } - } else { - player.pause() - } - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_fast_forward_24 else R.drawable.baseline_fast_rewind_24, - onClick = { - onControllerInteraction.invoke() - player.seekForward(seekForward) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_skip_next_24 else R.drawable.baseline_skip_previous_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Next) - }, - enabled = nextEnabled, - onControllerInteraction = onControllerInteraction, - ) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_forward_24, + onClick = { + onControllerInteraction.invoke() + player.seekForward(seekForward) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_skip_next_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Next) + }, + enabled = nextEnabled, + onControllerInteraction = onControllerInteraction, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index a1a83df6..d00fefea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -40,8 +41,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.media3.common.Player import coil3.compose.AsyncImage @@ -254,39 +257,41 @@ fun PlaybackOverlay( } } - // Trickplay - AnimatedVisibility( - visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, - enter = - expandVertically( - spring( - stiffness = Spring.StiffnessMedium, - visibilityThreshold = IntSize.VisibilityThreshold, - ), - ) + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxWidth(.95f), + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + // Trickplay + AnimatedVisibility( + visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, + enter = + expandVertically( + spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ) + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( modifier = Modifier - .align(Alignment.BottomStart) - .offsetByPercent( - xPercentage = seekProgressPercent.coerceIn(0f, 1f), - ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + .align(Alignment.Center) + .fillMaxWidth(.95f), ) { - TrickplayPreview( - seekProgressMs = seekProgressMs, - trickplayInfo = trickplayInfo, - trickplayUrlFor = trickplayUrlFor, - ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent( + xPercentage = seekProgressPercent.coerceIn(0f, 1f), + ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + ) { + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index c03be433..d2aae720 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -43,8 +43,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.playback.ControllerViewState @@ -202,7 +200,6 @@ private fun SeekBarDisplay( modifier: Modifier = Modifier, enabled: Boolean = true, ) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val color = MaterialTheme.colorScheme.border val onSurface = MaterialTheme.colorScheme.onSurface @@ -225,13 +222,7 @@ private fun SeekBarDisplay( Timber.v("Ignoring %s", event) return@onPreviewKeyEvent false } - val seekBack = - if (isLtr) { - isDpadLeft(event) - } else { - isDpadRight(event) - } - if (seekBack) { + if (isDpadLeft(event)) { when (event.type) { KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount @@ -300,68 +291,34 @@ private fun SeekBarDisplay( strokeWidth = size.height, cap = StrokeCap.Round, ) - if (isLtr) { - drawLine( - color = onSurface.copy(alpha = .65f), - start = Offset(x = 0f, y = yOffset), - end = - Offset( - x = size.width.times(bufferedProgress), - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(x = 0f, y = yOffset), - end = - Offset( - x = size.width.times(progress), - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - } else { - drawLine( - color = onSurface.copy(alpha = .65f), - start = - Offset( - x = size.width - size.width.times(bufferedProgress), - y = yOffset, - ), - end = - Offset( - x = size.width, - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(x = size.width - size.width.times(progress), y = yOffset), - end = - Offset( - x = size.width, - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - } + drawLine( + color = onSurface.copy(alpha = .65f), + start = Offset(x = 0f, y = yOffset), + end = + Offset( + x = size.width.times(bufferedProgress), + y = yOffset, + ), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(x = 0f, y = yOffset), + end = + Offset( + x = size.width.times(progress), + y = yOffset, + ), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) drawCircle( color = Color.White, radius = size.height + 2, center = Offset( - x = - if (isLtr) { - size.width.times(progress) - } else { - size.width - size.width.times(progress) - }, + x = size.width.times(progress), y = yOffset, ), ) From 9c56965c82db934879fb94fb3c0f2576239d78ef Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 10 Sep 2026 17:18:26 -0400 Subject: [PATCH 415/475] Add controls to edit playlists (#1927) ## Description Adds support to remove items from a playlist and move items within a playlist The remove option is in the context menu. Moving has buttons on the right side. The buttons are available if the user has edit permissions and the playlist is sorted by "Default" (the playlist order). Remove from playlist option is available if the user has edit permissions. Also, thumb images are now preferred instead of primary/poster images since the layout is much wider than tall. ### Related issues Closes #1881 ### Testing Emulator with both a user with & without permissions to edit ## Screenshots ### With move buttons <img width="1280" height="720" alt="playlist_buttons Large" src="https://github.com/user-attachments/assets/ce7880c3-b823-4b69-9ad4-cfc9e7ee5fd5" /> ### Without move buttons <img width="1280" height="720" alt="playlist_no_buttons Large" src="https://github.com/user-attachments/assets/f2027052-4628-4d44-8257-6c2a5e8c3af4" /> ## AI or LLM usage None --- .../wholphin/ui/components/ContextMenu.kt | 55 ++- .../wholphin/ui/detail/PlaylistDetails.kt | 387 +++++++++++++----- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 328 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index cf7b32d6..2f39f238 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -41,6 +41,7 @@ sealed interface ContextMenu { data class ForBaseItem( val fromLongClick: Boolean, val item: BaseItem, + val index: Int = UNSPECIFIED_INDEX, val chosenStreams: ChosenStreams?, val showGoTo: Boolean, val showStreamChoices: Boolean, @@ -48,6 +49,7 @@ sealed interface ContextMenu { val canRemoveContinueWatching: Boolean, val canRemoveNextUp: Boolean, val actions: ContextMenuActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForPerson( @@ -63,6 +65,7 @@ sealed interface ContextMenu { val canDelete: Boolean, val canRemoveFromQueue: Boolean, val actions: MusicContextActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForQueue( @@ -71,6 +74,10 @@ sealed interface ContextMenu { val index: Int, val actions: QueueContextActions, ) : ContextMenu + + companion object { + const val UNSPECIFIED_INDEX = -12 + } } data class ContextMenuActions( @@ -87,6 +94,7 @@ data class ContextMenuActions( val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, val onClickAddToQueue: (BaseItem) -> Unit = {}, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class PersonContextActions( @@ -109,6 +117,7 @@ data class MusicContextActions( val onClickGoToArtist: (UUID) -> Unit = { navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) }, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class QueueContextActions( @@ -200,11 +209,7 @@ fun ContextMenu( seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, - showGoTo = contextMenu.showGoTo, - showStreamChoices = contextMenu.showStreamChoices, - canDelete = contextMenu.canDelete, - canRemoveContinueWatching = contextMenu.canRemoveContinueWatching, - canRemoveNextUp = contextMenu.canRemoveNextUp, + contextMenu = contextMenu, actions = actions, onChooseVersion = { chooseVersion = @@ -319,17 +324,13 @@ fun ContextMenu( private fun buildContextMenuItems( resources: Resources, + contextMenu: ContextMenu.ForBaseItem, item: BaseItem, seriesId: UUID?, sourceId: UUID?, watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - showGoTo: Boolean, - showStreamChoices: Boolean, - canDelete: Boolean, - canRemoveContinueWatching: Boolean, - canRemoveNextUp: Boolean, actions: ContextMenuActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -340,7 +341,7 @@ private fun buildContextMenuItems( ): List<DialogItem> = buildList { // Songs should not show Go to - if (showGoTo && item.type != BaseItemKind.AUDIO) { + if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.go_to), @@ -414,7 +415,7 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices) { + if (contextMenu.showStreamChoices) { item.data.mediaSources?.letNotEmpty { sources -> val source = sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } @@ -478,6 +479,17 @@ private fun buildContextMenuItems( }, ) } + if (contextMenu.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(contextMenu.index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -487,7 +499,7 @@ private fun buildContextMenuItems( actions.onClickAddPlaylist.invoke(item.id) }, ) - if (canDelete) { + if (contextMenu.canDelete) { add( DialogItem( resources.getString(R.string.delete), @@ -499,7 +511,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { + if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { add( DialogItem( text = R.string.remove_continue_watching, @@ -510,7 +522,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { add( DialogItem( text = R.string.remove_next_up, @@ -602,7 +614,7 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices && canClearChosenStreams) { + if (contextMenu.showStreamChoices && canClearChosenStreams) { add( DialogItem( resources.getString(R.string.clear_track_choices), @@ -785,6 +797,17 @@ fun buildContextForMusic( }, ) } + if (music.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index fa3443e3..b7085db6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -14,9 +14,11 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -34,6 +36,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -41,6 +44,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -69,10 +73,13 @@ import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -100,9 +107,11 @@ import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundSeconds +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.LocalClock @@ -116,12 +125,16 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.MediaType import org.jellyfin.sdk.model.api.SortOrder @@ -146,6 +159,7 @@ class PlaylistViewModel private val favoriteWatchManager: FavoriteWatchManager, private val serverReportService: ServerReportService, private val filterOptionCache: FilterOptionCache, + private val playlistCreator: PlaylistCreator, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory @@ -169,9 +183,47 @@ class PlaylistViewModel .getItem(itemId) .content .let { BaseItem(it, false) } - state.update { it.copy(playlist = playlist) } + val user = serverRepository.currentUser + val canEdit = + user?.let { user -> + try { + val permission by api.playlistsApi.getPlaylistUser(itemId, user.id) + permission.canEdit + } catch (ex: CancellationException) { + throw ex + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + // Server will return this if no permission exists + Timber.w( + "User doesn't have permission to edit playlist %s", + itemId, + ) + } else { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + } + false + } catch (ex: Exception) { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + false + } + } ?: false + state.update { + it.copy( + playlist = playlist, + canEdit = canEdit, + ) + } + val libraryDisplayInfo = - serverRepository.currentUser?.let { user -> + user?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() @@ -333,6 +385,47 @@ class PlaylistViewModel fun sendMediaReport(itemId: UUID) { viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } } + + fun removeFromPlaylist( + index: Int, + itemId: UUID, + ) { + viewModelScope.launchIO { + try { + playlistCreator.removeFromServerPlaylist( + playlistId = this@PlaylistViewModel.itemId, + itemId = itemId, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e( + ex, + "Error removing %s from playlist %s", + itemId, + this@PlaylistViewModel.itemId, + ) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onMoveItem( + index: Int, + itemId: UUID, + direction: MoveDirection, + ) { + viewModelScope.launchIO { + val newIndex = index + if (direction == MoveDirection.UP) -1 else 1 + api.playlistsApi.moveItem( + playlistId = this@PlaylistViewModel.itemId.toServerString(), + itemId = itemId.toServerString(), + newIndex = newIndex, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1) + } + } } @Immutable @@ -355,6 +448,7 @@ data class PlaylistDetailsState( ), ), val loading: LoadingState = LoadingState.Pending, + val canEdit: Boolean = false, ) @Composable @@ -418,6 +512,7 @@ fun PlaylistDetails( }, onClickRemoveFromQueue = { _, _ -> }, onDeleteItem = viewModel::deleteItem, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } val contextActions = @@ -438,6 +533,7 @@ fun PlaylistDetails( onChooseTracks = {}, onClearChosenStreams = {}, onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } @@ -455,21 +551,23 @@ fun PlaylistDetails( play(0, it, shuffle) } }, - onLongClickIndex = { index, item -> + onShowContextMenu = { index, item, fromLongClick -> showContextMenu = if (item.type == BaseItemKind.AUDIO) { ContextMenu.ForMusic( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, index = index, canDelete = viewModel.canDelete(item, preferences.appPreferences), canRemoveFromQueue = false, actions = musicContextActions, + showRemoveFromPlaylist = state.canEdit, ) } else { ContextMenu.ForBaseItem( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, + index = index, chosenStreams = null, showGoTo = true, showStreamChoices = false, @@ -477,12 +575,15 @@ fun PlaylistDetails( canRemoveContinueWatching = false, canRemoveNextUp = false, actions = contextActions, + showRemoveFromPlaylist = state.canEdit, ) } }, filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, + canEdit = state.canEdit, + onMoveItem = viewModel::onMoveItem, modifier = modifier, ) showContextMenu?.let { contextMenu -> @@ -525,13 +626,15 @@ fun PlaylistDetailsContent( items: List<BaseItem?>, musicState: MusicServiceState, onClickIndex: (Int, BaseItem) -> Unit, - onLongClickIndex: (Int, BaseItem) -> Unit, + onShowContextMenu: (Int, BaseItem, Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, filterAndSort: FilterAndSort, onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, loadingState: LoadingState, + canEdit: Boolean, modifier: Modifier = Modifier, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } @@ -579,11 +682,16 @@ fun PlaylistDetailsContent( filterAndSort = filterAndSort, onFilterAndSortChange = onFilterAndSortChange, getPossibleFilterValues = getPossibleFilterValues, + filterOptions = DefaultPlaylistItemsOptions, modifier = Modifier .padding(top = 80.dp) .fillMaxWidth(.25f), ) + val filterCount = + remember(filterAndSort) { + filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions) + } PlaylistItems( loadingState = loadingState, items = items, @@ -596,15 +704,22 @@ fun PlaylistDetailsContent( savedIndex = index item?.let { onClickIndex.invoke(index, item) } }, - onLongClickItem = { index, item -> + onShowContextMenu = { index, item, fromLongClick -> savedIndex = index - item?.let { onLongClickIndex.invoke(index, item) } + item?.let { onShowContextMenu.invoke(index, item, fromLongClick) } }, + canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0, + onMoveItem = onMoveItem, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(start = 16.dp) .weight(1f) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }, ) } } @@ -614,6 +729,7 @@ fun PlaylistDetailsContent( @Composable fun PlaylistDetailsHeader( focusedItem: BaseItem?, + filterOptions: List<ItemFilterBy<*>>, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, focusRequester: FocusRequester, @@ -648,7 +764,7 @@ fun PlaylistDetailsHeader( modifier = Modifier, ) { FilterByButton( - filterOptions = DefaultPlaylistItemsOptions, + filterOptions = filterOptions, current = filterAndSort.filter, onFilterChange = { onFilterAndSortChange.invoke( @@ -697,11 +813,14 @@ fun PlaylistItems( items: List<BaseItem?>, musicState: MusicServiceState, playButtonFocusRequester: FocusRequester, + canMove: Boolean, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, onFocusItem: (Int, BaseItem?) -> Unit, onClickItem: (Int, BaseItem?) -> Unit, - onLongClickItem: (Int, BaseItem?) -> Unit, + onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val focusManager = LocalFocusManager.current when (loadingState) { is LoadingState.Error -> { ErrorMessage(loadingState, modifier) @@ -724,10 +843,7 @@ fun PlaylistItems( .surfaceColorAtElevation(1.dp) .copy(alpha = .75f), shape = RoundedCornerShape(16.dp), - ).focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }.focusGroup() + ).focusGroup() .focusRestorer(), ) { itemsIndexed(items) { index, item -> @@ -738,7 +854,7 @@ fun PlaylistItems( onClickItem.invoke(index, item) }, onLongClick = { - onLongClickItem.invoke(index, item) + onShowContextMenu.invoke(index, item, true) }, isPlaying = equalsNotNull( @@ -746,18 +862,27 @@ fun PlaylistItems( item?.id, ), isQueued = item?.id in musicState.queuedIds, + canMove = canMove, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClickMove = { direction -> + item?.let { onMoveItem.invoke(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { onShowContextMenu.invoke(index, item, false) }, modifier = Modifier + .animateItem() .ifElse( item?.type != BaseItemKind.AUDIO, Modifier.height(80.dp), ).onFocusChanged { - if (it.isFocused) { + if (it.hasFocus) { onFocusItem(index, item) } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester }, ) } @@ -788,6 +913,11 @@ fun PlaylistItem( index: Int, onClick: () -> Unit, onLongClick: () -> Unit, + canMove: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClickMove: (MoveDirection) -> Unit, + onClickMore: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, isPlaying: Boolean = false, @@ -797,97 +927,154 @@ fun PlaylistItem( val imageWidth = 160.dp val density = LocalDensity.current val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } - ListItem( - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - trailingContent = { - val duration = - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + val duration = + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } - else -> { - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } + } + duration?.let { duration -> + val now by LocalClock.current.now + val context = LocalContext.current + val endTimeStr = + remember(item, now, context) { + val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) + formatTime(context, endTime) + } + val resources = LocalResources.current + val durationText = + remember(resources, duration) { resources.formatDuration(duration) } + Column { + Text( + text = durationText, + ) + if (item?.type != BaseItemKind.AUDIO) { + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } } } - duration?.let { duration -> - val now by LocalClock.current.now - val context = LocalContext.current - val endTimeStr = - remember(item, now, context) { - val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - formatTime(context, endTime) - } - val resources = LocalResources.current - val durationText = - remember(resources, duration) { resources.formatDuration(duration) } - Column { + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { Text( - text = durationText, + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, ) - if (item?.type != BaseItemKind.AUDIO) { - Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } else { + val imageType = + remember(item) { + if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) { + ImageType.THUMB + } else { + ImageType.PRIMARY + } + } + ItemCardImage( + item = item, + name = item?.name, + imageType = imageType, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + modifier = Modifier.width(imageWidth), + useFallbackText = false, + fillWidth = imageWidthPx, ) } } - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) - if (item?.type == BaseItemKind.AUDIO) { - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, + }, + modifier = Modifier.weight(1f), + ) + val contentHeight = 24.dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentWidth(), + ) { + if (canMove) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, ) - } else { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, ) } } - }, - modifier = modifier, - ) + Button( + onClick = onClickMore, + enabled = true, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_ellipsis_vertical), + fontFamily = FontAwesome, + ) + } + } + } } @Composable diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 61c7af06..45a6f7b2 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -61,4 +61,5 @@ <string name="fa_compact_disc" translatable="false"></string> <string name="fa_xmark" translatable="false"></string> <string name="fa_dice" translatable="false"></string> + <string name="fa_circle_xmark" translatable="false"></string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8ae0187..a233096f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -922,4 +922,5 @@ <string name="dim_screen">Dim screen</string> <string name="photo_albums">Photo albums</string> <string name="no_favorites_found">No favorites found!</string> + <string name="remove_from_playlist">Remove from playlist</string> </resources> From 6486bd19eda29007ca731a90a3e420288452e527 Mon Sep 17 00:00:00 2001 From: SergeiAK <17836309+SergeiAK@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:22:02 +0300 Subject: [PATCH 416/475] Fix person credits: movie release dates, full filmography, sorted order (#1950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- 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 Person pages that get a filmography from Seerr had three problems, all in the same place. The release date was taken from `firstAirDate` alone — the field TMDB fills for series — so movie cards showed no year at all. The library person page capped the row at 25 cast + 25 crew while `DiscoverPersonPage` had no cap, because each screen carried its own copy of the same logic. And `cast + crew` left the row in no order a viewer can read, with a title appearing twice when a person is both cast and crew on it. This PR: * maps credit dates as `releaseDate ?: firstAirDate` — the expression the search result mapping already uses; * moves the credits logic into a single `SeerrService.personCredits()` used by both screens, so they cannot drift apart again; * drops the 25 + 25 cap, de-duplicates by TMDB id and sorts by release date descending, with undated entries last. Rows on movie and series pages are untouched: only the `BaseItemKind.PERSON` branch of `similar()` changed. Two things worth your opinion: * Without the cap, the row for a person with a long filmography is long — 135 cards for Cameron Diaz, for example. If you would rather keep a cap, the row could keep the newest 25 and gain a `ViewMoreCard` opening the full grid — `Destination.DiscoveredItem` with a `PERSON` item already routes to `DiscoverPersonPage`. I am happy to add that in a second commit. * The sort is fixed to release date descending. It now lives in one function, so a different key or a user-facing choice would be cheap to add later. ### Related issues Fixes #1873 ### Testing CI in my fork ran the repository's own PR workflow on this branch: `pre-commit` (ktlint) and `assembleDefaultDebug` with `testDefaultDebugUnitTest` are both green. I then installed a debug build of the same code on an NVIDIA Shield TV Pro 2019 (Android 11), against Jellyfin 10.11.11 with Jellyseerr, and opened Cameron Diaz from the cast row of *Charlie's Angels* before and after the change: * movie cards show a year now; before, the only two cards in the row with a year were the two TV entries; * the row is ordered by release date descending, movies and series interleaved, instead of the API order of all cast followed by all crew; * the row is no longer cut at 25 + 25 — it now runs back to her 1952 credit, and the entries TMDB has no date for sit at the very end of the row. Not tested on any other device; nothing in the change is device specific. ## Screenshots Person page of Cameron Diaz, reached from the cast row of *Charlie's Angels*. The library rows above the Discover row are the same in every shot and are useful as a reference: they always had their years. Before, start of the Discover row — no year under any card: <img width="1920" height="1080" alt="pr1873-1-before-start" src="https://github.com/user-attachments/assets/585d0670-ae39-4214-9004-0c92262991da" /> Before, end of the Discover row — the row stops here, and the only two years in it belong to the two TV entries: <img width="1920" height="1080" alt="pr1873-2-before-end" src="https://github.com/user-attachments/assets/85c58575-2ad0-4369-a103-60f04c8d2350" /> After, start of the row — years on the movie cards, ordered by date descending: <img width="1920" height="1080" alt="pr1873-3-after-start" src="https://github.com/user-attachments/assets/804745c5-e1a6-4fe4-93bd-dc6c97a7a876" /> After, end of the row — the list now reaches 1952, with the undated entries last: <img width="1920" height="1080" alt="pr1873-4-after-end" src="https://github.com/user-attachments/assets/2c32389c-ec0b-4df5-b9d1-f564ef10d8b3" /> ## AI or LLM usage The code was written with Claude Opus 5 (Claude Code): the two mapping lines, the extracted `personCredits()` function and the two call sites. I reviewed every line and can explain it. This description was written by me in Russian and translated into English with the same assistant. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Damontecres <damontecres@gmail.com> --- .../wholphin/services/SeerrService.kt | 64 ++++++++++++++----- .../wholphin/ui/detail/PersonPage.kt | 25 +++++++- .../ui/detail/discover/DiscoverPersonPage.kt | 17 +---- .../wholphin/ui/nav/Destination.kt | 2 + .../wholphin/ui/nav/DestinationContent.kt | 1 + 5 files changed, 77 insertions(+), 32 deletions(-) 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 8a283e4e..d60988a2 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 @@ -124,21 +124,7 @@ class SeerrService } BaseItemKind.PERSON -> { - api.personApi - .personPersonIdCombinedCreditsGet(personId = it) - .let { credits -> - val cast = - credits.cast - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - cast + crew - } + personCredits(it) } else -> { @@ -150,6 +136,50 @@ class SeerrService null } + /** + * Get the combined movie & TV credits of a person as [DiscoverItem]s + * + * The two lists returned by the API are merged, de-duplicated by id since a person can be + * both cast and crew on the same title, and sorted by release date descending with undated + * entries last + */ + suspend fun personCredits(personId: Int): List<DiscoverItem> = + api.personApi + .personPersonIdCombinedCreditsGet(personId = personId) + .let { credits -> + val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty() + val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty() + (cast + crew) + .distinctBy { it.id } + .sortedByDescending { it.releaseDate } + } + + /** + * Create a [DiscoverItem] for a person in the library so that the full grid of their + * credits can be opened from their page + * + * @return the item or null if the person has no TMDB id + */ + fun discoverPerson(item: BaseItem): DiscoverItem? = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + DiscoverItem( + id = it, + type = SeerrItemType.PERSON, + title = item.name, + subtitle = null, + overview = item.data.overview, + availability = SeerrAvailability.UNKNOWN, + releaseDate = null, + posterUrl = null, + backDropUrl = null, + logoUrl = null, + jellyfinItemId = null, + ) + } + suspend fun getTvSeries(item: BaseItem): TvDetails? = if (active.first()) { item.data.providerIds @@ -424,7 +454,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, @@ -451,7 +481,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 8c9b05de..27212b5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -157,6 +157,7 @@ class PersonViewModel _state.update { it.copy( discovered = results, + discoverPerson = seerrService.discoverPerson(person), ) } } @@ -221,6 +222,7 @@ data class PersonState( val series: RowLoadingState = RowLoadingState.Pending, val episodes: RowLoadingState = RowLoadingState.Pending, val discovered: List<DiscoverItem> = emptyList(), + val discoverPerson: DiscoverItem? = null, ) @Composable @@ -257,6 +259,12 @@ fun PersonPage( imageType = ImageType.PRIMARY, ) } + // A long filmography is hard to navigate as a row, so it is cut at the same + // limit as the home page rows and the rest is left to the full grid + val maxItems = preferences.appPreferences.homePagePreferences.maxItemsPerRow + val discovered = + remember(state.discovered, maxItems) { state.discovered.take(maxItems) } + PersonPageContent( preferences = preferences, name = name, @@ -276,10 +284,18 @@ fun PersonPage( favoriteOnClick = { viewModel.setFavorite(!person.favorite) }, - discovered = state.discovered, + discovered = discovered, onClickDiscover = { index, item -> viewModel.navigationManager.navigateTo(item.destination) }, + onClickViewMoreDiscover = { + state.discoverPerson?.let { + viewModel.navigationManager.navigateTo( + Destination.DiscoveredItem(it, maxItems), + ) + } + }, + enableViewMoreDiscover = state.discovered.size > discovered.size, modifier = modifier, ) AnimatedVisibility(showOverviewDialog) { @@ -323,6 +339,8 @@ fun PersonPageContent( overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + onClickViewMoreDiscover: () -> Unit, + enableViewMoreDiscover: Boolean, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -437,6 +455,11 @@ fun PersonPageContent( onLongClickItem = { _, _ -> }, onCardFocus = {}, focusRequester = focusRequester, + enableViewMore = enableViewMoreDiscover, + onClickViewMore = { + position = RowColumn(DISCOVER_ROW, discovered.size) + onClickViewMoreDiscover.invoke() + }, ) } } 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 5987b7eb..2ce4a3a9 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 @@ -61,20 +61,7 @@ class DiscoverPersonViewModel viewModelScope.launchIO { backdropService.clearBackdrop() - val credits = - seerrService.api.personApi - .personPersonIdCombinedCreditsGet(personId = item.id) - .let { credits -> - val cast = - credits.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - cast + crew - } + val credits = seerrService.personCredits(item.id) this@DiscoverPersonViewModel.credits.update { DataLoadingState.Success(credits) } @@ -85,6 +72,7 @@ class DiscoverPersonViewModel @Composable fun DiscoverPersonPage( person: DiscoverItem, + startIndex: Int, modifier: Modifier = Modifier, viewModel: DiscoverPersonViewModel = hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>( @@ -137,6 +125,7 @@ fun DiscoverPersonPage( }, onClickPlay = { _, item -> }, + initialPosition = startIndex, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 081b18d0..a65c97e8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -151,6 +151,8 @@ sealed class Destination( @Serializable data class DiscoveredItem( val item: DiscoverItem, + // Index to scroll to on the person page grid + val startIndex: Int = 0, ) : Destination(false) @Serializable 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 f8497a8f..63789d6c 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 @@ -405,6 +405,7 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, + startIndex = destination.startIndex, modifier = modifier, ) } From 616565f671a16e4305e3f3915e1de0c375c5d986 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 11 Sep 2026 15:40:11 -0400 Subject: [PATCH 417/475] Fixes to `v1.0.7` (#1953) ## Description Collection of minor bug fixes for `v1.0.7` - Fixes a possible race condition crash when navigating to the search page - Uses friendlier error message when login fails due to parental restrictions (based on [`jellyfin-web`](https://github.com/jellyfin/jellyfin-web/blob/v10.11.11/src/controllers/session/login/index.js#L38-L41)) - Don't re-sort search results by name, just by relevance scoring - Consume the key event when dim is active (ie if the screen is dimmed, pressing a key will un-dim and not perform another action) ### Related issues Fixes #1922 Fixes #1951 Related to #1848 (search sorting) ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 2 +- .../wholphin/ui/search/SearchPage.kt | 6 ++- .../wholphin/ui/search/SearchViewModel.kt | 6 +-- .../wholphin/ui/setup/SwitchUserContent.kt | 35 ++++++++------ .../wholphin/ui/setup/SwitchUserViewModel.kt | 47 ++++++++++++++++--- app/src/main/res/values/strings.xml | 2 + 6 files changed, 70 insertions(+), 28 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 f7d995ee..62f0e39b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -288,7 +288,7 @@ class MainActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (screensaverService.state.value.show) { + if (screensaverService.state.value.run { show || showDim }) { screensaverService.stop(false) screensaverService.pulse() return true diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt index 1bde1c4c..e353287d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -418,12 +418,16 @@ fun SearchPage( itemsIndexed(state.includedSearchableTypes) { index, type -> val rowIndex = RESULTS_START + index val result = state.results.getOrDefault(type, SearchResult.Searching) + val focusRequester = + remember(state.includedSearchableTypes.size) { + focusRequesters.getOrNull(rowIndex) ?: FocusRequester() + } SearchRowResult( title = type.titleStringRes, result = result, rowIndex = rowIndex, position = position, - focusRequester = focusRequesters[rowIndex], + focusRequester = focusRequester, onClickItem = onClickItem, onLongClickItem = { index, item -> onLongClickItem(rowIndex, index, item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt index 60ba2199..81dfa95f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -273,8 +273,7 @@ class SearchViewModel } val sorted = items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.sortName }, + compareBy<BaseItem> { SearchRelevance.score(it, query) }, ) Timber.v("Search finished for %s, %s results", type, sorted.size) _state.value.results[type] = SearchResult.Success(sorted) @@ -307,8 +306,7 @@ class SearchViewModel } val sorted = items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, + compareBy<BaseItem> { SearchRelevance.score(it, query) }, ) Timber.v("searchCombined complete %s results", sorted.size) _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 22b5a594..86cf718d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -86,6 +86,24 @@ fun SwitchUserContent( showAddUser = false } + fun trySwitchUser(user: JellyfinUser) { + val result = viewModel.trySwitchUser(user) + scope.launch { + when (val r = result.await()) { + is SwitchUserResult.Error -> { + Toast.makeText(context, r.errorMessage, Toast.LENGTH_LONG).show() + if (r.showLogin) { + showAddUserDialog(user) + } + } + + SwitchUserResult.Success -> { + // no-op, view model will navigate + } + } + } + } + LaunchedEffect(state.switchUserState) { if (!showAddUser) { when (val s = state.switchUserState) { @@ -148,13 +166,7 @@ fun SwitchUserContent( } else if (user.hasPin) { switchUserWithPin = user } else { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - } - } + trySwitchUser(user) } }, onAddUser = { @@ -396,14 +408,7 @@ fun SwitchUserContent( }, onTextChange = { if (it == user.pin) { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - switchUserWithPin = null - } - } + trySwitchUser(user) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index a282eb15..c47b86c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.ui.setup +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository @@ -19,6 +21,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job @@ -46,6 +49,7 @@ import kotlin.time.Duration.Companion.seconds class SwitchUserViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, @@ -124,21 +128,38 @@ class SwitchUserViewModel } } - fun trySwitchUser(user: JellyfinUser): Deferred<String?> = + fun trySwitchUser(user: JellyfinUser): Deferred<SwitchUserResult> = viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - null + SwitchUserResult.Success } catch (ex: InvalidStatusException) { - if (ex.status == 401) { - "Credentials expired, please login in again" - } else { - ex.localizedMessage + when (ex.status) { + 401 -> { + Timber.w(ex, "Error switching user 401") + SwitchUserResult.Error( + context.getString(R.string.login_credentials_expired), + true, + ) + } + + 403 -> { + Timber.w(ex, "Error switching user 403") + SwitchUserResult.Error( + context.getString(R.string.login_not_authorized), + false, + ) + } + + else -> { + Timber.e(ex, "Error switching user") + SwitchUserResult.Error(ex.localizedMessage, true) + } } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - ex.localizedMessage + SwitchUserResult.Error(ex.localizedMessage, true) } } @@ -322,3 +343,15 @@ data class SwitchUserState( val serverVersion: String? = null, val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +/** + * Result of trying to switch users via [SwitchUserViewModel.trySwitchUser] + */ +sealed interface SwitchUserResult { + data object Success : SwitchUserResult + + data class Error( + val errorMessage: String?, + val showLogin: Boolean, + ) : SwitchUserResult +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a233096f..cff98add 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -923,4 +923,6 @@ <string name="photo_albums">Photo albums</string> <string name="no_favorites_found">No favorites found!</string> <string name="remove_from_playlist">Remove from playlist</string> + <string name="login_credentials_expired">Credentials expired, please login in again</string> + <string name="login_not_authorized">You are not authorized to access the server at this time</string> </resources> From 4a118b6ddbbbb5b473f3c96011a0ac05781c9b76 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Fri, 11 Sep 2026 15:40:38 -0400 Subject: [PATCH 418/475] Update dependencies (#1952) ## Description Now that the SDK level is updated, many dependencies can be updated as well. This includes a fix from `wholphin-extensions` for a TLS timeout issue on some devices using MPV (https://github.com/damontecres/wholphin-extensions/pull/13). ### Related issues Fixes #1932 ### Testing Just basics on emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- gradle/libs.versions.toml | 40 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff09b53d..e7c6ca92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,41 +1,41 @@ [versions] -aboutLibraries = "14.2.1" +aboutLibraries = "15.2.0" acra = "5.13.1" agp = "9.2.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltNavigationCompose = "1.3.0" -hiltCompiler = "1.3.0" +hiltNavigationCompose = "1.4.0" +hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.4.10" +kotlin = "2.4.20" ksp = "2.3.9" -coreKtx = "1.18.0" -appcompat = "1.7.1" -composeBom = "2026.06.01" +coreKtx = "1.19.0" +appcompat = "1.8.0" +composeBom = "2026.09.00" mockk = "1.14.11" robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.41.0" -okhttpBom = "5.4.0" +multiplatformMarkdownRenderer = "0.45.0" +okhttpBom = "5.5.0" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" tvFoundation = "1.0.0" tvMaterial = "1.1.0-alpha01" -lifecycleRuntimeKtx = "2.10.0" +lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -androidx-media3 = "1.11.0" -coil = "3.5.0" +androidx-media3 = "1.11.1" +coil = "3.6.2" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.3" -lifecycleViewmodelNav3 = "2.10.0" -material3AdaptiveNav3 = "1.0.0-alpha03" +nav3Core = "1.1.7" +lifecycleViewmodelNav3 = "2.11.0" +material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" kotlinx-serialization = "1.11.0" -protobuf-javalite = "4.35.1" -hilt = "2.60" -room = "2.8.4" +protobuf-javalite = "4.36.1" +hilt = "2.60.1" +room = "2.8.5" preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" @@ -43,9 +43,9 @@ paletteKtx = "1.0.0" libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.24.0" +openapi-generator = "7.25.0" runner = "1.7.0" -wholphin-extensions = "0.2.2" +wholphin-extensions = "0.2.4" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" From 074eab22f3a0a6defb8101807219c16b866e0f7b Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 12 Sep 2026 15:14:14 -0400 Subject: [PATCH 419/475] Increase memory for CI builds (#1954) ## Description Increases the memory available for CI builds and splits up builds a bit more There are no user facing changes ### Related issues Recently dev builds failed due to OOM ### Testing https://github.com/damontecres/Wholphin/actions/runs/34710759629 ## Screenshots N/A ## AI or LLM usage None --- .github/workflows/main.yml | 5 +++-- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 75abc88d..3bd2e54d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ concurrency: env: BRANCH_NAME: ${{ github.ref_name }} - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: build: @@ -54,7 +54,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon + ./gradlew clean assembleDefaultDebug + ./gradlew assembleDefaultRelease - name: Verify signatures run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ffb33c03..9e518406 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,7 +9,7 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: pre-commit: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 290d065e..4822ce7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ defaults: env: APK_SHORT_NAME: Wholphin.apk - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" TAG_NAME: ${{ github.ref_name }} jobs: @@ -36,7 +36,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean bundleAppstoreRelease bundleFiretvRelease + ./gradlew clean bundleAppstoreRelease + ./gradlew bundleFiretvRelease ./gradlew assembleDefaultRelease - name: Verify signatures run: | From 98fba1340f5ef748f25e2cd1b931b08a743fd899 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 12 Sep 2026 18:38:54 -0400 Subject: [PATCH 420/475] Fix OS screensaver starting during playback with dim enabled (#1956) ## Description Fixes some devices activating the OS screensaver during playback when the in-app dim was enabled. I was not able to reproduce this on Shield 2017/2019, but could easily on a Fire TV Stick. #1834 added the dim feature and, when enabled, would not pass the keep screen on flag to the OS during playback. It seems maybe regular Android TV's screensaver also listened to active media session to prevent starting but maybe Fire TV OS doesn't. I'm not 100% sure. This PR changes the logic slightly so that the keep screen on flag is always set during playback. ### Related issues Fixes #1894 Related to dim feature from https://github.com/damontecres/Wholphin/pull/1834 ### Testing Emulator, shield 2019, & fire tv stick 4k ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/services/ScreensaverService.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 09aa29a8..c3fcb478 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 @@ -173,9 +173,10 @@ class ScreensaverService */ fun keepScreenOn(keep: Boolean) { scope.launchDefault { - val screensaverEnabled = _state.value.run { enabled || dimEnabled } + val screensaverEnabled = state.value.enabled + val dimEnabled = state.value.dimEnabled Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) - if (screensaverEnabled) { + if (screensaverEnabled || dimEnabled) { // Page is requesting to keep screen on, so we don't wait to show the screensaver _state.update { it.copy( @@ -187,7 +188,9 @@ class ScreensaverService if (!keep) { pulse() } - } else { + } + if (!screensaverEnabled) { + // If in-app screensaver is not enabled, send keep screen on to the OS keepScreenOnInternal(keep) } } From 02163ba47c31d2f5041984576150f4e6bc2837ff Mon Sep 17 00:00:00 2001 From: SergeiAK <sergeiak@noreply.codeberg.org> Date: Sat, 22 Aug 2026 13:13:16 +0000 Subject: [PATCH 421/475] Translated using Weblate (Russian) Currently translated at 65.0% (421 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 135 ++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e83ae0a8..a17f3cbc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -20,7 +20,7 @@ <string name="confirm">Подтвердить</string> <string name="continue_watching">Продолжить просмотр</string> <string name="critic_rating">Оценка критиков</string> - <string name="decimal_seconds">%.1f секунда</string> + <string name="decimal_seconds">%.2f секунд</string> <string name="default_track">По умолчанию</string> <string name="delete">Удалить</string> <string name="died">Умер</string> @@ -124,13 +124,15 @@ <string name="runtime_sort">Продолжительность</string> <string name="extras_title">Дополнительно</string> <plurals name="other_extras"> - <item quantity="one">Другое</item> + <item quantity="one">Другой</item> <item quantity="few">Других</item> <item quantity="many">Других</item> <item quantity="other">Других</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">За кадром</item> + <item quantity="few">За кадром</item> + <item quantity="many">За кадром</item> <item quantity="other">За кадром</item> </plurals> <plurals name="clips"> @@ -272,7 +274,7 @@ <string name="auto_play_next">Автовоспроизведение следующей серии</string> <string name="combine_continue_next_summary">Применяется только к Телешоу</string> <string name="combine_continue_next">Объединить Продолжить просмотр и Далее</string> - <string name="direct_play_ass">Прямое воспроизведение ASS-субтитров</string> + <string name="direct_play_ass">Использовать libaas для ASS-субтитров</string> <string name="direct_play_pgs">Прямое воспроизведение PGS-субтитров</string> <string name="font_size">Размер шрифта</string> <string name="font_color">Цвет щрифта</string> @@ -294,12 +296,16 @@ <item quantity="other">%d объектов</item> </plurals> <plurals name="samples"> - <item quantity="one">Отрывки</item> - <item quantity="other">Отрывки</item> + <item quantity="one">Образец</item> + <item quantity="few">Образца</item> + <item quantity="many">Образцов</item> + <item quantity="other">Образцов</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Дополнительные материалы</item> - <item quantity="other">Дополнительные материалы</item> + <item quantity="one">Дополнительный материал</item> + <item quantity="few">Дополнительных материала</item> + <item quantity="many">Дополнительных материалов</item> + <item quantity="other">Дополнительных материалов</item> </plurals> <string name="player_backend">Выбор плеера</string> <string name="edge_size">Толщина обводки</string> @@ -307,12 +313,16 @@ <string name="max_homepage_items">Максимальное количество элементов в ряду на домашней странице</string> <string name="forced_track">Форсированные</string> <plurals name="theme_songs"> - <item quantity="one">Саундтреки</item> - <item quantity="other">Саундтреки</item> + <item quantity="one">Тематическая песня</item> + <item quantity="few">Тематические песни</item> + <item quantity="many">Тематических песен</item> + <item quantity="other">Тематических песен</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Видеоролики</item> - <item quantity="other">Видеоролики</item> + <item quantity="one">Тематическое видео</item> + <item quantity="few">Тематических видео</item> + <item quantity="many">Тематических видео</item> + <item quantity="other">Тематических видео</item> </plurals> <string name="nav_drawer_pins">Настроить элементы боковой панели</string> <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> @@ -326,8 +336,10 @@ <string name="nav_drawer_switch_on_focus">Смена страниц по выделению</string> <string name="pass_out_protection">Остановка воспроизведения при бездействии</string> <plurals name="shorts"> - <item quantity="one">Короткометражки</item> - <item quantity="other">Короткометражки</item> + <item quantity="one">Короткометражка</item> + <item quantity="few">Короткометражки</item> + <item quantity="many">Короткометражек</item> + <item quantity="other">Короткометражек</item> </plurals> <string name="ends_at">Конец в %1$s</string> <string name="enter_server_address">Введите адрес сервера</string> @@ -416,4 +428,101 @@ <string name="light_gray">Светло-серый</string> <string name="dark_gray">Темно-серый</string> <string name="yellow">Желтый</string> + <plurals name="movies"> + <item quantity="one">Фильм</item> + <item quantity="few">Фильма</item> + <item quantity="many">Фильмов</item> + <item quantity="other">Фильмов</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Сериал</item> + <item quantity="few">Сериала</item> + <item quantity="many">Сериалов</item> + <item quantity="other">Сериалов</item> + </plurals> + <plurals name="people"> + <item quantity="one">Человек</item> + <item quantity="few">Человека</item> + <item quantity="many">Человек</item> + <item quantity="other">Человек</item> + </plurals> + <plurals name="years_old"> + <item quantity="one">%1$d год</item> + <item quantity="few">%1$d года</item> + <item quantity="many">%1$d лет</item> + <item quantity="other">%1$d лет</item> + </plurals> + <string name="profile_specific_settings_from_server">Использовать настройки профиля с сервера</string> + <string name="trailers_title">Трейлеры</string> + <string name="behind_the_scenes_title">За кадром</string> + <string name="theme_songs_title">Тематические Песни</string> + <string name="theme_videos_title">Тематические Видео</string> + <string name="clips_title">Клипы</string> + <string name="deleted_scenes_title">Удаленные Сцены</string> + <string name="interviews_title">Интервью</string> + <string name="scenes_title">Сцены</string> + <string name="require_login">Требуется логин для профиля</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="avc">AVC</string> + <string name="sdr">SDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="sample_rate_unit">Hz</string> + <string name="combined_search_results_on">Показать все результаты в одном списке</string> + <string name="combined_search_results_off">Показать результаты по категориям</string> + <string name="show_voice_search_button">Показать кнопку голосового ввода</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> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Прямое воспроизведение Dolby Vision Profile 7</string> + <string name="server_version_not_supported">Серверная версия не поддерживается</string> + <string name="favorite_items_title">Избранное %s</string> + <string name="disable_audio_offload_summary">Отключение выгрузки аудио-декодирования может исправить фризы воспроизведения на некоторых устройствах</string> + <string name="nal">NAL</string> + <string name="show_titles">Показывать названия</string> + <string name="live_tv_repeat">Повтор</string> + <string name="favorite_channels_at_beginning">Сначала показывать избранные каналы</string> + <string name="sort_channels_recently_watched">Сортировка каналов по недавно просмотренным</string> + <string name="backdrop_display">Стиль фона</string> + <string name="combined_search_results">Комбинированные результаты поиска</string> + <string name="visible_ui">Видимый</string> + <string name="hidden_ui">Скрытый</string> + <string name="results">Результаты</string> + <string name="resolution_switching">Переключение разрешения</string> + <string name="local">Локальный</string> + <string name="clear_track_choices">Очистить выбор дорожки</string> + <string name="force_dovi_profile_7_summary">Игнорировать проверки совместимости устройства</string> + <string name="discover">Обзор</string> + <string name="request">Запрос</string> + <string name="pending">В ожидании</string> + <string name="remove_seerr_server">Удалить сервер Seerr</string> + <string name="seerr_server_added">Сервер Seerr добавлен</string> + <string name="quick_connect">Быстрое Поключение</string> + <string name="quick_connect_summary">Авторизовать другое устройство для поключения к вашей учетной записи</string> + <string name="quick_connect_code">Ввести код Быстрого Подключения</string> + <string name="quick_connect_code_error">В коде должно быть 6 цифр</string> + <string name="quick_connect_success">Устройство авторизовано успешно</string> + <string name="url">URL</string> + <string name="trending">В тренде</string> + <string name="upcoming_tv">Ожидаемые сериалы</string> + <string name="upcoming_movies">Ожидаемые фильмы</string> + <string name="request_4k">Запросить в 4K</string> + <string name="software_decoding_av1">Программное декодирование AV1</string> + <string name="upgrade_mpv_toast">MPV сейчас плеер по умолчанию исключая HDR.\nВы можете изменить это в настройках.</string> + <string name="play_slideshow">Воспроизвести слайдшоу</string> + <string name="stop_slideshow">Остановить слайдшоу</string> + <string name="no_more_images">Больше нет фото</string> + <string name="rotate_left">Повернуть влево</string> + <string name="rotate_right">Повернуть вправо</string> + <string name="zoom_in">Приблизить</string> + <string name="zoom_out">Отдалить</string> + <string name="slideshow_duration">Продолжитьльность слайдшоу</string> + <string name="play_videos_during_slideshow">Воспроизводить видео во время слайдшоу</string> + <string name="send_media_info_log_to_server">Отправить лог информации о медиа на сервер</string> + <string name="no_limit">Без ограничения</string> </resources> From f7c391ad54877bb0f2eb0c03ccb41c5f45cff0d0 Mon Sep 17 00:00:00 2001 From: jatag41526 <jatag41526@noreply.codeberg.org> Date: Sat, 22 Aug 2026 07:03:10 +0000 Subject: [PATCH 422/475] Translated using Weblate (Russian) Currently translated at 65.0% (421 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a17f3cbc..3fa698a2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -525,4 +525,7 @@ <string name="play_videos_during_slideshow">Воспроизводить видео во время слайдшоу</string> <string name="send_media_info_log_to_server">Отправить лог информации о медиа на сервер</string> <string name="no_limit">Без ограничения</string> + <string name="hdr">HDR</string> + <string name="blur">Размытие</string> + <string name="layout">Расположение</string> </resources> From f3239c7b15b69cf16fc13d3d3d2ff3a66f043073 Mon Sep 17 00:00:00 2001 From: true-seeker <true-seeker@noreply.codeberg.org> Date: Sat, 22 Aug 2026 07:02:48 +0000 Subject: [PATCH 423/475] Translated using Weblate (Russian) Currently translated at 65.0% (421 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3fa698a2..ba8bb7b3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -528,4 +528,6 @@ <string name="hdr">HDR</string> <string name="blur">Размытие</string> <string name="layout">Расположение</string> + <string name="ffmpeg_never">Никогда не использовать FFmpeg</string> + <string name="ffmpeg_fallback">Использовать FFmpeg только, если не существует встроенных декодеров</string> </resources> From d6eb23d29f8b362e2143b336972396d53901477e Mon Sep 17 00:00:00 2001 From: olliveiraanderson <olliveiraanderson@noreply.codeberg.org> Date: Sun, 23 Aug 2026 00:11:30 +0000 Subject: [PATCH 424/475] Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.1% (622 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 37217d9f..e42abda3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -655,4 +655,66 @@ <string name="duration_seconds">%1$ds</string> <string name="profile_specific_settings_from_server">Configuração de Perfil de Usuário do servidor</string> <string name="time_left">%1$s left</string> + <string name="featurettes_title">Conteúdos extras</string> + <string name="shorts_title">Curtos</string> + <plurals name="trailers"> + <item quantity="one">Trailer</item> + <item quantity="many">Trailers</item> + <item quantity="other">Trailers</item> + </plurals> + <string name="require_login">Exigir login para o perfil</string> + <string name="pin_digits_hint">0-9</string> + <string name="disable_audio_offload">Desativar aceleração de hardware de áudio</string> + <string name="disable_audio_offload_summary">Desativar a aceleração de hardware de áudio pode corrigir travamentos durante a reprodução em alguns dispositivos</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para áudio surround</string> + <string name="brown">Marrom</string> + <string name="user_interface_language">Idioma de exibição</string> + <string name="display_toggles_title">Exibir classificações</string> + <string name="display_toggles_summary">Personalizar itens exibidos</string> + <string name="profile_protection">Proteção do perfil</string> + <string name="continue_watching_click_behavior">Clique em Continuar assistindo/Próximo</string> + <string name="continue_watching_click_summary_on">Reproduzir ao clicar</string> + <string name="continue_watching_click_summary_off">Mostrar detalhes ao clicar</string> + <string name="stars">Estrelas</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Dividir em linhas separadas</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Pasta raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Status de produção</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Configurações experimentais</string> + <string name="confirm_enable_experimental_title">Ativar configurações experimentais?</string> + <string name="confirm_enable_experimental_body">Configurações experimentais podem ser instáveis!\n\nElas podem ser removidas ou alteradas em qualquer versão.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="d_pad_seek_mode_title">Modo da barra de busca com D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Navegação rápida</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progresso pequena na parte inferior da tela (padrão)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progresso com imagens de pré-visualização do Trickplay</string> + <string name="dpad_seek_mode_none_summary">Sem barra de progresso</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legenda</string> + <string name="use_user_profile">Usar configuração do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Somente forçadas</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhuma</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma preferido do áudio</string> + <string name="override_user_profile_settings">Mais configurações do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as configurações do seu perfil definidas pelo servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> </resources> From 9602b4f3f0f97122a18d93391255b70a08954f30 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sun, 23 Aug 2026 08:20:13 +0000 Subject: [PATCH 425/475] Translated using Weblate (Swedish) Currently translated at 90.5% (586 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4f29ade6..655d33ac 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -346,7 +346,7 @@ <string name="refresh_rate_switching">Växling av uppdateringsfrekvens</string> <string name="ref_frames">Referensbilder</string> <string name="red">Röd</string> - <string name="resolution_switching">Växling av uppdateringsfrekvens</string> + <string name="resolution_switching">Växling av upplösning</string> <string name="retry">Försök igen</string> <string name="rewatch_next_up">Tillåt omvisning i “Nästa upp”</string> <string name="api_key">API nyckel</string> From 048a5f35b68ca2e555e91080ec3c3f4a2ca81e78 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Sun, 23 Aug 2026 20:02:01 +0000 Subject: [PATCH 426/475] Translated using Weblate (Italian) Currently translated at 100.0% (647 of 647 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 973cde7a..21705315 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -726,4 +726,7 @@ <string name="appears_on">Appare in</string> <string name="disable_audio_offload">Disabilita offload hardware audio</string> <string name="disable_audio_offload_summary">Disabilitare l’offload della decodifica audio può risolvere i blocchi di riproduzione su alcuni dispositivi</string> + <string name="dim_after_delay">Scurisci lo schermo dopo un ritardo</string> + <string name="dim_percentage">Percentuale di oscuramento</string> + <string name="dim_screen">Scurisci schermo</string> </resources> From db7cfaee9b06a3ed879af5c124137ba9fde9c0d5 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Mon, 24 Aug 2026 04:06:45 +0000 Subject: [PATCH 427/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (647 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 59c9fb5a..81213002 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -726,4 +726,7 @@ <string name="appears_on">מופיע ב־</string> <string name="disable_audio_offload_summary">השבתת פענוח אודיו בהורדה עשויה לתקן קפיאות בניגון במכשירים מסוימים</string> <string name="disable_audio_offload">השבתת פענוח אודיו בהורדה</string> + <string name="dim_after_delay">עמעם תצוגה אחרי השהייה</string> + <string name="dim_percentage">עוצמת העמעום</string> + <string name="dim_screen">עמעם תצוגה</string> </resources> From 4f4e13723c0634721256b95701143d2b0c81088c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Mon, 24 Aug 2026 15:26:06 +0000 Subject: [PATCH 428/475] Translated using Weblate (Estonian) Currently translated at 100.0% (647 of 647 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4ce7a329..a1576ce9 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -704,4 +704,7 @@ <string name="appears_on">On osalenud siin:</string> <string name="disable_audio_offload">Lülita graafikaprotsessori kasutamine heli töötluseks välja</string> <string name="disable_audio_offload_summary">Kui lülita graafikaprotsessori kasutamise heli töötluseks välja, siis see võib mõnede seadmete puhul parandada taasesituse hangumise vead</string> + <string name="dim_after_delay">Hämarda ekraani peale viivitust</string> + <string name="dim_percentage">Hämardamise protsent</string> + <string name="dim_screen">Hämarda ekraani</string> </resources> From 8931bf5d7a67c651fe04d9296698380cab94695f Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Thu, 27 Aug 2026 12:38:38 +0000 Subject: [PATCH 429/475] Translated using Weblate (Spanish) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0c739c8a..582a4405 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -726,4 +726,9 @@ <string name="appears_on">Aparece en</string> <string name="disable_audio_offload">Desactivar el procesamiento de audio por hardware</string> <string name="disable_audio_offload_summary">Desactivar el procesamiento de audio por hardware puede solucionar los bloqueos de reproducción en algunos dispositivos</string> + <string name="dim_after_delay">Atenuar la pantalla tras un tiempo de inactividad</string> + <string name="dim_percentage">Porcentaje de atenuación</string> + <string name="dim_screen">Atenuar pantalla</string> + <string name="photo_albums">Álbumes de fotos</string> + <string name="no_favorites_found">¡No se encontraron favoritos!</string> </resources> From 0b275a2daa848f1f8eebc22cb552e37b4480fc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Thu, 27 Aug 2026 08:27:33 +0000 Subject: [PATCH 430/475] Translated using Weblate (Estonian) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index a1576ce9..380be3e5 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -707,4 +707,6 @@ <string name="dim_after_delay">Hämarda ekraani peale viivitust</string> <string name="dim_percentage">Hämardamise protsent</string> <string name="dim_screen">Hämarda ekraani</string> + <string name="photo_albums">Fotoalbumid</string> + <string name="no_favorites_found">Ühtegi lemmikut ei leidu!</string> </resources> From c0401914a749932709dc15a9edac18f2c24597b8 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Thu, 27 Aug 2026 17:44:17 +0000 Subject: [PATCH 431/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 673402e3..543994c8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -685,4 +685,6 @@ <string name="dim_after_delay">閒置後調暗螢幕</string> <string name="dim_percentage">調暗程度</string> <string name="dim_screen">螢幕調暗</string> + <string name="no_favorites_found">未添加任何最愛項目!</string> + <string name="photo_albums">相簿</string> </resources> From 5a9e5c406ff405d3baf382cee93ac014b9bfb7df Mon Sep 17 00:00:00 2001 From: Outbreak2096 <outbreak2096@noreply.codeberg.org> Date: Thu, 27 Aug 2026 02:52:53 +0000 Subject: [PATCH 432/475] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (649 of 649 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 613a5cbd..a8f40586 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -685,4 +685,6 @@ <string name="dim_after_delay">延迟后调暗屏幕</string> <string name="dim_percentage">调暗百分比</string> <string name="dim_screen">调暗屏幕</string> + <string name="photo_albums">相册</string> + <string name="no_favorites_found">未找到收藏!</string> </resources> From e73d0961abf107a2f8ec005305c1b004114f3d59 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Fri, 28 Aug 2026 16:49:35 +0000 Subject: [PATCH 433/475] Translated using Weblate (Italian) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 21705315..20e750ad 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -729,4 +729,6 @@ <string name="dim_after_delay">Scurisci lo schermo dopo un ritardo</string> <string name="dim_percentage">Percentuale di oscuramento</string> <string name="dim_screen">Scurisci schermo</string> + <string name="photo_albums">Album fotografici</string> + <string name="no_favorites_found">Nessun preferito trovato!</string> </resources> From cbebc9e63ce2e87c7280915b5e40ae55e8676d65 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Sat, 29 Aug 2026 08:35:31 +0000 Subject: [PATCH 434/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 81213002..ddd2b700 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -729,4 +729,6 @@ <string name="dim_after_delay">עמעם תצוגה אחרי השהייה</string> <string name="dim_percentage">עוצמת העמעום</string> <string name="dim_screen">עמעם תצוגה</string> + <string name="photo_albums">אלבומי תמונות</string> + <string name="no_favorites_found">לא נמצאו מועדפים!</string> </resources> From 0398f3f68953c85f684762495551395e9a7251bf Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Sat, 29 Aug 2026 20:05:45 +0000 Subject: [PATCH 435/475] Translated using Weblate (Slovak) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index bd4c36d9..23af0764 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -751,4 +751,6 @@ <string name="dim_after_delay">Stlmiť obrazovku po uplynutí času</string> <string name="dim_percentage">Úroveň stlmenia</string> <string name="dim_screen">Stlmenie obrazovky</string> + <string name="photo_albums">Fotoalbumy</string> + <string name="no_favorites_found">Nenašli sa žiadne obľúbené položky!</string> </resources> From cf83266e4a00048471994a9020f5a2c730439442 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Mon, 31 Aug 2026 08:28:21 +0000 Subject: [PATCH 436/475] Translated using Weblate (Swedish) Currently translated at 90.4% (587 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 655d33ac..f50e2bee 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -648,4 +648,5 @@ <string name="brown">Brun</string> <string name="show_visualizer">Visa visualizer</string> <string name="show_backdrop">Visa bakgrund</string> + <string name="player_backend">Uppspelning backend</string> </resources> From 7f587b9f50acc3eda8d970ae079c7325d328c309 Mon Sep 17 00:00:00 2001 From: brdaustra <brdaustra@noreply.codeberg.org> Date: Sun, 30 Aug 2026 15:28:23 +0000 Subject: [PATCH 437/475] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 99.3% (645 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nb_NO/ --- app/src/main/res/values-nb-rNO/strings.xml | 372 ++++++++++++++++++++- 1 file changed, 358 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 9ddb6c77..9e26efcc 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -23,7 +23,7 @@ <string name="confirm">Bekreft</string> <string name="continue_watching">Fortsett å se</string> <string name="critic_rating">Kritikervurdering</string> - <string name="decimal_seconds">%.1f sekunder</string> + <string name="decimal_seconds">%.2f sekunder</string> <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Oppdagede servere</string> @@ -120,7 +120,7 @@ <string name="switch_user">Bytt bruker</string> <string name="top_unwatched">Best vurderte usette</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> + <item quantity="one">Trailer</item> <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> @@ -137,6 +137,7 @@ <string name="videos">Videoer</string> <string name="watch_live">Se direkte</string> <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> <item quantity="other">%1$d år gammel</item> </plurals> <string name="play_with_transcoding">Spill av med transkoding</string> @@ -163,39 +164,39 @@ <item quantity="other">Bak kulissene</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasanger</item> - <item quantity="other">Temasanger</item> + <item quantity="one">Kjenningsmelodi</item> + <item quantity="other">Kjenningsmelodier</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> - <item quantity="other">Temavideoer</item> + <item quantity="one">Kjenningsvideo</item> + <item quantity="other">Kjenningsvideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> + <item quantity="one">Slettet scene</item> <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> - <item quantity="one">Intervjuer</item> + <item quantity="one">Intervju</item> <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> + <item quantity="one">Scene</item> <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Smakebiter</item> + <item quantity="one">Smakebit</item> <item quantity="other">Smakebiter</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Bakomfilmer</item> - <item quantity="other">Bakomfilmer</item> + <item quantity="one">Novellefilm</item> + <item quantity="other">Novellefilmer</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilmer</item> + <item quantity="one">Kortfilm</item> <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> @@ -223,7 +224,7 @@ <string name="check_for_updates">Sjekk etter oppdateringer</string> <string name="combine_continue_next_summary">Gjelder kun TV-serier</string> <string name="combine_continue_next">Slå sammen \'Fortsett å se\' og \'Neste\'</string> - <string name="direct_play_ass">Direkteavspilling av ASS-undertekster</string> + <string name="direct_play_ass">Bruk libass for ASS-undertekster</string> <string name="direct_play_pgs">Direkteavspilling av PGS-undertekster</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> <string name="ffmpeg_extension_pref">Bruk FFmpeg-dekodermodul</string> @@ -365,4 +366,347 @@ <string name="play_trailer">Spill trailer</string> <string name="no_trailers">Ingen trailere</string> <string name="clear_track_choices">Fjern valg av spor</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dmin</string> + <string name="duration_minutes_seconds">%1$dmin %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dmin</string> + <string name="duration_seconds">%1$ds</string> + <string name="only_forced_subtitles">Bare Tvungne Undertekster</string> + <string name="profile_specific_settings_from_server">Brukerprofil instillinger fra server</string> + <string name="voice_search">Stemmesøk</string> + <string name="voice_starting">Begynner</string> + <string name="voice_search_prompt">Snakk for å søke</string> + <string name="processing">Behandler</string> + <string name="press_back_to_cancel">Trykk tilbake for å avbryte</string> + <string name="voice_error_audio">Lydopptaksfeil</string> + <string name="voice_error_client">Stemmegjenkjenningsfeil</string> + <string name="voice_error_permissions">Mikrofonadgang nødvendig</string> + <string name="voice_error_network">Nettverksfeil</string> + <string name="voice_error_network_timeout">Nettverksavbrudd</string> + <string name="voice_error_no_match">Ingen tale gjenkjent</string> + <string name="voice_error_busy">Stemmegjenkjenning opptatt</string> + <string name="voice_error_server">Tjenerfeil</string> + <string name="voice_error_speech_timeout">Ingen tale oppdaget</string> + <string name="voice_error_unknown">Ukjent feil</string> + <string name="voice_error_start_failed">Feil ved oppstart av stemmegjenkjenning</string> + <string name="voice_error_timeout">Tidsavbrudd ved stemmegjenkjenning</string> + <string name="retry">Prøv igjen</string> + <string name="time_left">%1$s gjenstår</string> + <string name="trailers_title">Trailere</string> + <string name="other_extras_title">Annet</string> + <string name="behind_the_scenes_title">Bak kulissene</string> + <string name="theme_songs_title">Kjenningsmelodier</string> + <string name="theme_videos_title">Kjenningsvideoer</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Slettede Scener</string> + <string name="interviews_title">Intervju</string> + <string name="scenes_title">Scener</string> + <string name="samples_title">Prøver</string> + <string name="featurettes_title">Novellefilmer</string> + <string name="shorts_title">Kortfilmer</string> + <string name="favorite_items_title">Favoritt %s</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dager</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minutt</item> + <item quantity="other">%s minutter</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="require_login">Krev innlogging for profil</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="disable_audio_offload">Deaktiver avlasting av lydmaskinvare</string> + <string name="disable_audio_offload_summary">Deaktivering av lyddekodingavlasting kan fikse fryst bilde ved avspilling på noen enheter</string> + <string name="combined_search_results">Kombinert søkeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i én liste</string> + <string name="combined_search_results_off">Vis resultater etter kategori</string> + <string name="show_voice_search_button">Vis knapp for stemmesøk</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</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> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorer kompatibilitetsjekk av enheten</string> + <string name="discover">Oppdag</string> + <string name="request">Forespørsel</string> + <string name="pending">Venter</string> + <string name="seerr_integration">Seerr integrering</string> + <string name="remove_seerr_server">Fjern Seerr Tjener</string> + <string name="seerr_server_added">Seerr tjener lagt til</string> + <string name="quick_connect">Rask Tilkobling</string> + <string name="quick_connect_summary">Autoriser annen enhet til å logge på kontoen din</string> + <string name="for_episodes">For episoder</string> + <string name="suggestions_for">Forslag for %1$s</string> + <string name="increase_all_cards_size">Øk størrelsen for alle kort</string> + <string name="decrease_all_cards_size">Reduser størrelsen for alle kort</string> + <string name="use_thumb_images">Bruk miniatyrbilder</string> + <string name="settings_saved">Instillinger lagret</string> + <string name="display_presets">Vis forhåndsinnstillinger</string> + <string name="display_presets_description">Innebygde forhåndsinnstillinger for å raskt style alle rader</string> + <string name="customize_home_summary">Velg rader og bilder på hjemmesiden</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode ikke-stereo lyd til AV3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Bruk AC3 for surround sound lyd</string> + <string name="start_after">Start etter</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Maks aldersgrense</string> + <string name="no_max">Ingen maks</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Opp til %s år</string> + <string name="screensaver">Skjermsparer</string> + <string name="screensaver_settings">Innstillinger for skjermsparer</string> + <string name="start_screensaver">Start skjermsparer</string> + <string name="duration">Varighet</string> + <string name="photos">Bilder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke elementer å vise bilder for</string> + <string name="content_scale_fit">Tilpass</string> + <string name="content_scale_crop">Beskjær</string> + <string name="content_scale_fill">Fyll</string> + <string name="content_scale_fill_width">Fyll bredde</string> + <string name="content_scale_fill_height">Fyll høyde</string> + <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_series_thumb">Serieminiatyrbilder</string> + <string name="display_preset_episode_thumbnails">Episodeminiatyrbilder</string> + <string name="volume_lowest">Lavest</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Middels</string> + <string name="volume_high">Høy</string> + <string name="volume_full">Full</string> + <string name="purple">Lilla</string> + <string name="orange">Oransje</string> + <string name="bold_blue">Dristig Blå</string> + <string name="black">Svart</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Hopp over automatisk</string> + <string name="skip_ask">Spør om å hoppe over</string> + <string name="ffmpeg_fallback">Bare bruk FFmpeg dersom ingen innebygd dekoder eksisterer</string> + <string name="ffmpeg_prefer">Foretrekk å bruke FFmpeg over innebygde dekodere</string> + <string name="ffmpeg_never">Aldri bruk FFmpeg dekodere</string> + <string name="next_up_playback_end">Ved slutten av avspilling</string> + <string name="next_up_outro">Under rulletekst/avslutning</string> + <string name="next_up_never">Aldri</string> + <string name="white">Hvit</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Kant</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Innpakning</string> + <string name="background_style_boxed">Innebokset</string> + <string name="prefer_mpv">Foretrekk MPV</string> + <string name="external_player">Ekstern Spiller</string> + <string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for avspilling av HDR</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Primær</string> + <string name="image_type_thumb">Miniatyr</string> + <string name="view_options_type_grid">Rutenett</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt Liste</string> + <string name="backdrop_style_dynamic">Bilde med dynamisk farge</string> + <string name="backdrop_style_image">Bare bilde</string> + <string name="enter_url_api_key"><![CDATA[Skriv inn URL & API-nøkkel]]></string> + <string name="api_key">API-nøkkel</string> + <string name="seerr_login">Logg inn på Seerr-tjener</string> + <string name="seerr_jellyfin_user">Jellyfinbruker</string> + <string name="seerr_local_user">Lokal bruker</string> + <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekst]]></string> + <string name="no_subtitles_found">Ingen eksterne undertekster ble funnet</string> + <string name="series_continueing">Nåværende</string> + <string name="in_app_screensaver">Bruk appens skjermsparer</string> + <string name="delete_item">Er du sikker på at du vil slette dette elementet?</string> + <string name="show_media_management">Vis alternativ for behandling av medier</string> + <string name="search_for">Søk %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Forfatter</string> + <string name="guest_star">Gjesteartist</string> + <string name="producer">Produsent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Sangtekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Ingeniør</string> + <string name="mixer">Lydmikser</string> + <string name="creator">Skaper</string> + <string name="artist">Artist</string> + <string name="album_artist">Albumartist</string> + <string name="album">Album</string> + <string name="popular_songs">Populære sanger</string> + <string name="play_next">Spill av neste</string> + <string name="music_videos">Musikkvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Låt har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumomslag</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis bakgrunn</string> + <string name="play_as_type">Spill av som?</string> + <string name="visualizer_rationale">Visualisering av lyden som spilles av, krever tillatelse til å ta opp lyd.</string> + <string name="continue_string">Fortsett</string> + <string name="select_all">Velg alle</string> + <string name="separate_types">Seperate typer</string> + <string name="prefer_logos">Foretrekk å vise logoer for titler</string> + <string name="updated_toast">Wholphin oppdatert til %s</string> + <string name="remove_continue_watching">Fjern fra fortsett å se</string> + <string name="remove_next_up">Fjern serier fra neste</string> + <string name="view_removed_next_up">Vis serier fjernet fra neste</string> + <string name="view_more">Vis mer</string> + <string name="discover_tv">Oppdag TV-serier</string> + <string name="discover_movies">Oppdag Filmer</string> + <string name="studios_in">Studioer i %1$s</string> + <string name="last_played">Sist avspilt</string> + <string name="system_default">System standard</string> + <string name="ass_subtitle_mode_libass">Direkteavspilling med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkteavspilling med ExoPlayer innebygd</string> + <string name="ass_subtitle_mode_transcode">Brenn/transkode på tjener</string> + <string name="ass_subtitle_playback">SSA/ASS avspilling av undertekst</string> + <string name="cinema_mode">Kinomodus</string> + <string name="show_more">Vis %1$s mer</string> + <string name="skip_behavior">Hopp over oppførsel</string> + <string name="skip_behavior_summary">For introer, rulletekst, etc</string> + <string name="play_with">Spill med</string> + <string name="transcoding">Transkoding</string> + <string name="user_interface_language">Språk på brukergrensesnitt</string> + <string name="display_toggles_title">Visning av anmeldelser</string> + <string name="display_toggles_summary">Tilpass hva å vise</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Fortsett å se/Neste klikkoppførsel</string> + <string name="continue_watching_click_summary_on">Spill av ved klikk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved klikk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sorter</string> + <string name="split_into_separate_rows">Del inn i separate rader</string> + <string name="genre_movies">%1$s Filmer</string> + <string name="genre_tv_shows">%1$s TV-Serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Root-mappe</string> + <string name="popularity">Populæritet</string> + <string name="production_status">Produksjonstatus</string> + <string name="different_for_hdr">Bruk annen stil for HDR</string> + <string name="experimental_settings">Eksperimentelle innstillinger</string> + <string name="confirm_enable_experimental_title">Aktiver eksperimentelle innstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle innstillinger kan være ustabile!\n\nDisse kan fjernes eller endre i hvilken som helst versjon.</string> + <string name="quick_connect_code">Skriv inn Quick Connect kode</string> + <string name="quick_connect_code_error">Kode må være 6 siffer</string> + <string name="quick_connect_success">Enhet autorisering vellykket</string> + <string name="password">Passord</string> + <string name="username">Brukernavn</string> + <string name="url">URL</string> + <string name="trending">Trender</string> + <string name="upcoming_movies">Kommende Filmer</string> + <string name="upcoming_tv">Kommende TV-Serier</string> + <string name="request_4k">Forespør i 4K</string> + <string name="software_decoding_av1">AV1 programvaredekoding</string> + <string name="upgrade_mpv_toast">MPV er nå standardspiller utenom for HDR.\nDu kan endre dette i innstillinger.</string> + <string name="hdr_subtitle_style">HDR undertekststil</string> + <string name="image_subtitle_opacity">Bildeundertekst ugjennomsiktighet</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Metning</string> + <string name="hue">Tone</string> + <string name="red">Rød</string> + <string name="green">Grønn</string> + <string name="blue">Blå</string> + <string name="blur">Uklarhet</string> + <string name="save_for_album">Lagre for album</string> + <string name="play_slideshow">Start lysbildefremvisning</string> + <string name="stop_slideshow">Stans lysbildefremvisning</string> + <string name="slideshow_at_beginning">Ved start</string> + <string name="no_more_images">Ingen flere bilder</string> + <string name="rotate_left">Roter venstre</string> + <string name="rotate_right">Roter høyre</string> + <string name="zoom_in">Zoom inn</string> + <string name="zoom_out">Zoom ut</string> + <string name="slideshow_duration">Lysbildefremvisningens varighet</string> + <string name="play_videos_during_slideshow">Spill av video under lysbildefremvisning</string> + <string name="send_media_info_log_to_server">Send mediainformasjonslogg til tjener</string> + <string name="no_limit">Ingen grense</string> + <string name="max_days_next_up">Maks dager i Neste</string> + <string name="albums">Album</string> + <string name="artists">Artister</string> + <string name="songs">Sanger</string> + <string name="go_to_artist">Gå til artist</string> + <string name="now_playing">Spilles nå</string> + <string name="instant_mix">Øyeblikkelig miks</string> + <string name="go_to_album">Gå til album</string> + <string name="add_to_queue">Legg til i kø</string> + <string name="add_row">Legg til rad</string> + <string name="genres_in">Sjangre i %1$s</string> + <string name="recently_released_in">Nylig utgitt i %1$s</string> + <string name="height">Høyde</string> + <string name="apply_all_rows">Bruk på alle rader</string> + <string name="customize_home">Tilpass hjemmesiden</string> + <string name="home_rows">Hjemmerader</string> + <string name="load_from_server">Last inn fra tjeners brukerprofil</string> + <string name="save_to_server">Lagre på tjeners brukerprofil</string> + <string name="load_from_web_client">Last inn fra web-client</string> + <string name="use_series">Bruk seriebilde</string> + <string name="add_row_for">Legg til rad for %1$s</string> + <string name="overwrite_server_settings">Overskriv innstillinger på tjener?</string> + <string name="overwrite_local_settings">Overskriv lokale innstillinger?</string> + <string name="video_tunneling">Videotunnelering</string> + <string name="d_pad_seek_mode_title">D-Pad søkelinjemodus</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Lurespilling</string> + <string name="dpad_seek_mode_minimal_summary">Liten søkelinje langs bunnen av skjermen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Søkelinje med lurespilling forhåndsvisningsbilde</string> + <string name="dpad_seek_mode_none_summary">Ingen søkelinje</string> + <string name="blocklisted">Blokkeringslistet</string> + <string name="unavailable">Utilgjengelig</string> + <string name="subtitle_mode">Undertekstmodus</string> + <string name="use_user_profile">Din brukerprofilinnstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Bare Tvunget</string> + <string name="subtitle_mode_always">Alltid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstspråk</string> + <string name="preferred_audio_language">Foretrukket lydspråk</string> + <string name="override_user_profile_settings">Flere brukerprofilinnstillinger</string> + <string name="override_user_profile_settings_summary">Overskriv brukerprofilinnstillingene dine fra tjeneren</string> + <string name="any_language">Hvilket som helst språk</string> + <string name="hash_matches">Hash-matcher</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-program</string> + <string name="recordings">Opptak</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brukervisninger</string> + <string name="books">Bøker</string> + <string name="audio_books">Lydbøker</string> + <string name="server_version_not_supported">Tjenerversjon ikke støttet</string> + <string name="appears_on">Opptrer på</string> + <string name="dim_after_delay">Dim skjerm etter forsinking</string> + <string name="dim_percentage">Dim prosent</string> + <string name="dim_screen">Dim skjerm</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Ingen favoritter funnet!</string> </resources> From c8d202c3fae976eabeedb6d90e00c9870a339222 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Sun, 30 Aug 2026 12:18:06 +0000 Subject: [PATCH 438/475] Translated using Weblate (Polish) Currently translated at 99.6% (647 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 796d5fd7..30e8bffa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -749,4 +749,6 @@ <string name="dim_screen">Przyciemnienie ekranu</string> <string name="dim_after_delay">Opóźnienie przyciemniana ekranu</string> <string name="dim_percentage">Procent przyciemniane</string> + <string name="photo_albums">Albumy zdjęć</string> + <string name="no_favorites_found">Nic jeszcze nie polubiono!</string> </resources> From de6cf7fb59959146fb04d7bcf0dac73002d8feef Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Tue, 1 Sep 2026 15:52:22 +0000 Subject: [PATCH 439/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (649 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f7878d42..dfa96fd1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -726,4 +726,9 @@ <string name="appears_on">Aparece em</string> <string name="disable_audio_offload">Desativar a processamento de áudio por hardware</string> <string name="disable_audio_offload_summary">Desativar o processamento de áudio por hardware pode resolver os bloqueios de reprodução em alguns dispositivos</string> + <string name="dim_after_delay">Escurecimento do ecrã após tempo de inatividade</string> + <string name="dim_percentage">Percentagem do escurecimento</string> + <string name="dim_screen">Escurecer ecrã</string> + <string name="photo_albums">Álbum de fotos</string> + <string name="no_favorites_found">Não foram encontrados favoritos!</string> </resources> From 8c2cbcb7b210acbf2311cd34062cf69d10c84a39 Mon Sep 17 00:00:00 2001 From: Andi Chandler <andi@gowling.com> Date: Tue, 1 Sep 2026 17:29:51 +0000 Subject: [PATCH 440/475] Translated using Weblate (English (United Kingdom)) Currently translated at 7.8% (51 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/en_GB/ --- app/src/main/res/values-en-rGB/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 417bac6b..4e3381c0 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -36,4 +36,14 @@ <string name="skip_behavior">Skip behaviour</string> <string name="display_toggles_summary">Customise which to display</string> <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> + <string name="about">About</string> + <string name="active_recordings">Active Recordings</string> + <string name="add_server">Add Server</string> + <string name="add_user">Add User</string> + <string name="audio">Audio</string> + <string name="birthplace">Birthplace</string> + <string name="bitrate">Bitrate</string> + <string name="born">Born</string> + <string name="cancel_recording">Cancel Recording</string> + <string name="cancel_series_recording">Cancel Series Recording</string> </resources> From 2ade8173ff1bffd0731e087bbfced49bf0e4103b Mon Sep 17 00:00:00 2001 From: alexkahler <alexkahler@noreply.codeberg.org> Date: Tue, 1 Sep 2026 20:59:07 +0000 Subject: [PATCH 441/475] Translated using Weblate (Danish) Currently translated at 99.8% (648 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ad04be00..870d8c85 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -707,4 +707,6 @@ <string name="dim_after_delay">Dæmp skærmen efter ventetid</string> <string name="dim_percentage">Dæmpning (%)</string> <string name="dim_screen">Dæmp skærmen</string> + <string name="photo_albums">Fotoalbummer</string> + <string name="no_favorites_found">Ingen favoritter fundet!</string> </resources> From 026f8c7592d16c24cd0dea8f9afb098c96162c3a Mon Sep 17 00:00:00 2001 From: Bone NI <bounkirdni@noreply.codeberg.org> Date: Wed, 2 Sep 2026 09:27:27 +0000 Subject: [PATCH 442/475] Added translation using Weblate (Lao) --- app/src/main/res/values-lo/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-lo/strings.xml diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-lo/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> \ No newline at end of file From 6e12b0c776cf929b3e7f77c87d6e41ad73bc4c78 Mon Sep 17 00:00:00 2001 From: lednurb <lednurb@noreply.codeberg.org> Date: Fri, 4 Sep 2026 13:54:13 +0000 Subject: [PATCH 443/475] Translated using Weblate (Dutch) Currently translated at 92.6% (601 of 649 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3dff3a5f..bf02bbea 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -42,8 +42,8 @@ <string name="file_size">Grootte</string> <string name="forced_track">Geforceerd</string> <string name="genres">Genres</string> - <string name="go_to_series">Ga naar serie</string> - <string name="go_to">Ga naar item</string> + <string name="go_to_series">Naar seizoenen</string> + <string name="go_to">Naar afspelen</string> <string name="hide_controller_timeout">Afspeelbediening verbergen</string> <string name="hide_debug_info">Foutopsporingsinformatie verbergen</string> <string name="hide">Verbergen</string> @@ -660,4 +660,6 @@ <string name="scenes_title">Scenes</string> <string name="pin_digits_hint">[0-9]</string> <string name="dts_x">DTS:X</string> + <string name="override_user_profile_settings">Meer instellingen gebruikersprofiel</string> + <string name="d_pad_seek_mode_title">"D-pad zoekbalk"</string> </resources> From d6b1d77c28ff2f46b5af002bff3d0d463a6c9740 Mon Sep 17 00:00:00 2001 From: North-DaCoder <north-dacoder@noreply.codeberg.org> Date: Sat, 5 Sep 2026 08:34:42 +0000 Subject: [PATCH 444/475] Translated using Weblate (German) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8d13051d..e183feb9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -704,4 +704,16 @@ <string name="server_version_not_supported">Nicht unterstützte Server-Version</string> <string name="disable_audio_offload">Audio-Hardware-Offload deaktivieren</string> <string name="disable_audio_offload_summary">Das Auslagern der Audio Decodierung zu deaktivieren könnte Wiedergabeprobleme auf einigen Gräten beheben</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d F%2$d</string> + <string name="season_episode_number_range">S%1$d F%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dF%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dF%2$02d-E%3$02d</string> + <string name="episode_number_short">F%1$d</string> + <string name="season_number">Staffel %1$d</string> + <string name="dim_after_delay">Bildschirm nach Verzögerung dimmen</string> + <string name="dim_percentage">Dimmen in Prozent</string> + <string name="dim_screen">Bildschirm dimmen</string> + <string name="photo_albums">Fotoalben</string> + <string name="no_favorites_found">Keine Favoriten vorhanden!</string> </resources> From 114df65de33df17235547fa8486eea837724d4a4 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sat, 5 Sep 2026 01:53:36 +0000 Subject: [PATCH 445/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 543994c8..782e3e0a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -687,4 +687,11 @@ <string name="dim_screen">螢幕調暗</string> <string name="no_favorites_found">未添加任何最愛項目!</string> <string name="photo_albums">相簿</string> + <string name="duration_hours_seconds">%1$d小時%2$d分</string> + <string name="season_episode_number">第%1$d季 第%2$d集</string> + <string name="season_episode_number_range">第%1$d季 第%2$d-%3$d集</string> + <string name="season_episode_number_padded">第%1$02d季 第%2$02d集</string> + <string name="season_episode_number_padded_range">第%1$02d季 第%2$02d-%3$02d集</string> + <string name="episode_number_short">第 %1$d 集</string> + <string name="season_number">第 %1$d 季</string> </resources> From 8e050a08301d23cd79d0b6e633db57fca6652b94 Mon Sep 17 00:00:00 2001 From: lednurb <lednurb@noreply.codeberg.org> Date: Sat, 5 Sep 2026 06:56:38 +0000 Subject: [PATCH 446/475] Translated using Weblate (Dutch) Currently translated at 91.6% (601 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index bf02bbea..2d6bd0c4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -43,7 +43,7 @@ <string name="forced_track">Geforceerd</string> <string name="genres">Genres</string> <string name="go_to_series">Naar seizoenen</string> - <string name="go_to">Naar afspelen</string> + <string name="go_to">Naar inhoud</string> <string name="hide_controller_timeout">Afspeelbediening verbergen</string> <string name="hide_debug_info">Foutopsporingsinformatie verbergen</string> <string name="hide">Verbergen</string> From 7b39854621e0c20f03b6e54fa070db80fd69f2e9 Mon Sep 17 00:00:00 2001 From: tal_sarid <tal_sarid@noreply.codeberg.org> Date: Fri, 4 Sep 2026 17:13:30 +0000 Subject: [PATCH 447/475] Translated using Weblate (Hebrew) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index ddd2b700..a4191206 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -731,4 +731,11 @@ <string name="dim_screen">עמעם תצוגה</string> <string name="photo_albums">אלבומי תמונות</string> <string name="no_favorites_found">לא נמצאו מועדפים!</string> + <string name="duration_hours_seconds">%1$d שע\' %2$d שנ\'</string> + <string name="season_episode_number">עונה %1$d פרק %2$d</string> + <string name="season_episode_number_range">עונה %1$d פרקים %2$d-%3$d</string> + <string name="season_episode_number_padded">עונה %1$02d פרק %2$02d</string> + <string name="season_episode_number_padded_range">עונה %1$02d פרקים %2$02d-%3$02d</string> + <string name="episode_number_short">פרק %1$d</string> + <string name="season_number">עונה %1$d</string> </resources> From 1e86616d2a12a64969f753c1e2561352f185839a Mon Sep 17 00:00:00 2001 From: idezentas <idezentas@noreply.codeberg.org> Date: Fri, 4 Sep 2026 21:04:54 +0000 Subject: [PATCH 448/475] Translated using Weblate (Turkish) Currently translated at 99.8% (655 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ba80486e..409059eb 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -706,4 +706,13 @@ <string name="dim_after_delay">Belirli bir süre sonra ekranı karart</string> <string name="dim_percentage">Karartma yüzdesi</string> <string name="dim_screen">Ekranı karart</string> + <string name="duration_hours_seconds">%1$dsa %2$dsn</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezon %1$d</string> + <string name="photo_albums">Fotoğraf Albümleri</string> + <string name="no_favorites_found">Favori bulunamadı!</string> </resources> From 5ee6b31018dc96d18490a9e7f5c470c2df678ad1 Mon Sep 17 00:00:00 2001 From: danpergal84 <danpergal84@noreply.codeberg.org> Date: Sun, 6 Sep 2026 17:29:53 +0000 Subject: [PATCH 449/475] Translated using Weblate (Spanish) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 582a4405..c70eeeba 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -731,4 +731,11 @@ <string name="dim_screen">Atenuar pantalla</string> <string name="photo_albums">Álbumes de fotos</string> <string name="no_favorites_found">¡No se encontraron favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> </resources> From 60a0d633bd1235130b9d8c87c8735f710e2c5103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Sat, 5 Sep 2026 20:55:20 +0000 Subject: [PATCH 450/475] Translated using Weblate (Estonian) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 380be3e5..0ef7ed29 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -709,4 +709,11 @@ <string name="dim_screen">Hämarda ekraani</string> <string name="photo_albums">Fotoalbumid</string> <string name="no_favorites_found">Ühtegi lemmikut ei leidu!</string> + <string name="duration_hours_seconds">%1$d t %2$d sek</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">%1$d. hooaeg</string> </resources> From 3598e3010b828cce9fd194c3d4fd38ab5d55ef01 Mon Sep 17 00:00:00 2001 From: marinosGR <marinosgr@noreply.codeberg.org> Date: Sun, 6 Sep 2026 00:00:23 +0000 Subject: [PATCH 451/475] Translated using Weblate (Greek) Currently translated at 51.0% (335 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 281 ++++++++++++++++++++----- 1 file changed, 231 insertions(+), 50 deletions(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index a4070eca..732ffe5d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -18,20 +18,20 @@ <string name="videos">Τα βίντεο</string> <string name="background">Παρασκήνιο</string> <plurals name="theme_songs"> - <item quantity="one">Θεματικά τραγούδια</item> + <item quantity="one">Θεματικό τραγούδι</item> <item quantity="other">Θεματικά τραγούδια</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Θεματικά βίντεο</item> + <item quantity="one">Θεματικό βίντεο</item> <item quantity="other">Θεματικά βίντεο</item> </plurals> - <string name="next_up">Επόμενο</string> - <string name="rewatch_next_up">Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\'</string> + <string name="next_up">Επόμενα</string> + <string name="rewatch_next_up">Ενεργοποιήστε την επαναπροβολή στα \'Επόμενα\'</string> <string name="font">Γραμματοσειρά</string> <string name="font_size">Μέγεθος Κειμένου</string> <string name="font_color">Χρώμα Κειμένου</string> - <string name="bold_font">Φωτεινότητα κειμένου</string> - <string name="library">Βιβλιοθήκης</string> + <string name="bold_font">Έντονη γραμματοσειρά</string> + <string name="library">Βιβλιοθήκη</string> <string name="tv_shows_title">Σειρές</string> <string name="genres">Είδη</string> <string name="collection">Συλλογή</string> @@ -43,25 +43,25 @@ <string name="studios">Στούντιο</string> <string name="sdr">SDR</string> <string name="cast_and_crew">Ηθοποιοί και Συνεργείο</string> - <string name="more_like_this">Περισσότερα Σαν Αυτό</string> + <string name="more_like_this">Παραπλήσια</string> <string name="episodes">Επεισόδια</string> <string name="jump_letters">#ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ</string> <plurals name="downloads"> <item quantity="one">%s λήψη</item> <item quantity="other">%s λήψεις</item> </plurals> - <string name="downloading">λήψη…</string> + <string name="downloading">Γίνεται λήψη…</string> <string name="path">Διαδρομή</string> <string name="name">Όνομα</string> - <string name="container">Δοχείο</string> + <string name="container">Κοντέινερ</string> <string name="file_size">Μέγεθος</string> <string name="codec">Κωδικοποιητής</string> <string name="avc">AVC</string> <string name="bitrate">Ρυθμός bit</string> <string name="bit_depth">Βάθος bit</string> <string name="bit_unit">bit</string> - <string name="forced_track">Εξαναγκασμός</string> - <string name="layout">Σχέδιο</string> + <string name="forced_track">Αναγκαστικό</string> + <string name="layout">Διάταξη</string> <string name="default_track">Προεπιλογή</string> <string name="external_track">Εξωτερικό</string> <string name="add_user">Προσθήκη χρήστη</string> @@ -69,25 +69,25 @@ <string name="chapters">Κεφάλαια</string> <string name="choose_stream">Διάλεξε %1$s</string> <string name="add_server">Προσθήκη διακομιστή</string> - <string name="cancel_recording">Ακύρωση καταχώρησης</string> - <string name="cancel_series_recording">Ακύρωση σειρά καταχώρησης</string> + <string name="cancel_recording">Ακύρωση καταγραφής</string> + <string name="cancel_series_recording">Ακύρωση καταγραφής σειράς</string> <string name="clear_image_cache">Καθαρισμός μνήμης εικόνας</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="continue_watching">Συνέχεια προβολής</string> <string name="critic_rating">Κριτική</string> - <string name="decimal_seconds">%.1f δευτερόλεπτα</string> + <string name="decimal_seconds">%.2f δευτερόλεπτα</string> <string name="delete">Διαγραφή</string> <string name="died">Πέθανε</string> <string name="directed_by">Σκηνοθετήθηκε από %1$s</string> - <string name="disabled">Απενεργοποιημένος</string> - <string name="enabled">Ενεργοποιημένος</string> - <string name="enter_server_url">Καταχώρηση διακομιστή IP or URL</string> + <string name="disabled">Απενεργοποιημένο</string> + <string name="enabled">Ενεργοποιημένο</string> + <string name="enter_server_url">Δώσε την IP ή το URL του διακομιστή</string> <string name="error_loading_collection">Σφάλμα φόρτωσης συλλογής %1$s</string> - <string name="go_to_series">Πηγαίνετε στις σειρές</string> - <string name="go_to">Πηγαίνετε στο</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> @@ -96,15 +96,15 @@ <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_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">Περισσότερα</string> <string name="no_data">Χωρίς δεδομένα</string> <string name="no_results">Χωρίς αποτελέσματα</string> - <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταχωρήσεις</string> + <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταγραφές</string> <string name="no_update_available">Χωρίς διαθέσιμη ενημέρωση</string> <string name="play_from_here">Έναρξη από εδώ</string> <string name="play">Έναρξη</string> @@ -117,16 +117,16 @@ <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_added_in">Πρόσφατες προσθήκες σε %1$s</string> + <string name="recently_added">Πρόσφατες προσθήκες</string> <string name="recently_recorded">Πρόσφατα καταγεγραμμένο</string> - <string name="recently_released">Πρόσφατα κυκλοφόρησε</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="record_program">Καταγραφή προγράμματος</string> + <string name="record_series">Καταγραφή σειράς</string> + <string name="remove_favorite">Αναίρεση αγαπημένου</string> <string name="restart">Επανεκκίνηση</string> - <string name="resume">Περίληψη</string> + <string name="resume">Συνέχισε</string> <string name="save">Αποθήκευση</string> <string name="search">Αναζήτηση</string> <string name="searching">Αναζήτηση…</string> @@ -136,9 +136,9 @@ <string name="show_next_up_when">Εμφάνιση επόμενου</string> <string name="show">Εμφάνιση</string> <string name="shuffle">Τυχαία αναπαραγωγή</string> - <string name="skip">Αναπήδηση</string> - <string name="sort_by_date_added">Προσθήκη ημερομηνίας</string> - <string name="sort_by_date_episode_added">Προσθήκη ημερομηνίας επεισοδίου</string> + <string name="skip">Παράλειψε</string> + <string name="sort_by_date_added">Ημερομηνία Προσθήκης</string> + <string name="sort_by_date_episode_added">Ημερομηνία Προσθήκης Επεισοδίου</string> <string name="sort_by_date_played">Ημερομηνία αναπαραγωγής</string> <string name="sort_by_date_released">Ημερομηνία κυκλοφορίας</string> <string name="sort_by_name">Όνομα</string> @@ -146,17 +146,17 @@ <string name="submit">Υποβολή</string> <string name="active_recordings">Ενεργές καταγραφές</string> <string name="none">Κανένας</string> - <string name="playback_debug_info">Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων</string> - <string name="subtitle_download_too_long">Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή</string> + <string name="playback_debug_info">Εμφάνιση πληροφοριών σφαλμάτων αναπαραγωγής</string> + <string name="subtitle_download_too_long">Καθυστέρηση της λήψης, ίσως απαιτείται επανεκκίνηση αναπαραγωγής</string> <string name="switch_servers">Αλλαγή διακομιστή</string> - <string name="switch_user">Αντικατάσταση</string> - <string name="top_unwatched">Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει</string> + <string name="switch_user">Αλλαγή</string> + <string name="top_unwatched">Κορυφαίας βαθμολογίας που δεν έχουν προβληθεί</string> <string name="tv_guide">Οδηγός</string> <string name="tv_season">Σεζόν</string> <string name="tv_seasons">Περισσότερες Σεζόν</string> <string name="unknown">Άγνωστο</string> <string name="updates">Αναβαθμίσεις</string> - <string name="version">Εκδοχή</string> + <string name="version">Έκδοση</string> <string name="video_scale">Κλίμακα βίντεο</string> <string name="watch_live">Ζωντανή μετάδοση</string> <string name="media_information">Πληροφορίες μέσων</string> @@ -167,46 +167,227 @@ <string name="italic_font">Πλάγια γραμματοσειρά</string> <string name="success">Επιτυχής</string> <string name="official_rating">Γονεική αξιολόγηση</string> - <string name="runtime_sort">Χρόνος εκτέλεσης</string> + <string name="runtime_sort">Διάρκεια</string> <string name="extras_title">Επιπρόσθετα</string> <plurals name="other_extras"> <item quantity="one">Άλλο</item> - <item quantity="other">Άλλο</item> + <item quantity="other">Άλλα</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Παρασκήνια</item> + <item quantity="one">Παρασκήνιο</item> <item quantity="other">Παρασκήνια</item> </plurals> <plurals name="clips"> - <item quantity="one">Αποσπάσματα</item> + <item quantity="one">Απόσπασμα</item> <item quantity="other">Αποσπάσματα</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Διαγραμμένες σκηνές</item> + <item quantity="one">Διαγραμμένη σκηνή</item> <item quantity="other">Διαγραμμένες σκηνές</item> </plurals> <plurals name="interviews"> - <item quantity="one">Συνεντεύξεις</item> + <item quantity="one">Συνέντευξη</item> <item quantity="other">Συνεντεύξεις</item> </plurals> <plurals name="scenes"> - <item quantity="one">Σκηνές</item> + <item quantity="one">Σκηνή</item> <item quantity="other">Σκηνές</item> </plurals> <plurals name="samples"> - <item quantity="one">Δείγματα</item> + <item quantity="one">Δείγμα</item> <item quantity="other">Δείγματα</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Χαρακτηριστικά</item> - <item quantity="other">Χαρακτηριστικά</item> + <item quantity="one">Προωθητικό βίντεο</item> + <item quantity="other">Προωθητικά βίντεο</item> </plurals> <string name="auto_check_for_updates">Αυτόματος έλεγχος για ενημερώσεις</string> <string name="auto_play_next_delay">Καθυστέρηση πριν την έναρξη του επόμενου</string> <string name="auto_play_next">Αυτόματη έναρξη επόμενου</string> <string name="check_for_updates">Έλεχγος για ενημερώσεις</string> <string name="combine_continue_next_summary">Ισχύει μόνο για τηλεοπτικές σειρές</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="installed_version">Εγκατεστημένη έκδοση</string> + <string name="about">Πληροφορίες</string> + <string name="duration_hours_minutes_seconds">%1$dω %2$dλ %3$dδ</string> + <string name="duration_hours_minutes">%1$dω %2$dλ</string> + <string name="duration_minutes_seconds">%1$dλ %2$dδ</string> + <string name="duration_hours">%1$dω</string> + <string name="duration_minutes">%1$dλ</string> + <string name="duration_seconds">%1$dδ</string> + <string name="time_left">%1$s απέμεινε</string> + <string name="duration_hours_seconds">%1$dω %2$dδ</string> + <string name="discovered_servers">Εντοπισμένοι διακομιστές</string> + <string name="ends_at">Τελειώνει σε %1$s</string> + <string name="resolution_switching">Εναλλαγή ανάλυσης</string> + <string name="local">Τοπικό</string> + <string name="play_trailer">Αναπαραγωγή τρέιλερ</string> + <string name="no_trailers">Λείπει τρέιλερ</string> + <string name="clear_track_choices">Καθαρισμός επιλογών καναλιού</string> + <string name="enter_server_address">Δώσε την διεύθυνση διακομιστή</string> + <string name="no_servers_found">Δεν βρέθηκαν διακομιστές</string> + <string name="play_count">Μετρητής προβολών</string> + <string name="only_forced_subtitles">Μόνο αναγκαστικοί υπότιτλοι</string> + <string name="outro">Τίτλοι τέλους</string> + <string name="voice_error_audio">Σφάλμα καταγραφής ήχου</string> + <string name="visualizer_rationale">Η οπτικοποίηση του ήχου αναπαραγωγής απαιτεί άδεια καταγραφής ήχου.</string> + <string name="recordings">Καταγραφές</string> + <string name="profile_specific_settings_from_server">Ρυθμίσεις του προφίλ χρήστη στο διακομιστή</string> + <string name="subtitle_mode">Λειτουργία υποτίτλων</string> + <string name="use_user_profile">Χρήση των ρυθμίσεων του προφίλ</string> + <string name="subtitle_mode_default">Προεπιλογή</string> + <string name="subtitle_mode_smart">Έξυπνο</string> + <string name="subtitle_mode_only_forced">Μόνο αναγκαστικοί</string> + <string name="subtitle_mode_always">Πάντα</string> + <string name="subtitle_mode_none">Κανένα</string> + <string name="preferred_subtitle_language">Προτιμώμενη γλώσσα υποτίτλων</string> + <string name="preferred_audio_language">Προτιμώμενη γλώσσα ήχου</string> + <string name="override_user_profile_settings">Περισσότερες ρυθμίσεις του προφίλ χρήστη</string> + <string name="override_user_profile_settings_summary">Παράκαμψη των ρυθμίσεων του προφίλ χρήστη στον διακομιστή</string> + <string name="any_language">Οποιαδήποτε γλώσσα</string> + <string name="cinema_mode">Λειτουργία σινεμά</string> + <string name="download_and_update"><![CDATA[Λήψη και ενημέρωση]]></string> + <plurals name="movies"> + <item quantity="one">Ταινία</item> + <item quantity="other">Ταινίες</item> + </plurals> + <plurals name="people"> + <item quantity="one">Άτομο</item> + <item quantity="other">Άτομα</item> + </plurals> + <string name="search_and_download"><![CDATA[Αναζήτηση και λήψη]]></string> + <plurals name="trailers"> + <item quantity="one">Τρέιλερ</item> + <item quantity="other">Τρέιλερ</item> + </plurals> + <string name="tv_dvr_schedule">Προγραμματισμός DVR</string> + <plurals name="tv_shows"> + <item quantity="one">Σειρά</item> + <item quantity="other">Σειρές</item> + </plurals> + <string name="ui_interface">Διεπαφή</string> + <plurals name="years_old"> + <item quantity="one">%1$d έτους</item> + <item quantity="other">%1$d ετών</item> + </plurals> + <string name="play_with_transcoding">Αναπαραγωγή με μετατροπή</string> + <string name="aired_episode_order">Σειρά τηλεοπτικής προβολής</string> + <string name="one_click_pause_summary_on">Παύση/Αναπαραγωγή με το κεντρικό κουμπί του σταυρού πλοήγησης</string> + <plurals name="shorts"> + <item quantity="one">Μικρού μήκους</item> + <item quantity="other">Μικρού μήκους</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d ώρα</item> + <item quantity="other">%d ώρες</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d αντικείμενο</item> + <item quantity="other">%d αντικείμενα</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d δευτερόλεπτο</item> + <item quantity="other">%d δευτερόλεπτα</item> + </plurals> + <string name="ac3_supported">Η συσκευή υποστηρίζει AC3/Dolby Digital</string> + <string name="advanced_ui">Προηγμένη διεπαφή</string> + <string name="combine_continue_next"><![CDATA[Συγχώνευση Συνέχεια προβολής και Επόμενα]]></string> + <string name="max_days_next_up">Ανώτατο όριο ημερών στα Επόμενα</string> + <string name="next_up_playback_end">Στο τέλος της αναπαραγωγής</string> + <string name="next_up_outro">Κατά τη διάρκεια των τίτλων τέλους</string> + <string name="remove_next_up">Αφαίρεση σειράς από τα Επόμενα</string> + <string name="view_removed_next_up">Εμφάνιση σειρών που αφαιρέθηκαν από τα Επόμενα</string> + <string name="next_up_never">Ποτέ</string> + <string name="continue_watching_click_behavior">Συμπεριφορά επιλογής του Συνέχεια προβολής/Επόμενο</string> + <string name="remove_continue_watching">Αφαίρεση από συνέχεια προβολής</string> + <string name="continue_watching_click_summary_on">Αναπαραγωγή κατά την επιλογή</string> + <string name="continue_watching_click_summary_off">Προβολή λεπτομέρειον κατά την επιλογή</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> + <string name="max_homepage_items">Ανώτατο όριο αντικειμένων στις γραμμές της αρχικής</string> + <string name="nav_drawer_pins_summary">Διάλεξε τα προεπιλεγμένα αντικείμενα για εμφάνιση, θα γίνει απόκρυψη των υπόλοιπων</string> + <string name="remember_selected_tab">Απομνημόνευση επιλεγμένων καρτελών</string> + <string name="settings">Ρυθμίσεις</string> + <string name="discover">Ανακάλυψε</string> + <string name="request">Αίτημα</string> + <string name="pending">Εκκρεμί</string> + <string name="password">Κωδικός</string> + <string name="username">Όνομα χρήστη</string> + <string name="trending">Τάσεις</string> + <string name="red">Κόκκινο</string> + <string name="green">Πράσινο</string> + <string name="blue">Μπλε</string> + <string name="blur">Θόλωση</string> + <string name="height">Ύψος</string> + <string name="content_scale_fit">Προσαρμογή</string> + <string name="content_scale_crop">Περικοπή</string> + <string name="content_scale_fill">Γέμισμα</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="black">Μαύρο</string> + <string name="skip_ignore">Αγνόηση</string> + <string name="white">Άσπρο</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="background_style_wrap">Αναδίπλωση</string> + <string name="background_style_boxed">Πλαίσιο</string> + <string name="duration">Διάρκεια</string> + <string name="screensaver">Προστασία οθόνης</string> + <string name="photos">Φωτογραφίες</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="mixer">Υπεύθυνος μίξης</string> + <string name="creator">Δημιουργός</string> + <string name="artist">Καλλιτέχνης</string> + <string name="albums">Άλμπουμ</string> + <string name="artists">Καλλιτέχνες</string> + <string name="songs">Τραγούδια</string> + <string name="album">Άλμπουμ</string> + <string name="lyrics">Στίχοι</string> + <string name="continue_string">Συνεχεία</string> + <string name="transcoding">Μετατροπή</string> + <string name="reset">Επαναφορά</string> + <string name="played">Παίχτηκε</string> + <string name="filter">Φίλτρο</string> + <string name="year">Έτος</string> + <string name="decade">Δεκαετία</string> + <string name="remove">Αφαίρεση</string> + <string name="subtitle_margin">Περιθώριο</string> + <string name="incorrect">Λανθασμένο</string> + <string name="columns">Στήλες</string> + <string name="spacing">Διάστιχο</string> + <string name="automatic">Αυτόματα</string> + <string name="login">Σύνδεση</string> + <string name="general">Γενικά</string> + <string name="title">Τίτλος</string> + <string name="level">Επίπεδο</string> + <string name="resolution">Ανάλυση</string> + <string name="anamorphic">Αναμορφικό</string> + <string name="interlaced">Πεπλεγμένο</string> + <string name="framerate">Ρυθμός ανανέωσης</string> + <string name="live_tv_repeat">Επανάληψη</string> + <string name="retry">Επανάλαβε</string> + <string name="brightness">Φωτεινότητα</string> + <string name="contrast">Αντίθεση</string> + <string name="saturation">Κορεσμός</string> + <string name="hue">Απόχρωση</string> + <string name="subtitle_delay">Καθυστέρηση υποτίτλων</string> + <string name="discover_movies">Ανακάλυψε ταινίες</string> + <string name="discover_tv">Ανακάλυψε σειρές</string> </resources> From 6647cf338c537e18df34fd1e69e8982a0df54e59 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Mon, 7 Sep 2026 09:10:31 +0000 Subject: [PATCH 452/475] Translated using Weblate (Swedish) Currently translated at 90.3% (593 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index f50e2bee..7dba993c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -649,4 +649,10 @@ <string name="show_visualizer">Visa visualizer</string> <string name="show_backdrop">Visa bakgrund</string> <string name="player_backend">Uppspelning backend</string> + <string name="season_number">Säsong %1$d</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="duration_hours_seconds">%1$dt %2$ds</string> + <string name="blur">Oskärpa</string> </resources> From c0f6293ea703fcb8e28cf299663937b24e2bb3a8 Mon Sep 17 00:00:00 2001 From: n8llcaster <n8llcaster@noreply.codeberg.org> Date: Mon, 7 Sep 2026 11:48:42 +0000 Subject: [PATCH 453/475] Translated using Weblate (Slovak) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 23af0764..a57d3311 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -753,4 +753,11 @@ <string name="dim_screen">Stlmenie obrazovky</string> <string name="photo_albums">Fotoalbumy</string> <string name="no_favorites_found">Nenašli sa žiadne obľúbené položky!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Séria %1$d</string> </resources> From 7bd1e993d0321d36789ba618c9446351be580c10 Mon Sep 17 00:00:00 2001 From: opakholis <opakholis@noreply.codeberg.org> Date: Tue, 8 Sep 2026 07:22:34 +0000 Subject: [PATCH 454/475] Translated using Weblate (Indonesian) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index fbd7ed5d..f3c0a00c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -664,4 +664,34 @@ <string name="any_language">Semua bahasa</string> <string name="brown">Cokelat</string> <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> + <string name="duration_hours_seconds">%1$dj %2$dd</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Season %1$d</string> + <string name="disable_audio_offload">Nonaktifkan offload audio hardware</string> + <string name="disable_audio_offload_summary">Menonaktifkan dekode audio offload dapat mengatasi masalah pemutaran macet pada beberapa perangkat</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Bilah geser kecil di sepanjang bawah layar (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Bilah geser dengan gambar pratinjau trickplay</string> + <string name="dpad_seek_mode_none_summary">Tanpa bilah geser</string> + <string name="blocklisted">Daftar blokir</string> + <string name="hash_matches">Hash sesuai</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Acara TV</string> + <string name="recordings">Rekaman</string> + <string name="folders_title">Folder</string> + <string name="user_views">Tampilan pengguna</string> + <string name="books">Buku</string> + <string name="audio_books">Buku audio</string> + <string name="server_version_not_supported">Versi server tidak didukung</string> + <string name="appears_on">Tampil pada</string> + <string name="dim_after_delay">Redupkan layar setelah penundaan</string> + <string name="dim_percentage">Persentase redup</string> + <string name="dim_screen">Redupkan layar</string> + <string name="photo_albums">Album foto</string> + <string name="no_favorites_found">Favorit tidak ditemukan!</string> </resources> From 8a570b89fbad812ebe0f8adadb56b8fb1fe30ce2 Mon Sep 17 00:00:00 2001 From: Andi Chandler <andi@gowling.com> Date: Mon, 7 Sep 2026 11:31:59 +0000 Subject: [PATCH 455/475] Translated using Weblate (English (United Kingdom)) Currently translated at 8.6% (57 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/en_GB/ --- app/src/main/res/values-en-rGB/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 4e3381c0..6f57641a 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -46,4 +46,10 @@ <string name="born">Born</string> <string name="cancel_recording">Cancel Recording</string> <string name="cancel_series_recording">Cancel Series Recording</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="cancel">Cancel</string> + <string name="chapters">Chapters</string> + <string name="choose_stream">Choose %1$s</string> </resources> From bad47eb6387b0de2dc6f474f420d2e076e09c7f3 Mon Sep 17 00:00:00 2001 From: MrGegaz <mrgegaz@noreply.codeberg.org> Date: Tue, 8 Sep 2026 20:39:41 +0000 Subject: [PATCH 456/475] Translated using Weblate (Croatian) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hr/ --- app/src/main/res/values-hr/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index dfdb469a..b42d0683 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -729,4 +729,13 @@ <string name="dim_after_delay">Zatamni zaslon nakon odgode</string> <string name="dim_percentage">Postotak zatamnjenja</string> <string name="dim_screen">Zatamni zaslon</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezona %1$d</string> + <string name="photo_albums">Albumi slika</string> + <string name="no_favorites_found">Nijedan favorit nije pronađen!</string> </resources> From f79c0bdfe79b2de5c74e137fbd7cc45e403d575a Mon Sep 17 00:00:00 2001 From: American_Jesus <american_jesus@noreply.codeberg.org> Date: Wed, 9 Sep 2026 11:27:15 +0000 Subject: [PATCH 457/475] Translated using Weblate (Portuguese) Currently translated at 100.0% (656 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index dfa96fd1..b95286c4 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -731,4 +731,11 @@ <string name="dim_screen">Escurecer ecrã</string> <string name="photo_albums">Álbum de fotos</string> <string name="no_favorites_found">Não foram encontrados favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> </resources> From 584c3566d6c74bf9eff3974cdb9d46af241b9ad7 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Wed, 9 Sep 2026 19:32:54 +0000 Subject: [PATCH 458/475] Translated using Weblate (Swedish) Currently translated at 90.7% (595 of 656 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7dba993c..c38d1bd8 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -655,4 +655,6 @@ <string name="season_episode_number_padded">S%1$02dE%2$02d</string> <string name="duration_hours_seconds">%1$dt %2$ds</string> <string name="blur">Oskärpa</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> </resources> From 0db4d45dbcec7675e12e887fcc8eb7233987f1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= <jrtcdbrg@noreply.codeberg.org> Date: Fri, 11 Sep 2026 10:27:40 +0000 Subject: [PATCH 459/475] Translated using Weblate (Estonian) Currently translated at 100.0% (657 of 657 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 0ef7ed29..ffbec2a7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -716,4 +716,5 @@ <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> <string name="episode_number_short">E%1$d</string> <string name="season_number">%1$d. hooaeg</string> + <string name="remove_from_playlist">Eemalda esitusloendist</string> </resources> From cd0128e1db7ed242ec5c5cc0d93092fd2e578106 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Fri, 11 Sep 2026 00:25:21 +0000 Subject: [PATCH 460/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (657 of 657 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 782e3e0a..854c7275 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -694,4 +694,5 @@ <string name="season_episode_number_padded_range">第%1$02d季 第%2$02d-%3$02d集</string> <string name="episode_number_short">第 %1$d 集</string> <string name="season_number">第 %1$d 季</string> + <string name="remove_from_playlist">從播放清單中移除</string> </resources> From 391a38e777d2dc3d4fe10668d1abbbaf35c45d21 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Fri, 11 Sep 2026 14:17:45 +0000 Subject: [PATCH 461/475] Translated using Weblate (Polish) Currently translated at 98.6% (648 of 657 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 30e8bffa..4799f7d0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -751,4 +751,5 @@ <string name="dim_percentage">Procent przyciemniane</string> <string name="photo_albums">Albumy zdjęć</string> <string name="no_favorites_found">Nic jeszcze nie polubiono!</string> + <string name="season_number">Sezon %1$d</string> </resources> From 923f0b7ea30c96d2f94f94943782ea372890c635 Mon Sep 17 00:00:00 2001 From: SimonHung <simonhung@noreply.codeberg.org> Date: Sat, 12 Sep 2026 08:53:01 +0000 Subject: [PATCH 462/475] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (659 of 659 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 854c7275..ca8bd810 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -695,4 +695,6 @@ <string name="episode_number_short">第 %1$d 集</string> <string name="season_number">第 %1$d 季</string> <string name="remove_from_playlist">從播放清單中移除</string> + <string name="login_not_authorized">您目前無存取此伺服器的權限</string> + <string name="login_credentials_expired">登入憑證已過期,請重新登入</string> </resources> From f181770870f29c1e72418c70b241e9d0f1b141d6 Mon Sep 17 00:00:00 2001 From: arcker95 <arcker95@noreply.codeberg.org> Date: Fri, 11 Sep 2026 20:14:13 +0000 Subject: [PATCH 463/475] Translated using Weblate (Italian) Currently translated at 100.0% (659 of 659 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 20e750ad..7e347b2d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -731,4 +731,14 @@ <string name="dim_screen">Scurisci schermo</string> <string name="photo_albums">Album fotografici</string> <string name="no_favorites_found">Nessun preferito trovato!</string> + <string name="duration_hours_seconds">%1$do %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Stagione %1$d</string> + <string name="remove_from_playlist">Rimuovi dalla playlist</string> + <string name="login_credentials_expired">Credenziali scadute, effettua di nuovo l’accesso</string> + <string name="login_not_authorized">Non sei autorizzato ad accedere al server in questo momento</string> </resources> From a60433dbbfbd1d857e36c53c163b5f5fd1ea4a07 Mon Sep 17 00:00:00 2001 From: TheMatrixan <thematrixan@noreply.codeberg.org> Date: Sat, 12 Sep 2026 12:21:05 +0000 Subject: [PATCH 464/475] Translated using Weblate (Polish) Currently translated at 99.6% (657 of 659 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4799f7d0..deb42ecd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -752,4 +752,13 @@ <string name="photo_albums">Albumy zdjęć</string> <string name="no_favorites_found">Nic jeszcze nie polubiono!</string> <string name="season_number">Sezon %1$d</string> + <string name="remove_from_playlist">Usuń z listy odtwarzania</string> + <string name="login_credentials_expired">Sesja wygasła, zaloguj się ponownie</string> + <string name="login_not_authorized">Nie jesteś upoważniony do dostępu do serwera w tym momencie</string> + <string name="episode_number_short">O%1$d</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d O%2$d</string> + <string name="season_episode_number_range">S%1$d O%2$d-%3$d</string> + <string name="season_episode_number_padded">S%1$02d O%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02d O%2$02d-%3$02d</string> </resources> From af6a3b99169b329eac9e8b0cac642bd7f386d275 Mon Sep 17 00:00:00 2001 From: Prahlis <prahlis@noreply.codeberg.org> Date: Sat, 12 Sep 2026 19:55:42 +0000 Subject: [PATCH 465/475] Translated using Weblate (Swedish) Currently translated at 92.2% (608 of 659 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c38d1bd8..6664b928 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -657,4 +657,17 @@ <string name="blur">Oskärpa</string> <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> <string name="episode_number_short">E%1$d</string> + <string name="show_more">Visa %1$s till</string> + <string name="is_default">Standard</string> + <string name="preferred_audio_language">Föredraget ljudspråk</string> + <string name="override_user_profile_settings">Fler profilinställningar</string> + <string name="use_user_profile">Använd profilinställningar</string> + <string name="override_user_profile_settings_summary">Ignorera serverns profilinställningar</string> + <string name="server_version_not_supported">Serverns version stöds inte</string> + <string name="appears_on">Medverkar i</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Inga favoriter hittades!</string> + <string name="confirm_enable_experimental_body">Experimentella inställningar kan vara instabila!\n\n Dessa kan tas bort eller ändras i framtida versioner.</string> + <string name="remove_from_playlist">Ta bort från spellista</string> + <string name="trailers_title">Trailers</string> </resources> From 190c6be41abe1642b1623f1dee544426558e8cb2 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sat, 12 Sep 2026 18:43:14 -0400 Subject: [PATCH 466/475] Run pre-commit --- app/src/main/res/values-lo/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -1,3 +1,3 @@ <?xml version="1.0" encoding="utf-8"?> <resources> -</resources> \ No newline at end of file +</resources> From 45c1ceca270afabd2e1ca012d6e7374b32d4f00d Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Sun, 13 Sep 2026 20:09:08 -0400 Subject: [PATCH 467/475] Fix favorites not querying for people & fix restoring focus on search page for discover items (#1961) ## Description Fix two bugs: - Favorites page didn't query for favorite people, introduced by #1864 - Fixes restoring focus on the search page after clicking a discover item and going back ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/detail/FavoritesViewModel.kt | 4 +++- .../github/damontecres/wholphin/ui/search/SearchPage.kt | 2 +- .../damontecres/wholphin/ui/search/SearchViewModel.kt | 9 ++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt index 8471c40d..adbd9a44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -787,7 +787,9 @@ fun List<Library>.possibleFavoriteTypes(sort: Boolean) = flatMap { it.collectionType.baseItemKinds } .distinct() .filter { it in favoriteOptions } - .let { list -> + .let { + // No library for persons, so always include it + val list = it + listOf(BaseItemKind.PERSON) if (sort) { list.sortedWith(compareBy { favoriteOptions.indexOf(it) }) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt index e353287d..f20222d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -113,7 +113,7 @@ fun SearchPage( val focusRequesters = remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } } - val seerrActive by viewModel.seerrActive.collectAsState(initial = false) + val seerrActive by viewModel.seerrActive.collectAsState() var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) } var showViewOptions by rememberSaveable { mutableStateOf(false) } var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt index 81dfa95f..044178b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -43,9 +43,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient @@ -79,7 +81,12 @@ class SearchViewModel private val navDrawerService: NavDrawerService, ) : ViewModel(), ContextMenuProvider { - val seerrActive = seerrService.active + val seerrActive = + seerrService.active.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) private val _state = MutableStateFlow(SearchState()) val state: StateFlow<SearchState> = _state From 7af7bc0aa70229fda6222f2d17d1b1ddbc2373c5 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Mon, 14 Sep 2026 10:04:09 -0400 Subject: [PATCH 468/475] Release v1.0.8 From 62965eae48e1e36aeb3100a22695c2a57bc73f58 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 15 Sep 2026 10:53:14 -0400 Subject: [PATCH 469/475] Fix CI setup step (#1974) ## Description Looks like `tools` was replaced by `cmdline-tools` (https://developer.android.com/tools#tools-sdk), so updating the setup action for this. ### Related issues https://github.com/android-actions/setup-android/issues/537 ### Testing N/A ## Screenshots N/A ## AI or LLM usage None --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index bf6de7a1..0fb980d7 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -23,7 +23,7 @@ runs: - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 with: - packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" + packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path shell: bash run: | From 61f7f70d36500b300358722e3977ccef10490b2c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:45:06 -0400 Subject: [PATCH 470/475] Update dependency org.robolectric:robolectric to v4.17 (#1446) --- 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 e7c6ca92..dc7608cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ coreKtx = "1.19.0" appcompat = "1.8.0" composeBom = "2026.09.00" mockk = "1.14.11" -robolectric = "4.16.1" +robolectric = "4.17" multiplatformMarkdownRenderer = "0.45.0" okhttpBom = "5.5.0" programguide = "1.6.0" From 8effd2b1e71567ce64093b5262150e6a0ae2be1a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:46:02 -0400 Subject: [PATCH 471/475] Update actions/setup-java action to v6 (#1907) --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 0fb980d7..b551456a 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -10,7 +10,7 @@ runs: with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 with: distribution: zulu java-version: '21' From ffdebd43c3b5d87721062b1db4e31c967e858332 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Tue, 15 Sep 2026 13:07:08 -0400 Subject: [PATCH 472/475] Update intent parsing to allow for user switching to home page (#1962) ## Description Makes the `itemId` parameter optional allowing just passing `serverId`/`userId` to switch the user and load the app at the home page for that user. Dev note: I added a new assert function `assertIs` which asserts that an object `is` of a type. It also has a contract so the compiler will smart-cast usages after the `assertIs`. Checking a result type is a very common pattern in the unit tests, so this assert will simplify a lot of current & future tests. ### Related issues Closes https://github.com/damontecres/Wholphin/discussions/1824 ### Testing Emulator and added unit tests for `parseIntent` ## Screenshots N/A ## AI or LLM usage None --- Intents.md | 10 +- .../wholphin/services/IntentService.kt | 41 ++- .../wholphin/services/IntentServiceTest.kt | 273 +++++++++++++++++- .../damontecres/wholphin/test/TestUtils.kt | 21 ++ 4 files changed, 321 insertions(+), 24 deletions(-) diff --git a/Intents.md b/Intents.md index 02511787..7f01a501 100644 --- a/Intents.md +++ b/Intents.md @@ -63,7 +63,7 @@ Action: `android.intent.action.VIEW` Shorthand action: `view` Parameters: -- `itemId` - Required, the UUID of the media item +- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen ### Examples @@ -135,12 +135,20 @@ adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee Full format ```bash +# Switch user, go to item adb shell am start \ -a android.intent.action.VIEW \ -n 'com.github.damontecres.wholphin/.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" + +# Switch user, go to home screen +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" ``` URI based diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index a37a0ed2..2dd0cde5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -55,30 +55,37 @@ class IntentService val query = intent.getStringParam(SearchManager.QUERY) return IntentResult.Target(listOf(Destination.Search(query ?: ""))) } - val itemId = - intent.getStringParam("itemId")?.toUUIDOrNull() - ?: return IntentResult.Error("No item id provided") + val itemId = intent.getStringParam("itemId")?.toUUIDOrNull() val item = - try { - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it) } - } catch (ex: Exception) { - Timber.w(ex, "Error fetching item %s", itemId) - return IntentResult.Error("Could not fetch item $itemId") + itemId?.let { + try { + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching item %s", itemId) + return IntentResult.Error("Could not fetch item $itemId") + } } - - val itemDestination = item.destination() + val itemDestination = item?.destination() val destinations = when (action) { Intent.ACTION_VIEW, "view" -> { - listOf(itemDestination) + if (itemDestination != null) { + listOf(itemDestination) + } else { + // Will go to the home page + emptyList() + } } - "com.github.damontecres.wholphin.PLAYBACK", "play" -> { + ACTION_PLAYBACK, "play" -> { + if (itemId == null || itemDestination == null) { + return IntentResult.Error("Cannot start playback with no itemId") + } val position = intent.getLongParam("position")?.coerceAtLeast(0) val shuffle = intent.getBooleanExtra("shuffle", false) @@ -104,7 +111,7 @@ class IntentService } } - return IntentResult.Target(destinations) + return IntentResult.Target(destinations, true) } internal suspend fun prepare(intent: Intent): IntentResult? { @@ -195,6 +202,8 @@ class IntentService const val INTENT_SEASON_ID = "seaId" const val INTENT_SERVER_ID = "serverId" const val INTENT_USER_ID = "userId" + + const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK" } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt index bb4904aa..ee32c7dd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -1,24 +1,34 @@ package com.github.damontecres.wholphin.services +import android.app.SearchManager import android.content.Intent import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao 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.preferences.update +import com.github.damontecres.wholphin.test.assertIs import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.movie import com.github.damontecres.wholphin.test.server import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successResponse import com.github.damontecres.wholphin.ui.toServerString import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.model.UUID +import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -32,13 +42,16 @@ import org.robolectric.annotation.Config class IntentServiceTest { private val api: ApiClient = mockk() private val serverRepository: ServerRepository = mockk() + private val serverDao: JellyfinServerDao = mockk() private val userPreferencesService: UserPreferencesService = mockk() + private val userLibraryApi: UserLibraryApi = mockk() lateinit var intentService: IntentService private val serverId = UUID.randomUUID() private val userId = UUID.randomUUID() private val currentUser = currentUser(serverId, userId) + private val itemId = UUID.randomUUID() private val protectedCurrentUser = CurrentUser( @@ -49,6 +62,9 @@ class IntentServiceTest { @Before fun setup() { intentService = IntentService(api, serverRepository, userPreferencesService) + every { api.userLibraryApi } returns userLibraryApi + coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId)) + every { serverRepository.serverDao } returns serverDao } private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { @@ -63,16 +79,20 @@ class IntentServiceTest { } } + private fun setupAutoSignInUnprotected() { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + } + @Test fun `Test auto sign in with unprotected profile`() = runTest { - setupPreferences { - signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + setupAutoSignInUnprotected() val intent = Intent() val result = intentService.prepare(intent) @@ -221,4 +241,243 @@ class IntentServiceTest { coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } + + @Test + fun `Test parseIntent with view item`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.restoreSession( + newServerId, + newUserId, + ) + } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view, itemId, and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.restoreSession( + newServerId, + newUserId, + ) + } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with search`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals("", first.query) + } + + @Test + fun `Test parseIntent with search query`() = + runTest { + setupAutoSignInUnprotected() + + val query = "query123" + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + putExtra(SearchManager.QUERY, query) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals(query, first.query) + } + + @Test + fun `Test parseIntent with play but no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Error>(result) + } + + @Test + fun `Test parseIntent with play itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with play itemId and params`() = + runTest { + setupAutoSignInUnprotected() + + val position = 4_000L + val shuffle = true + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + putExtra("position", position) + putExtra("shuffle", shuffle) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + assertEquals(shuffle, second.shuffle) + assertEquals(position, second.positionMs) + + coVerify { userLibraryApi.getItem(itemId) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 7f51e994..34d962da 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -4,12 +4,16 @@ import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.ibm.icu.impl.Assert import io.mockk.MockKMatcherScope import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.jellyfin.sdk.model.api.UserDto +import org.junit.Assert.assertNotNull +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() } @@ -128,3 +132,20 @@ fun currentUser( server(serverId), user(userId), ) + +/** + * Asserts that the object is not null and is type `T` + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +inline fun <reified T : Any> assertIs(obj: Any?) { + contract { + returns() implies (obj is T) + } + assertNotNull(obj) + val result = T::class.isInstance(obj) + if (!result) { + Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") + } +} From 32a56e83875fa9474ad9d57da0248ee144c887c5 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 17 Sep 2026 14:41:36 -0400 Subject: [PATCH 473/475] Wait for switched user's home settings before fetching data (#1981) ## Description There is a race condition when switching users between `HomeViewModel.init` and `HomeSetttingsService.loadCurrentSettings`. This happens more often if the new user doesn't have a customized home page. The result is that the previous user's home page settings are used for the initial load of the home page. This PR fixes this by collecting the flows for current user & home settings, waiting until both are valid and refer to the same user, then fetch the data. ### Related issues Fixes #1976 Maybe related (and fixes) #1980, but need to investigate more ### Testing Emulator. It's easy to reproduce by adding a manual `delay(2.seconds)` at the start of `HomeSetttingsService.loadCurrentSettings`. ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 16 +- .../services/SetupNavigationManager.kt | 3 - .../wholphin/ui/main/HomeViewModel.kt | 323 +++++++++++------- .../ui/main/settings/HomeSettingsViewModel.kt | 8 +- .../wholphin/ui/setup/SwitchUserViewModel.kt | 1 + 5 files changed, 208 insertions(+), 143 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 e7b5db00..3371f45e 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 @@ -245,7 +245,7 @@ class HomeSettingsService settings.rows.mapIndexed { index, config -> resolve(index, config) } - HomePageResolvedSettings(resolvedRows) + HomePageResolvedSettings(userId, resolvedRows) } else { createDefault(userId) } @@ -256,12 +256,15 @@ class HomeSettingsService /** * Resolve the settings and set them to be the current settings */ - suspend fun updateCurrent(settings: HomePageSettings) { + suspend fun updateCurrent( + userId: UUID, + settings: HomePageSettings, + ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - val resolvedSettings = HomePageResolvedSettings(resolvedRows) + val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) currentSettings.update { resolvedSettings } } @@ -312,7 +315,7 @@ class HomeSettingsService ), ) val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(rowConfig) + return HomePageResolvedSettings(userId, rowConfig) } /** @@ -428,7 +431,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(rowConfigs) + HomePageResolvedSettings(userId, rowConfigs) } else { null } @@ -1224,10 +1227,11 @@ data class HomeRowConfigDisplay( * @see HomePageSettings */ data class HomePageResolvedSettings( + val userId: UUID, val rows: List<HomeRowConfigDisplay>, ) { companion object { - val EMPTY = HomePageResolvedSettings(listOf()) + val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) } } 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 fa42469e..abe39959 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 @@ -27,9 +27,6 @@ class SetupNavigationManager fun navigateTo(destination: SetupDestination) { backStack[0] = destination log() - if (destination !is SetupDestination.AppContent) { - navigationManager.reloadHome() - } } private fun log() { 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 bceccc5e..9e405196 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 @@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.services.ServerReportService 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.collectLatestIn +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -31,6 +33,8 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -44,6 +48,7 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -68,150 +73,204 @@ class HomeViewModel private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow<HomeState> = _state + private var dataLoadingJob: Job? = null + init { datePlayedService.invalidateAll() -// init() + serverRepository.currentUserDtoFlow + .combinePair(homeSettingsService.currentSettings) + .collectLatestIn(viewModelScope) { (userDto, settings) -> + Timber.v( + "Got new userDto & settings, userId=%s, settings?=%s", + userDto?.id, + settings != HomePageResolvedSettings.EMPTY, + ) + _state.update { HomeState.EMPTY } + dataLoadingJob?.cancel() + if (userDto == null) { + Timber.d("UserDto is null") + return@collectLatestIn + } + if (settings == HomePageResolvedSettings.EMPTY) { + Timber.d("Home settings are empty") + return@collectLatestIn + } + if (userDto.id != settings.userId) { + Timber.d("User IDs don't match: %s vs %s", userDto?.id, settings.userId) + return@collectLatestIn + } + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } } fun init() { viewModelScope.launchIO { Timber.d("init HomeViewModel") - try { - val preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - - serverRepository.currentUserDto?.let { userDto -> - val libraries = - navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + serverRepository.currentUserDto?.let { userDto -> + if (dataLoadingJob?.isActive == false) { val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - val state = state.value - - // 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=%s, state.loadingState=%s, %s rows", - refresh, - state.loadingState, - settings.rows.size, - ) - _state.update { - it.copy( - loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, - refreshState = LoadingState.Loading, - settings = settings, - homeRows = - if (refresh) { - it.homeRows - } else { - List(settings.rows.size) { - HomeRowLoadingState.Pending(EmptyStringProvider) - } - }, + if (userDto.id == settings.userId) { + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } else { + Timber.d( + "Init user IDs don't match: %s vs %s", + userDto.id, + settings.userId, ) } - - val semaphore = Semaphore(4) - - val deferred = - settings.rows - .map { row -> - viewModelScope.async(WholphinDispatchers.IO) { - semaphore.withPermit { - Timber.v("Fetching row: %s", row) - try { - homeSettingsService.fetchDataForRow( - row = row.config, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = libraries, - limit = prefs.maxItemsPerRow, - isRefresh = refresh, - ) - } catch (ex: InvalidStatusException) { - if (ex.status == 404) { - Timber.w(ex, "404 on row %s", row) - HomeRowLoadingState.Success( - row.title, - emptyList(), - ) - } else { - Timber.e( - ex, - "Error %s on row %s", - ex.status, - row, - ) - HomeRowLoadingState.Error( - row.title, - exception = ex, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error on row %s", row) - HomeRowLoadingState.Error( - row.title, - exception = ex, - ) - } - } - } - } - - if (refresh) { - // Replace rows as they complete - val remaining = deferred.withIndex().toMutableList() - while (remaining.isNotEmpty()) { - val (rowIndex, rowData) = - select { - // "Return" the first remaining that is completed - remaining - .forEach { (rowIndex, deferred) -> - deferred.onAwait { rowIndex to it } - } - } - Timber.v("Got row data index=%s", rowIndex) - remaining.removeIf { it.index == rowIndex } - _state.update { state -> - val newRows = - state.homeRows.toMutableList().apply { - set(rowIndex, rowData) - } - state.copy( - homeRows = newRows, - ) - } - } - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - ) - } - } else { - val rows = deferred.awaitAll() - Timber.v("Got all rows") - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - homeRows = rows, - ) - } - } - Timber.d("Home page load complete") - } - } catch (ex: Exception) { - Timber.e(ex, "Exception during home page loading") - if (state.value.loadingState == LoadingState.Success) { - showToast(context, "Error refreshing home: ${ex.localizedMessage}") - _state.update { it.copy(refreshState = LoadingState.Error(ex)) } } else { - _state.update { - it.copy(loadingState = LoadingState.Error(ex)) + Timber.v("Data loading job is active") + } + } + } + } + + suspend fun loadHomeRows( + userDto: UserDto, + settings: HomePageResolvedSettings, + ) { + Timber.i("Starting loadHomeRows") + try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + + val state = state.value + + // 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=%s, state.loadingState=%s, %s rows", + refresh, + state.loadingState, + settings.rows.size, + ) + _state.update { + it.copy( + loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, + refreshState = LoadingState.Loading, + settings = settings, + homeRows = + if (refresh) { + it.homeRows + } else { + List(settings.rows.size) { + HomeRowLoadingState.Pending(EmptyStringProvider) + } + }, + ) + } + + val semaphore = Semaphore(4) + + val deferred = + settings.rows + .map { row -> + viewModelScope.async(WholphinDispatchers.IO) { + semaphore.withPermit { + Timber.v("Fetching row: %s", row) + try { + homeSettingsService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + limit = prefs.maxItemsPerRow, + isRefresh = refresh, + ) + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + Timber.w(ex, "404 on row %s", row) + HomeRowLoadingState.Success( + row.title, + emptyList(), + ) + } else { + Timber.e( + ex, + "Error %s on row %s", + ex.status, + row, + ) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } + } } + + if (refresh) { + // Replace rows as they complete + val remaining = deferred.withIndex().toMutableList() + while (remaining.isNotEmpty()) { + val (rowIndex, rowData) = + select { + // "Return" the first remaining that is completed + remaining + .forEach { (rowIndex, deferred) -> + deferred.onAwait { rowIndex to it } + } + } + Timber.v("Got row data index=%s", rowIndex) + remaining.removeIf { it.index == rowIndex } + _state.update { state -> + val newRows = + state.homeRows.toMutableList().apply { + set(rowIndex, rowData) + } + state.copy( + homeRows = newRows, + ) + } + } + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + ) + } + } else { + val rows = deferred.awaitAll() + Timber.v("Got all rows") + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + homeRows = rows, + ) + } + } + Timber.d("Home page load complete") + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception during home page loading") + if (state.value.loadingState == LoadingState.Success) { + showToast(context, "Error refreshing home: ${ex.localizedMessage}") + _state.update { it.copy(refreshState = LoadingState.Error(ex)) } + } else { + _state.update { + it.copy(loadingState = LoadingState.Error(ex)) } } } @@ -298,7 +357,7 @@ data class HomeState( HomeState( LoadingState.Pending, LoadingState.Pending, - listOf(), + emptyList(), HomePageResolvedSettings.EMPTY, ) } 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 64c0308e..e3eafcbb 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 @@ -595,7 +595,7 @@ class HomeSettingsViewModel } if (shouldSave) { homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent(settings) + homeSettingsService.updateCurrent(user.id, settings) showSaveToast() } else { Timber.d("No changes") @@ -616,7 +616,11 @@ class HomeSettingsViewModel _state.update { update.invoke(it) } - homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } + serverRepository.currentUser?.id?.let { userId -> + homeSettingsService.currentSettings.update { + HomePageResolvedSettings(userId, state.value.rows) + } + } } fun resizeCards(relative: Int) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index c47b86c5..e1a6dd96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -132,6 +132,7 @@ class SwitchUserViewModel viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) + navigationManager.reloadHome() setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) SwitchUserResult.Success } catch (ex: InvalidStatusException) { From 170989c31e2cfa8d7b295e3decc46eb96c4c92ca Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Thu, 17 Sep 2026 14:41:45 -0400 Subject: [PATCH 474/475] Guard against crash if episode has `seasonId` but no `seriesId` (#1964) ## Description When clicking on an episode, the app assumes if there is a season ID for the episode, then there must be a series ID. However, on my JF 12.0 server, an episode (with no metadata) appeared on my Continue Watching and it has a season ID (which in turn has a series ID) but no series ID. I'm not sure if this really a valid situation or if it's a server bug, but Wholphin shouldn't crash at least. So this PR checks both `seasonId` and `seriesId` and if either is null, falls back to the generic "Episode Page". Similarly, if a season has no `seriesId` (again, not sure if a valid situation), a crash won't occur, however there is no generic "Season Page", so an error page is shown. ### Related issues TODO, check JF server issues, maybe fixed in 12.1? ### Testing Emulator ## Screenshots N/a ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 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 dd1236a4..eefb001b 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 @@ -237,21 +237,30 @@ data class BaseItem( // Redirect episodes & seasons to their series if possible when (type) { BaseItemKind.EPISODE -> { - data.seasonId?.let { seasonId -> + val seasonId = data.seasonId + val seriesId = data.seriesId + if (seasonId != null && seriesId != null) { Destination.SeriesOverview( - data.seriesId!!, + seriesId, BaseItemKind.SERIES, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(this) + } else { + Destination.MediaItem(this) + } } BaseItemKind.SEASON -> { - Destination.SeriesOverview( - data.seriesId!!, - BaseItemKind.SERIES, - SeasonEpisodeIds(id, indexNumber, null, null), - ) + val seriesId = data.seriesId + if (seriesId != null) { + Destination.SeriesOverview( + seriesId, + BaseItemKind.SERIES, + SeasonEpisodeIds(id, indexNumber, null, null), + ) + } else { + Destination.MediaItem(this) + } } BaseItemKind.TV_CHANNEL -> { From 365f8f8adacf4dcaf7d05317917830ed3a439668 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:16:50 -0400 Subject: [PATCH 475/475] Update android-actions/setup-android digest to be39fa8 (#1986) --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b551456a..24711c4a 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -21,7 +21,7 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 with: packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path