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:
parent
54966f9124
commit
51ab39ed3b
17 changed files with 282 additions and 250 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", " ")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue