From af5459d5e8786a1fc2cbccbc4eef289d36c09c62 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli <75630095+luqmanfadlli@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:09:29 +0700 Subject: [PATCH 01/11] fix: trailer popup player sizing The previous popup layout only applied fillMaxWidth() to PlatformPlayerSurface, which could leave the underlying native UIView with zero-height bounds on iOS. As a result, the MPV bridge queued the trailer load but never issued the loadfile command because the player view was not ready. This changes ensure the trailer popup player surface fills its 16:9 container on iOS and Android. --- .../app/features/details/components/TrailerPlayerPopup.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index c12ead6b9..09976eba1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -173,7 +174,7 @@ fun TrailerPlayerPopup( sourceUrl = playbackSource.videoUrl, sourceAudioUrl = playbackSource.audioUrl, useYoutubeChunkedPlayback = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxSize(), playWhenReady = true, resizeMode = PlayerResizeMode.Fit, useNativeController = true, From 108d6cfb23eedbe2f1473d2f71b065d29f5a8772 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli <75630095+luqmanfadlli@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:12:08 +0700 Subject: [PATCH 02/11] feat(ios): implement native controller polyfill for MPV player MPV lacks a built-in native playback controller on iOS. Previously, passing `useNativeController = true` like being used in `TrailerPlayerPopup.kt` resulted in a bare video surface with no user interaction. This commit injects a custom Compose touch overlay (play/pause control) directly into `PlayerEngine.ios.kt` when native controls are requested. By isolating this polyfill to the iOS source set, we prevent touch event interception issues on Android, ensuring ExoPlayer's robust native UI remains fully functional and unobstructed. --- .../app/features/player/PlayerEngine.ios.kt | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 5fa4ffe74..e5f134067 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -4,9 +4,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -87,6 +99,23 @@ actual fun PlatformPlayerSurface( bridge.setPlaybackSpeed(speed) } + override fun currentPlayerVolume(): PlayerAudioLevel { + val current = bridge.getVolume().coerceIn(0f, 2f) + return PlayerAudioLevel( + fraction = current, + isMuted = current <= 0.001f, + ) + } + + override fun setPlayerVolume(level: Float): PlayerAudioLevel { + val target = level.coerceIn(0f, 2f) + bridge.setVolume(target) + return PlayerAudioLevel( + fraction = target, + isMuted = target <= 0.001f, + ) + } + override fun setMuted(muted: Boolean) { bridge.setMuted(muted) } @@ -295,17 +324,50 @@ actual fun PlatformPlayerSurface( // Cleanup DisposableEffect(bridge) { + IosPictureInPictureSession.registerBridge(bridge) onDispose { + IosPictureInPictureSession.unregisterBridge(bridge) bridge.destroy() } } // Render the player view - UIKitViewController( - factory = { bridge.createPlayerViewController() }, - modifier = modifier, - interactive = false, - ) + Box(modifier = modifier) { + UIKitViewController( + factory = { bridge.createPlayerViewController() }, + modifier = Modifier.fillMaxSize(), + interactive = false, + ) + + if (useNativeController) { + var isPlayingLocal by remember { mutableStateOf(playWhenReady) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(if (!isPlayingLocal) Color.Black.copy(alpha = 0.4f) else Color.Transparent) + .clickable { + if (isPlayingLocal) { + bridge.pause() + isPlayingLocal = false + } else { + bridge.play() + isPlayingLocal = true + } + }, + contentAlignment = Alignment.Center + ) { + if (!isPlayingLocal) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(64.dp) + ) + } + } + } + } } private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { From c23fff9c73bac84259b9bb3efe7c307fd1c00f52 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli <75630095+luqmanfadlli@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:09:22 +0700 Subject: [PATCH 03/11] remove orphaned IosPictureInPictureSession --- .../kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 7cf6a7663..623b90ca6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -324,9 +324,7 @@ actual fun PlatformPlayerSurface( // Cleanup DisposableEffect(bridge) { - IosPictureInPictureSession.registerBridge(bridge) onDispose { - IosPictureInPictureSession.unregisterBridge(bridge) bridge.destroy() } } From 4e460793903f10cc6aad75f9ee4ff2b88be743ca Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sun, 14 Jun 2026 20:53:50 +0200 Subject: [PATCH 04/11] =?UTF-8?q?Change=20'Avan=C3=A7ado'=20to=20'Avan?= =?UTF-8?q?=C3=A7adas'=20in=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commonMain/composeResources/values-pt/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index d7ddbc3d1..2b4589e4e 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -404,7 +404,7 @@ Geral Conta Addons - Avançado + Avançadas Esquema Conteúdo e descoberta Continuar a ver From 1b7c9d6481f1e01ab8122dae8d0cd70aecbc4acb Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Sat, 13 Jun 2026 19:53:28 -0700 Subject: [PATCH 05/11] fix: apply tv/series alias and singular resource name to subtitle addon matching Co-Authored-By: Claude Sonnet 4.6 --- .../com/nuvio/app/features/player/SubtitleRepository.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index cc2d18e80..2acec2182 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -101,7 +101,7 @@ object SubtitleRepository { } _addonSubtitles.value = allSubs - if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) { + if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name.isSubtitleResourceName() } == true }) { _error.value = getString(Res.string.compose_player_no_subtitles_found) } _isLoading.value = false @@ -123,7 +123,8 @@ private fun String.isSubtitleResourceName(): Boolean = equals("subtitles", ignoreCase = true) || equals("subtitle", ignoreCase = true) private fun AddonResource.supportsSubtitleType(type: String, videoId: String): Boolean { - val typeMatches = types.isEmpty() || types.any { it.equals(type, ignoreCase = true) } + val canonical = canonicalSubtitleType(type) + val typeMatches = types.isEmpty() || types.any { canonicalSubtitleType(it).equals(canonical, ignoreCase = true) } if (!typeMatches) return false return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } } From a65fd5dc88476b0c252e3b06d55ec8782ce52471 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 18 Jun 2026 06:33:51 +0200 Subject: [PATCH 06/11] fix(ios): serialize catalog route target kind Store CatalogRoute.targetKind as CatalogTargetKind.name and convert it back in toCatalogTarget(). This keeps the typed route argument on a primitive String boundary so Compose Navigation does not require a custom enum NavType during graph construction. --- composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 0dadb2ddf..6e27e67a0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -305,7 +305,7 @@ data class StreamRoute( data class CatalogRoute( val title: String, val subtitle: String, - val targetKind: CatalogTargetKind, + val targetKind: String, val contentType: String, val supportsPagination: Boolean = false, val manifestUrl: String? = null, @@ -327,7 +327,7 @@ data class CatalogRoute( is CatalogTarget.Addon -> CatalogTargetKind.ADDON is CatalogTarget.Library -> CatalogTargetKind.LIBRARY is CatalogTarget.CollectionSource -> CatalogTargetKind.COLLECTION_SOURCE - }, + }.name, contentType = target.contentType, supportsPagination = target.supportsPagination, manifestUrl = (target as? CatalogTarget.Addon)?.manifestUrl, @@ -340,7 +340,7 @@ data class CatalogRoute( ) fun toCatalogTarget(): CatalogTarget = - when (targetKind) { + when (CatalogTargetKind.valueOf(targetKind)) { CatalogTargetKind.ADDON -> CatalogTarget.Addon( manifestUrl = requireNotNull(manifestUrl), contentType = contentType, From 232703c3cd7be745dedc6d67b6d582be717355c4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:00:25 +0530 Subject: [PATCH 07/11] Read sync backend credentials from env --- composeApp/build.gradle.kts | 39 +++++++++++++++-- .../app/core/network/SyncBackendConfig.kt | 43 +++---------------- .../app/core/network/SyncBackendRepository.kt | 9 ++-- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8dadc1c01..964b76961 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property + @get:Input + abstract val supabaseUrl: Property + + @get:Input + abstract val supabaseAnonKey: Property + + @get:Input + abstract val nuvioSupabaseUrl: Property + + @get:Input + abstract val nuvioSupabaseAnonKey: Property + + @get:Input + abstract val syncBackendManifestUrl: Property + @TaskAction fun generate() { val props = Properties() @@ -39,8 +54,10 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SupabaseConfig { - | const val URL = "${props.getProperty("SUPABASE_URL", "")}" - | const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}" + | const val URL = "${supabaseUrl.get()}" + | const val ANON_KEY = "${supabaseAnonKey.get()}" + | const val NUVIO_URL = "${nuvioSupabaseUrl.get()}" + | const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}" |} """.trimMargin() ) @@ -49,7 +66,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SyncBackendBootstrapConfig { - | const val SWITCH_MANIFEST_URL = "${props.getProperty("SYNC_BACKEND_MANIFEST_URL", "")}" + | const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}" |} """.trimMargin() ) @@ -208,12 +225,28 @@ val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName -> taskName.startsWith("bundlefull") || taskName.endsWith("bundle") } +val runtimeLocalProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +fun runtimeConfigValue(key: String, fallback: String = ""): String = + runtimeLocalProperties.getProperty(key)?.trim()?.takeIf { it.isNotBlank() } + ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } + ?: fallback val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) + supabaseUrl.set(runtimeConfigValue("SUPABASE_URL")) + supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY")) + nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) + nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) + syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL")) } tasks.withType>().configureEach { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt index 0ff7db977..ca45da22e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -38,16 +38,6 @@ data class SyncBackendManifest( val activeBackend: String, val revision: String = "", val forceLogoutOnChange: Boolean = true, - val backends: Map = emptyMap(), -) - -@Serializable -data class SyncBackendManifestBackend( - val displayName: String = "", - val supabaseUrl: String = "", - val anonKey: String = "", - val avatarPublicBaseUrl: String = "", - val schemaVersion: Int = 1, ) data class SyncBackendState( @@ -75,7 +65,8 @@ sealed interface SyncBackendRefreshResult { @Serializable internal data class StoredSyncBackendSelection( - val backend: SyncBackendConfig, + val backend: SyncBackendConfig? = null, + val backendId: String = "", val appliedRevision: String = "", ) @@ -94,9 +85,9 @@ object SyncBackendDefaults { SyncBackendConfig( id = SYNC_BACKEND_NUVIO_ID, displayName = "Nuvio", - supabaseUrl = "https://api.nuvio.tv", - anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI", - avatarPublicBaseUrl = "https://api.nuvio.tv/storage/v1/object/public/avatars", + supabaseUrl = SupabaseConfig.NUVIO_URL, + anonKey = SupabaseConfig.NUVIO_ANON_KEY, + avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars", schemaVersion = 1, ).normalized() @@ -118,28 +109,8 @@ internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendCon if (version != 1) return null val activeId = activeBackend.trim().lowercase() - val manifestBackend = backends.entries - .firstOrNull { (key, _) -> key.trim().lowercase() == activeId } - ?.value - ?: return null - val fallback = SyncBackendDefaults.byId(activeId) ?: return null - - val supabaseUrl = manifestBackend.supabaseUrl.trim().ifBlank { fallback.supabaseUrl } - val anonKey = manifestBackend.anonKey.trim().ifBlank { fallback.anonKey } - val avatarPublicBaseUrl = manifestBackend.avatarPublicBaseUrl.trim().ifBlank { - "$supabaseUrl/storage/v1/object/public/avatars" - } - - return SyncBackendConfig( - id = activeId, - displayName = manifestBackend.displayName.trim().ifBlank { fallback.displayName }, - supabaseUrl = supabaseUrl, - anonKey = anonKey, - avatarPublicBaseUrl = avatarPublicBaseUrl, - schemaVersion = manifestBackend.schemaVersion.takeIf { it > 0 } ?: fallback.schemaVersion, - ) - .normalized() - .takeIf { it.isUsableClientConfig() } + return SyncBackendDefaults.byId(activeId) + ?.takeIf { it.isUsableClientConfig() } } private fun SyncBackendConfig.isUsableClientConfig(): Boolean = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt index d38b49410..db3065293 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -36,9 +36,10 @@ object SyncBackendRepository { } val backend = storedSelection - ?.backend - ?.normalized() - ?.takeIf { storedBackend -> SyncBackendDefaults.byId(storedBackend.id) != null } + ?.let { selection -> + selection.backendId.ifBlank { selection.backend?.id.orEmpty() } + } + ?.let(SyncBackendDefaults::byId) ?: SyncBackendDefaults.hosted() _state.value = SyncBackendState( @@ -107,7 +108,7 @@ object SyncBackendRepository { val normalizedBackend = backend.normalized() val payload = json.encodeToString( StoredSyncBackendSelection( - backend = normalizedBackend, + backendId = normalizedBackend.id, appliedRevision = revision, ), ) From 0645f3504ef2e6d46a478cc735e6cfca5357e31e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:39:24 +0530 Subject: [PATCH 08/11] Align next up copy with TV app --- .../commonMain/composeResources/values-el/strings.xml | 6 +++--- .../src/commonMain/composeResources/values/strings.xml | 10 +++++----- .../kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt | 4 ++-- .../app/features/details/SeriesPlaybackResolverTest.kt | 6 +++--- .../com/nuvio/app/features/home/HomeScreenTest.kt | 8 ++++---- .../features/watching/domain/SeriesContinuityTest.kt | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 55a061cd2..fcb41fc95 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -355,7 +355,7 @@ Προτροπή συνέχισης κατά την εκκίνηση ΣΤΥΛ ΚΑΡΤΑΣ ΚΑΤΑ ΤΗΝ ΕΚΚΙΝΗΣΗ - ΣΥΜΠΕΡΙΦΟΡΑ UP NEXT + ΣΥΜΠΕΡΙΦΟΡΑ ΕΠΟΜΕΝΟΥ ΟΡΑΤΟΤΗΤΑ Εμφάνιση της ενότητας Συνέχεια παρακολούθησης στην Αρχική οθόνη. Εμφάνιση συνέχειας παρακολούθησης @@ -363,8 +363,8 @@ Κάρτα αφίσας με προτεραιότητα στο artwork Πλατύ Οριζόντια κάρτα με πλούσιες πληροφορίες - Όταν είναι ενεργοποιημένο, το Up Next συνεχίζει πάντα από το πιο απομακρυσμένο παρακολουθημένο επεισόδιο. Όταν είναι απενεργοποιημένο, ακολουθεί το πιο πρόσφατα παρακολουθηθέν επεισόδιο. Χρήσιμο αν ξαναβλέπετε παλαιότερα επεισόδια. - Up Next από το πιο απομακρυσμένο επεισόδιο + Εμφάνιση επόμενου επεισοδίου με βάση το πιο προχωρημένο προβληθέν επεισόδιο. Απενεργοποιήστε το για επαναπροβολές ώστε να χρησιμοποιείται το πιο πρόσφατα προβληθέν επεισόδιο. + Επόμενο από το πιο προχωρημένο επεισόδιο ΑΡΧΙΚΗ ΠΗΓΕΣ Εγκατάσταση, αφαίρεση, ανανέωση και ταξινόμηση των πηγών περιεχομένου σας. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index be123bdf7..134801bdb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -613,7 +613,7 @@ Released items first, upcoming at the end Poster Card Style ON LAUNCH - UP NEXT BEHAVIOR + NEXT UP BEHAVIOR VISIBILITY Display the Continue Watching shelf on the Home screen. Show Continue Watching @@ -624,7 +624,7 @@ Wide Info-dense horizontal card Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. - Up Next From Furthest Episode + Next Up From Furthest Episode Prefer episode thumbnails when available. Prefer Episode Thumbnails in Continue Watching HOME @@ -1189,7 +1189,7 @@ Mark previous seasons as watched Mark as unwatched Mark as watched - Up next + Next Up %1$s watched %1$dh %2$dm left %1$dm left @@ -1336,8 +1336,8 @@ Sign-out failed Sign-up failed Unable to load catalog items. - Up Next - Up Next • S%1$dE%2$d + Next Up + Next Up • S%1$dE%2$d %1$s logo Failed to load comments Could not load details from any addon. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt index 3c96e28c7..10356040a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -100,11 +100,11 @@ fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String { fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = if (seasonNumber != null && episodeNumber != null) { - resourceString("Up Next • S${seasonNumber}E${episodeNumber}") { + resourceString("Next Up • S${seasonNumber}E${episodeNumber}") { getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) } } else { - resourceString("Up Next") { getString(Res.string.continue_watching_up_next) } + resourceString("Next Up") { getString(Res.string.continue_watching_up_next) } } fun localizedMonthName(month: Int): String = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index a70a26274..faff7f847 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -37,7 +37,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(1, action.seasonNumber) assertEquals(3, action.episodeNumber) @@ -86,7 +86,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) } @@ -124,7 +124,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S4E15", action.label) + assertEquals("Next Up • S4E15", action.label) assertEquals("tmdb:98765:4:15", action.videoId) assertEquals(4, action.seasonNumber) assertEquals(15, action.episodeNumber) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 2ebf8447c..98c2ff52b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -33,7 +33,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "tt0944947:1:4", - subtitle = "Up Next • S1E4 • Cripples, Bastards, and Broken Things", + subtitle = "Next Up • S1E4 • Cripples, Bastards, and Broken Things", ) val movie = progressEntry( videoId = "movie-1", @@ -64,7 +64,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "show:1:5", - subtitle = "Up Next • S1E5 • The Wolf and the Lion", + subtitle = "Next Up • S1E5 • The Wolf and the Lion", ) val result = buildHomeContinueWatchingItems( @@ -87,7 +87,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "show:1:5", - subtitle = "Up Next • S1E5 • Next", + subtitle = "Next Up • S1E5 • Next", ) val result = buildHomeContinueWatchingItems( @@ -264,7 +264,7 @@ class HomeScreenTest { fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( videoId = "show:4:11", - subtitle = "Up Next • S4E11", + subtitle = "Next Up • S4E11", seedSeasonNumber = 4, seedEpisodeNumber = 10, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index 5aab3131e..eda075e4e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -39,7 +39,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(3, action.episodeNumber) } @@ -166,7 +166,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next • S2E2", action.label) + assertEquals("Next Up • S2E2", action.label) assertEquals("show:2:2", action.videoId) } } From 938f871a919ece16c978e547c5ead5944b9fd23f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:22:55 +0530 Subject: [PATCH 09/11] Fix iOS player engine volume overrides --- .../app/features/player/PlayerEngine.ios.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 261e73ac9..380fa9026 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -100,23 +100,6 @@ actual fun PlatformPlayerSurface( bridge.setPlaybackSpeed(speed) } - override fun currentPlayerVolume(): PlayerAudioLevel { - val current = bridge.getVolume().coerceIn(0f, 2f) - return PlayerAudioLevel( - fraction = current, - isMuted = current <= 0.001f, - ) - } - - override fun setPlayerVolume(level: Float): PlayerAudioLevel { - val target = level.coerceIn(0f, 2f) - bridge.setVolume(target) - return PlayerAudioLevel( - fraction = target, - isMuted = target <= 0.001f, - ) - } - override fun setMuted(muted: Boolean) { bridge.setMuted(muted) } From 20620dc4008e7d905b731b98b462a7fade83e79b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:14:54 +0530 Subject: [PATCH 10/11] Fix mobile continue watching backfill --- .../com/nuvio/app/features/home/HomeScreen.kt | 71 +++++++++++++- .../nuvio/app/features/home/HomeScreenTest.kt | 94 ++++++++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index de8b49839..aa91d463b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -81,6 +81,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight @@ -495,7 +496,9 @@ fun HomeScreen( val candidatesToResolve = completedSeriesCandidates.filter { candidate -> candidate.content.id !in cachedResolvedNextUpItems } - val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit) + val resolutionPlan = planHomeNextUpResolutionCandidates(candidatesToResolve) + val resolutionCandidates = resolutionPlan.initialCandidates + val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { withContext(Dispatchers.Main) { @@ -578,6 +581,59 @@ fun HomeScreen( todayIsoDate = todayIsoDate, seedLastWatchedMap = seedLastWatchedMap, ) + + if (deferredResolutionCandidates.isEmpty()) { + return@withContext + } + + val deferredCandidateBatches = deferredResolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) + for (batch in deferredCandidateBatches) { + if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) { + break + } + + val batchResults = batch.map { completedEntry -> + async { + semaphore.withPermit { + resolveHomeNextUpCandidate( + completedEntry = completedEntry, + watchProgressEntries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, + dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, + isTraktProgressActive = isTraktProgressActive, + ) + } + } + }.awaitAll() + batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } + + val resolvedBeforeBatch = freshResults.size + batchResults.filterNotNull().forEach { (contentId, item) -> + if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { + freshResults[contentId] = item + } + } + if (freshResults.size > resolvedBeforeBatch) { + val progressiveResults = cachedResolvedNextUpItems + freshResults + withContext(Dispatchers.Main) { + nextUpItemsBySeries = progressiveResults + processedNextUpContentIds = ( + cachedResolvedNextUpItems.keys + + processedFreshContentIds + ).toSet() + } + saveContinueWatchingSnapshots( + nextUpItemsBySeries = progressiveResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) + } + yield() + } } } @@ -820,6 +876,19 @@ private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY +internal data class HomeNextUpResolutionPlan( + val initialCandidates: List, + val deferredCandidates: List, +) + +internal fun planHomeNextUpResolutionCandidates( + candidates: List, +): HomeNextUpResolutionPlan = + HomeNextUpResolutionPlan( + initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit), + deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit), + ) + internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, isTraktProgressActive: Boolean, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 98c2ff52b..1f189673c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -23,6 +24,18 @@ class HomeScreenTest { assertEquals(32, HomeNextUpInitialResolutionLimit) } + @Test + fun `home next up resolution keeps candidates beyond initial limit for background resolution`() { + val candidates = (1..35).map { index -> completedSeriesCandidate(index) } + + val plan = planHomeNextUpResolutionCandidates(candidates) + + assertEquals(HomeNextUpInitialResolutionLimit, plan.initialCandidates.size) + assertEquals(3, plan.deferredCandidates.size) + assertEquals("show-1", plan.initialCandidates.first().content.id) + assertEquals("show-33", plan.deferredCandidates.first().content.id) + } + @Test fun `build home continue watching items removes duplicate video ids`() { val inProgress = progressEntry( @@ -76,6 +89,33 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `build home continue watching items keeps deferred next up items with metadata`() { + val nextUpItems = (1..35).associate { index -> + val id = "show-$index" + val item = continueWatchingItem( + videoId = "$id:1:$index", + subtitle = "Next Up • S1E$index", + imageUrl = "https://example.test/$id.jpg", + logo = "https://example.test/$id-logo.png", + episodeThumbnail = "https://example.test/$id-thumb.jpg", + ) + + id to ((10_000L - index) to item) + } + + val result = buildHomeContinueWatchingItems( + visibleEntries = emptyList(), + nextUpItemsBySeries = nextUpItems, + ) + val deferredItem = result.first { item -> item.parentMetaId == "show-33" } + + assertEquals(35, result.size) + assertEquals("https://example.test/show-33.jpg", deferredItem.imageUrl) + assertEquals("https://example.test/show-33-logo.png", deferredItem.logo) + assertEquals("https://example.test/show-33-thumb.jpg", deferredItem.episodeThumbnail) + } + @Test fun `build home continue watching items suppresses next up when series has in progress resume`() { val inProgress = progressEntry( @@ -138,6 +178,45 @@ class HomeScreenTest { assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) } + @Test + fun `build home continue watching items preserves cached in progress artwork fallback`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ) + val cached = ContinueWatchingItem( + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:4", + title = "Cached Show", + subtitle = "S1E4", + imageUrl = "https://example.test/cached.jpg", + logo = "https://example.test/logo.png", + poster = "https://example.test/poster.jpg", + background = "https://example.test/backdrop.jpg", + seasonNumber = 1, + episodeNumber = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + isNextUp = false, + resumePositionMs = 120_000L, + durationMs = 1_000_000L, + progressFraction = 0.12f, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + cachedInProgressByVideoId = mapOf(progress.videoId to cached), + nextUpItemsBySeries = emptyMap(), + ) + + assertEquals("https://example.test/cached.jpg", result.single().imageUrl) + assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( @@ -310,6 +389,9 @@ class HomeScreenTest { episodeNumber: Int? = 4, seedSeasonNumber: Int? = seasonNumber, seedEpisodeNumber: Int? = episodeNumber, + imageUrl: String? = null, + logo: String? = null, + episodeThumbnail: String? = null, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -317,10 +399,12 @@ class HomeScreenTest { videoId = videoId, title = "Show", subtitle = subtitle, - imageUrl = null, + imageUrl = imageUrl, + logo = logo, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), + episodeThumbnail = episodeThumbnail, isNextUp = true, nextUpSeedSeasonNumber = seedSeasonNumber, nextUpSeedEpisodeNumber = seedEpisodeNumber, @@ -344,6 +428,14 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = + CompletedSeriesCandidate( + content = WatchingContentRef(type = "series", id = "show-$index"), + seasonNumber = 1, + episodeNumber = index, + markedAtEpochMs = 10_000L - index, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L } From 4a013678f81b13eeb46ec6ac4f657837a3b755a8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:46:34 +0530 Subject: [PATCH 11/11] fix: update donations api --- .../composeResources/values/strings.xml | 4 + .../settings/SupportersContributorsPage.kt | 118 ++++++++++++++++-- mpvEx | 1 + 3 files changed, 115 insertions(+), 8 deletions(-) create mode 160000 mpvEx diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 134801bdb..eddd4964a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -777,6 +777,10 @@ Test notification Community See the people building and supporting Nuvio across Mobile, TV, and Web. + This month's server & maintenance + Covered. Additional support now goes to development. + After 100%%, additional support goes to development. + Loading funding progress... Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties. Contributors Supporters diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 3b3f80563..2299e120a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -73,6 +74,7 @@ private data class CommunityUiState( val hasLoadedContributors: Boolean = false, val contributors: List = emptyList(), val contributorsErrorMessage: String? = null, + val donationProgress: DonationProgress? = null, val isSupportersLoading: Boolean = false, val hasLoadedSupporters: Boolean = false, val supporters: List = emptyList(), @@ -94,13 +96,23 @@ private data class ContributionDto( @Serializable private data class DonationsResponseDto( + val currency: String? = null, + val monthlyGoal: DonationMonthlyGoalDto? = null, val donations: List = emptyList(), ) +@Serializable +private data class DonationMonthlyGoalDto( + val progressPercent: Double? = null, + val monthLabel: String? = null, +) + @Serializable private data class DonationDto( + val id: String? = null, val name: String? = null, val date: String? = null, + val createdAt: String? = null, val message: String? = null, ) @@ -119,6 +131,15 @@ internal data class SupporterDonation( val sortTimestamp: Long, ) +internal data class DonationProgress( + val progressPercent: Int, +) + +internal data class SupportersResult( + val supporters: List, + val progress: DonationProgress?, +) + private object SupportersContributorsRepository { private val json = Json { ignoreUnknownKeys = true; isLenient = true } @@ -147,7 +168,7 @@ private object SupportersContributorsRepository { ) } - suspend fun getSupporters(limit: Int = 200): Result> = runCatching { + suspend fun getSupporters(): Result = runCatching { val baseUrl = CommunityConfig.DONATIONS_BASE_URL.trim().removeSuffix("/") check(baseUrl.isNotBlank()) { getString(Res.string.community_supporters_not_configured) @@ -155,7 +176,7 @@ private object SupportersContributorsRepository { val response = httpRequestRaw( method = "GET", - url = "$baseUrl/api/donations?limit=$limit", + url = "$baseUrl/api/donations?view=recent", headers = emptyMap(), body = "", ) @@ -163,15 +184,17 @@ private object SupportersContributorsRepository { error(getString(Res.string.community_error_supporters_request_failed)) } - json.decodeFromString(response.body) - .donations + val donationsResponse = json.decodeFromString(response.body) + val supporters = donationsResponse.donations .mapNotNull { donation -> val name = donation.name?.trim().orEmpty() - val date = donation.date?.trim().orEmpty() + val date = donation.date?.trim() + ?: donation.createdAt?.trim() + ?: "" if (name.isBlank() || date.isBlank()) return@mapNotNull null SupporterDonation( - key = "${name.lowercase()}-$date", + key = donation.id?.trim()?.takeIf { it.isNotBlank() } ?: "${name.lowercase()}-$date", name = name, date = date, message = donation.message?.trim()?.takeIf { it.isNotBlank() }, @@ -182,6 +205,17 @@ private object SupportersContributorsRepository { .mapIndexed { index, donation -> donation.copy(key = "${donation.key}#$index") } + + val progress = donationsResponse.monthlyGoal + ?.progressPercent + ?.toInt() + ?.coerceIn(0, 100) + ?.let { percent -> DonationProgress(progressPercent = percent) } + + SupportersResult( + supporters = supporters, + progress = progress, + ) } private fun normalizeContributor(dto: ContributionDto): CommunityContributor? { @@ -286,11 +320,12 @@ private fun SupportersContributorsBody( supportersErrorMessage = null, ) SupportersContributorsRepository.getSupporters() - .onSuccess { supporters -> + .onSuccess { result -> uiState = uiState.copy( isSupportersLoading = false, hasLoadedSupporters = true, - supporters = supporters, + supporters = result.supporters, + donationProgress = result.progress, supportersErrorMessage = null, ) } @@ -299,6 +334,7 @@ private fun SupportersContributorsBody( isSupportersLoading = false, hasLoadedSupporters = false, supporters = emptyList(), + donationProgress = null, supportersErrorMessage = error.message ?: supportersErrorFallback, ) } @@ -307,6 +343,7 @@ private fun SupportersContributorsBody( LaunchedEffect(Unit) { loadContributors(force = false) + loadSupporters(force = false) } LaunchedEffect(uiState.selectedTab) { @@ -331,6 +368,14 @@ private fun SupportersContributorsBody( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (donationsConfigured) { + Spacer(modifier = Modifier.height(16.dp)) + DonationProgressSection( + progress = uiState.donationProgress, + isLoading = uiState.isSupportersLoading, + errorMessage = uiState.supportersErrorMessage, + ) + } Spacer(modifier = Modifier.height(16.dp)) Button( onClick = { if (donateConfigured) uriHandler.openUri(donateUrl) }, @@ -572,6 +617,61 @@ private fun SupportersCard( } } +@Composable +private fun DonationProgressSection( + progress: DonationProgress?, + isLoading: Boolean, + errorMessage: String?, +) { + val percent = progress?.progressPercent ?: 0 + val progressFraction = (percent / 100f).coerceIn(0f, 1f) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.community_donation_progress_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + ) + + if (isLoading && progress == null) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } else { + LinearProgressIndicator( + progress = { progressFraction }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } + + Text( + text = when { + errorMessage != null -> errorMessage + isLoading && progress == null -> stringResource(Res.string.community_loading_donation_progress) + percent >= 100 -> stringResource(Res.string.community_donation_progress_complete) + else -> stringResource(Res.string.community_donation_progress_remaining) + }, + style = MaterialTheme.typography.bodySmall, + color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + @Composable private fun ContributorRow( contributor: CommunityContributor, @@ -855,6 +955,8 @@ private fun contributorContributionSummary(contributor: CommunityContributor): S private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" + "whitegiso" -> "https://ko-fi.com/whitegiso" + "edoedac0" -> "https://ko-fi.com/edoedac" "crisszollo", "xrissozollo" -> "https://ko-fi.com/crisszollo" else -> null } diff --git a/mpvEx b/mpvEx new file mode 160000 index 000000000..4151a45f8 --- /dev/null +++ b/mpvEx @@ -0,0 +1 @@ +Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48