From 51ab39ed3bdef5f2efecf76d79861c145e8a1408 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 13 Aug 2026 12:36:20 -0400 Subject: [PATCH] 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 + 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("") 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 = Build.SUPPORTED_ABIS.toList(), + supportedABIs: List = 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 = 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() - 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() }, ) }