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.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)
|
||||||
|
|
|
||||||
|
|
@ -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", " ")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()) },
|
||||||
|
|
|
||||||
|
|
@ -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()) },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue