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
This commit is contained in:
Damontecres 2026-08-13 12:36:20 -04:00 • committed by GitHub
parent 54966f9124
commit 51ab39ed3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 282 additions and 250 deletions

View file

@ -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.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences 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.ProgramItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap 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.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions 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.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType 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.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
@ -280,7 +280,7 @@ class HomeSettingsService
libraries libraries
.mapIndexed { index, it -> .mapIndexed { index, it ->
val parentId = it.itemId val parentId = it.itemId
val title = getRecentlyAddedTitle(it) val title = getRecentlyAddedTitle(it.name)
if (it.collectionType == CollectionType.LIVETV) { if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = index, id = index,
@ -766,13 +766,11 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val library = val library = libraries.firstOrNull { it.itemId == row.parentId }
libraries val title = getRecentlyAddedTitle(library?.name)
.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library)
val request = val request =
GetLatestMediaRequest( GetLatestMediaRequest(
fields = SlimItemFields, fields = library.itemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = row.parentId, parentId = row.parentId,
groupItems = true, groupItems = true,
@ -797,12 +795,9 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyReleased -> { is HomeRowConfig.RecentlyReleased -> {
val name = val library = libraries.firstOrNull { it.itemId == row.parentId }
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title = val title =
name?.let { library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it) ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released) } ?: ResStringProvider(R.string.recently_released)
val request = val request =
@ -821,7 +816,7 @@ class HomeSettingsService
SortOrder.ASCENDING, SortOrder.ASCENDING,
SortOrder.DESCENDING, SortOrder.DESCENDING,
), ),
fields = DefaultItemFields, fields = library.itemFields,
recursive = true, recursive = true,
maxPremiereDate = LocalDateTime.now(), maxPremiereDate = LocalDateTime.now(),
isUnaired = false, isUnaired = false,
@ -851,6 +846,7 @@ class HomeSettingsService
} }
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request = val request =
GetItemsRequest( GetItemsRequest(
userId = userDto.id, userId = userDto.id,
@ -885,7 +881,7 @@ class HomeSettingsService
} }
}, },
limit = limit, limit = limit,
fields = DefaultItemFields, fields = library.itemFields,
) )
// Not using getItemName because we want to throw the 404 // Not using getItemName because we want to throw the 404
@ -970,7 +966,7 @@ class HomeSettingsService
GetPersonsRequest( GetPersonsRequest(
userId = userDto.id, userId = userDto.id,
limit = limit, limit = limit,
fields = DefaultItemFields, fields = HomeItemFields,
isFavorite = true, isFavorite = true,
enableImages = true, enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY), enableImageTypes = listOf(ImageType.PRIMARY),
@ -988,12 +984,18 @@ class HomeSettingsService
) )
} }
} else { } else {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request = val request =
GetItemsRequest( GetItemsRequest(
userId = userDto.id, userId = userDto.id,
recursive = true, recursive = true,
limit = limit, limit = limit,
fields = DefaultItemFields, fields = fields,
includeItemTypes = listOf(row.kind), includeItemTypes = listOf(row.kind),
isFavorite = true, isFavorite = true,
) )
@ -1027,7 +1029,7 @@ class HomeSettingsService
GetRecordingsRequest( GetRecordingsRequest(
userId = userDto.id, userId = userDto.id,
isInProgress = true, isInProgress = true,
fields = DefaultItemFields, fields = HomeItemFields,
limit = limit, limit = limit,
enableImages = true, enableImages = true,
enableUserData = true, enableUserData = true,
@ -1098,7 +1100,7 @@ class HomeSettingsService
val request = val request =
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
userId = userDto.id, userId = userDto.id,
fields = DefaultItemFields, fields = HomeItemFields,
limit = limit, limit = limit,
enableImages = true, enableImages = true,
) )
@ -1225,10 +1227,17 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle(library: Library?): StringProvider = fun getRecentlyAddedTitle(name: String?): StringProvider =
if (library?.isRecordingFolder == true) { name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
ResStringProvider(R.string.recently_recorded) ?: ResStringProvider(R.string.recently_added)
} else {
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) } private val Library?.itemFields: List<ItemFields>
?: ResStringProvider(R.string.recently_added) 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)

View file

@ -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.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.google.protobuf.MessageLiteOrBuilder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -76,21 +77,18 @@ class MediaReportService
model=${Build.MODEL} model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT} apiLevel=${Build.VERSION.SDK_INT}
playbackPrefs=${ playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()}
appPreferences.playbackPreferences.toString().replace("\n", ", ") experimental=${appPreferences.experimentalPreferences.toStringOneLine()}
.replace("\t", " ")
}
experimental=${
appPreferences.experimentalPreferences.toString().replace("\n", ", ")
.replace("\t", " ")
}
deviceProfile=$deviceProfileJson
mediaSources=$sourcesJson mediaSources=$sourcesJson
deviceProfile=$deviceProfileJson
""".trimIndent() """.trimIndent()
Timber.w(body) body.chunked(2048).forEach { Timber.w(it) }
Timber.w("End send media info")
val response by api.clientLogApi.logFile(body) val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}") showToast(context, "Sent! Filename=${response.fileName}")
} }
} }
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -94,6 +95,8 @@ class ServerEventListener
.joinToString("\n") .joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG) showToast(context, toast, Toast.LENGTH_LONG)
} }
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(activity.lifecycleScope) }.launchIn(activity.lifecycleScope)
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -61,8 +62,6 @@ class UpdateChecker
private const val APK_MIME_TYPE = "application/vnd.android.package-archive" 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:(.+) -->") private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED val ACTIVE = BuildConfig.UPDATING_ENABLED
@ -158,8 +157,8 @@ class UpdateChecker
private fun getRelease(request: Request): Release? { private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use { return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) { if (it.isSuccessful) {
val result = Json.parseToJsonElement(it.body!!.string()) val result = Json.parseToJsonElement(it.body.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name) val version = Version.tryFromString(name)
val publishedAt = val publishedAt =
@ -195,6 +194,7 @@ class UpdateChecker
/** /**
* Download and install an update * Download and install an update
*/ */
@SuppressLint("RequestInstallPackagesPolicy")
suspend fun installRelease( suspend fun installRelease(
release: Release, release: Release,
callback: DownloadCallback, callback: DownloadCallback,
@ -208,7 +208,7 @@ class UpdateChecker
.get() .get()
.build() .build()
okHttpClient.newCall(request).execute().use { okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) { if (it.isSuccessful) {
Timber.v("Request successful for ${release.downloadUrl}") Timber.v("Request successful for ${release.downloadUrl}")
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
callback.contentLength(it.body.contentLength()) callback.contentLength(it.body.contentLength())
@ -230,7 +230,7 @@ class UpdateChecker
contentValues, contentValues,
) )
if (uri != null) { if (uri != null) {
it.body!!.byteStream().use { input -> it.body.byteStream().use { input ->
resolver.openOutputStream(uri).use { output -> resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback) copyTo(input, output!!, callback = callback)
} }
@ -290,7 +290,7 @@ class UpdateChecker
downloadDir.mkdirs() downloadDir.mkdirs()
val targetFile = File(downloadDir, APK_NAME) val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output -> targetFile.outputStream().use { output ->
response.body!!.byteStream().use { input -> response.body.byteStream().use { input ->
copyTo(input, output, callback = callback) copyTo(input, output, callback = callback)
} }
} }
@ -374,7 +374,7 @@ data class Release(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1", "#$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\\*\\*.*"), "") .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
} }
@ -387,29 +387,30 @@ interface DownloadCallback {
suspend fun copyTo( suspend fun copyTo(
input: InputStream, input: InputStream,
out: OutputStream, out: OutputStream,
bufferSize: Int = 16 * 1024, bufferSize: Int = 64 * 1024,
callback: DownloadCallback, callback: DownloadCallback,
): Long { ): Long =
var bytesCopied: Long = 0 withContext(WholphinDispatchers.IO) {
val buffer = ByteArray(bufferSize) var bytesCopied: Long = 0
var bytes = input.read(buffer) val buffer = ByteArray(bufferSize)
while (bytes >= 0) { var bytes = input.read(buffer)
out.write(buffer, 0, bytes) while (bytes >= 0) {
bytesCopied += bytes out.write(buffer, 0, bytes)
withContext(WholphinDispatchers.Main) { bytesCopied += bytes
callback.bytesDownloaded(bytesCopied) withContext(WholphinDispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
} }
bytes = input.read(buffer) return@withContext bytesCopied
} }
return bytesCopied
}
fun getDownloadUrl( fun getDownloadUrl(
assets: JsonArray, assets: JsonArray,
debug: Boolean, debug: Boolean,
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
): String? { ): 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 releaseSuffix = if (debug) "-debug" else "-release"
val preferredNames = val preferredNames =
buildList { buildList {

View file

@ -49,8 +49,6 @@ const val DEFAULT_PAGE_SIZE = 100
*/ */
val DefaultItemFields = val DefaultItemFields =
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.TRICKPLAY, ItemFields.TRICKPLAY,
@ -58,7 +56,6 @@ val DefaultItemFields =
ItemFields.CHAPTERS, ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE, ItemFields.CAN_DELETE,
) )
@ -67,13 +64,16 @@ val DefaultItemFields =
*/ */
val SlimItemFields = val SlimItemFields =
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID, ItemFields.CAN_DELETE,
)
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE, ItemFields.CAN_DELETE,
) )

View file

@ -265,7 +265,7 @@ fun ItemCardImageOverlay(
} }
} }
watchedPercent?.let { percent -> if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
Box( Box(
modifier = modifier =
Modifier Modifier
@ -274,7 +274,7 @@ fun ItemCardImageOverlay(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(Cards.playedPercentHeight) .height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()), .fillMaxWidth((watchedPercent / 100.0).toFloat()),
) )
} }
} }

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -99,7 +100,7 @@ fun CollectionFolderList(
val positionFocusRequester = remember { FocusRequester() } val positionFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
var showHeader by remember { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
fun jumpTo(newPosition: Int) { fun jumpTo(newPosition: Int) {
scope.launch { scope.launch {

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -32,7 +33,7 @@ fun CollectionFolderGeneric(
sortOptions: List<ItemSortBy> = VideoSortOptions, sortOptions: List<ItemSortBy> = VideoSortOptions,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val viewOptions = val viewOptions =
remember(usePosters) { remember(usePosters) {
if (usePosters) { if (usePosters) {

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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() val state by viewModel.state.collectAsState()
CollectionFolderView( CollectionFolderView(
preferences = preferences, preferences = preferences,

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -24,7 +24,7 @@ fun CollectionFolderPlaylist(
filter: CollectionFolderFilter = CollectionFolderFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -24,7 +24,7 @@ fun CollectionFolderRecordings(
filter: CollectionFolderFilter = CollectionFolderFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -259,7 +260,7 @@ fun DiscoverRequestGrid(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var showHeader by remember { mutableStateOf(state.startIndex < 6) } var showHeader by rememberSaveable { mutableStateOf(state.startIndex < 6) }
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,

View file

@ -576,7 +576,7 @@ fun SearchPage(
viewModel.navigationManager.navigateTo(dest) viewModel.navigationManager.navigateTo(dest)
} }
var showHeader by remember { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val positionCallback = { columns: Int, index: Int -> val positionCallback = { columns: Int, index: Int ->
showHeader = index < columns showHeader = index < columns
} }

View file

@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService 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.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
@ -381,7 +382,7 @@ class HomeSettingsViewModel
} }
LibraryRowType.RECENTLY_RECORDED -> { LibraryRowType.RECENTLY_RECORDED -> {
val title = ResStringProvider(R.string.recently_recorded) val title = getRecentlyAddedTitle(library.name)
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = title, title = title,

View file

@ -84,6 +84,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -1470,6 +1471,8 @@ class PlaybackViewModel
} }
} }
} }
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
/** /**

View file

@ -278,188 +278,182 @@ private fun SwitchServerContentInternal(
} }
if (showAddServer) { if (showAddServer) {
var showEnterAddress by remember { mutableStateOf(false) } AddServerDialog(
onDismissRequest = { showAddServer = false },
viewModel = viewModel,
)
}
}
}
LaunchedEffect(Unit) { @Composable
viewModel.clearAddServerState() private fun AddServerDialog(
if (!showEnterAddress) { onDismissRequest: () -> Unit,
viewModel.discoverServers() 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) LaunchedEffect(Unit) {
val filteredDiscoveredServers = viewModel.clearAddServerState()
remember(state.discoveredServers) { if (!showEnterAddress) {
val seenUrls = mutableSetOf<String>() viewModel.discoverServers()
state.discoveredServers.filter { server -> }
val normalizedUrl = server.url.lowercase().trim() }
if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
} else {
seenUrls.add(normalizedUrl) // Default focus to first discovered server if available
true // First occurrence, keep it 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() } TextButton(
onClick = {
// Default focus to first discovered server if available showEnterAddress = true
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) { },
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) { modifier = Modifier.align(Alignment.CenterHorizontally),
firstDiscoveredServerFocusRequester.tryRequestFocus() ) {
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( LaunchedEffect(Unit) {
onDismissRequest = { textBoxFocusRequester.tryRequestFocus()
showAddServer = false }
showEnterAddress = false LaunchedEffect(url) {
viewModel.clearAddServerState() viewModel.clearAddServerState()
}, }
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp, Text(
) { text = stringResource(R.string.enter_server_url),
Column( )
horizontalAlignment = Alignment.CenterHorizontally, EditTextBox(
verticalArrangement = Arrangement.spacedBy(8.dp), 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 =
Modifier Modifier
.padding(16.dp) .testTag("server_url_text")
.fillMaxWidth(.4f), .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) { if (addServerState == LoadingState.Loading) {
// Show discovered servers first CircularProgress(Modifier.size(32.dp))
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))
}
} else { } else {
// Show enter server address form Text(text = stringResource(R.string.submit))
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))
}
}
} }
} }
} }

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions 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.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue import org.jellyfin.sdk.discovery.RecommendedServerIssue
@ -134,6 +135,14 @@ class SwitchServerViewModel
.getPublicSystemInfo() .getPublicSystemInfo()
.content .content
ServerConnectionStatus.Success(systemInfo) 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) { } catch (ex: Exception) {
Timber.w(ex, "Error checking server ${server.url}") Timber.w(ex, "Error checking server ${server.url}")
ServerConnectionStatus.Error(ex.localizedMessage) ServerConnectionStatus.Error(ex.localizedMessage)
@ -155,12 +164,15 @@ class SwitchServerViewModel
serverRepository.addAndChangeServer(updatedServer) serverRepository.addAndChangeServer(updatedServer)
navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) navigationManager.navigateTo(SetupDestination.UserList(updatedServer))
} else if (result is ServerConnectionStatus.Error) { } 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) } _state.update { it.copy(addServerState = LoadingState.Loading) }
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
@ -186,7 +198,9 @@ class SwitchServerViewModel
_state.update { it.copy(addServerState = LoadingState.Success) } _state.update { it.copy(addServerState = LoadingState.Success) }
navigationManager.navigateTo(SetupDestination.UserList(server)) navigationManager.navigateTo(SetupDestination.UserList(server))
} else { } 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 { } else {
Timber.w("Error connecting with %s: %s", inputUrl, scores) Timber.w("Error connecting with %s: %s", inputUrl, scores)
@ -215,10 +229,12 @@ class SwitchServerViewModel
} }
val message = "Error, tried addresses:\n$errors" val message = "Error, tried addresses:\n$errors"
_state.update { it.copy(addServerState = LoadingState.Error(message)) } _state.update { it.copy(addServerState = LoadingState.Error(message)) }
if (showToast) showToast(context, message)
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error creating API for $inputUrl") Timber.w(ex, "Error creating API for $inputUrl")
_state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) }
if (showToast) showToast(context, "Error: ${ex.localizedMessage}")
} }
} }
} }
@ -247,6 +263,9 @@ class SwitchServerViewModel
.toMutableList() .toMutableList()
.apply { .apply {
add(jellyfinServer) add(jellyfinServer)
}.distinctBy {
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
it.url.lowercase().trim()
}, },
) )
} }