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/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-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 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3138238c4..58cf290ae 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -614,7 +614,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 @@ -625,7 +625,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 @@ -778,6 +778,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 @@ -1192,7 +1196,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 @@ -1339,8 +1343,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/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index a75d21d09..68cdce7fc 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, 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/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, ), ) 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, 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/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) } } 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/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..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( @@ -33,7 +46,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 +77,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( @@ -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( @@ -87,7 +127,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "show:1:5", - subtitle = "Up Next • S1E5 • Next", + subtitle = "Next Up • S1E5 • Next", ) val result = buildHomeContinueWatchingItems( @@ -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( @@ -264,7 +343,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, ) @@ -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 } 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) } } 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 c122caccd..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 @@ -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 @@ -302,11 +314,42 @@ actual fun PlatformPlayerSurface( } // 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) { diff --git a/mpvEx b/mpvEx new file mode 160000 index 000000000..4151a45f8 --- /dev/null +++ b/mpvEx @@ -0,0 +1 @@ +Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48