Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite

This commit is contained in:
skoruppa 2026-06-18 14:48:06 +02:00 committed by GitHub
commit a764046e29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 402 additions and 84 deletions

View file

@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input
abstract val appVersionCode: Property<Int>
@get:Input
abstract val supabaseUrl: Property<String>
@get:Input
abstract val supabaseAnonKey: Property<String>
@get:Input
abstract val nuvioSupabaseUrl: Property<String>
@get:Input
abstract val nuvioSupabaseAnonKey: Property<String>
@get:Input
abstract val syncBackendManifestUrl: Property<String>
@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<GenerateRuntimeConfigsTask>("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<KotlinCompilationTask<*>>().configureEach {

View file

@ -355,7 +355,7 @@
<string name="settings_continue_watching_resume_prompt_title">Προτροπή συνέχισης κατά την εκκίνηση</string>
<string name="settings_continue_watching_section_card_style">ΣΤΥΛ ΚΑΡΤΑΣ</string>
<string name="settings_continue_watching_section_on_launch">ΚΑΤΑ ΤΗΝ ΕΚΚΙΝΗΣΗ</string>
<string name="settings_continue_watching_section_up_next_behavior">ΣΥΜΠΕΡΙΦΟΡΑ UP NEXT</string>
<string name="settings_continue_watching_section_up_next_behavior">ΣΥΜΠΕΡΙΦΟΡΑ ΕΠΟΜΕΝΟΥ</string>
<string name="settings_continue_watching_section_visibility">ΟΡΑΤΟΤΗΤΑ</string>
<string name="settings_continue_watching_show_description">Εμφάνιση της ενότητας Συνέχεια παρακολούθησης στην Αρχική οθόνη.</string>
<string name="settings_continue_watching_show_title">Εμφάνιση συνέχειας παρακολούθησης</string>
@ -363,8 +363,8 @@
<string name="settings_continue_watching_style_poster_description">Κάρτα αφίσας με προτεραιότητα στο artwork</string>
<string name="settings_continue_watching_style_wide">Πλατύ</string>
<string name="settings_continue_watching_style_wide_description">Οριζόντια κάρτα με πλούσιες πληροφορίες</string>
<string name="settings_continue_watching_up_next_description">Όταν είναι ενεργοποιημένο, το Up Next συνεχίζει πάντα από το πιο απομακρυσμένο παρακολουθημένο επεισόδιο. Όταν είναι απενεργοποιημένο, ακολουθεί το πιο πρόσφατα παρακολουθηθέν επεισόδιο. Χρήσιμο αν ξαναβλέπετε παλαιότερα επεισόδια.</string>
<string name="settings_continue_watching_up_next_title">Up Next από το πιο απομακρυσμένο επεισόδιο</string>
<string name="settings_continue_watching_up_next_description">Εμφάνιση επόμενου επεισοδίου με βάση το πιο προχωρημένο προβληθέν επεισόδιο. Απενεργοποιήστε το για επαναπροβολές ώστε να χρησιμοποιείται το πιο πρόσφατα προβληθέν επεισόδιο.</string>
<string name="settings_continue_watching_up_next_title">Επόμενο από το πιο προχωρημένο επεισόδιο</string>
<string name="settings_content_discovery_section_home">ΑΡΧΙΚΗ</string>
<string name="settings_content_discovery_section_sources">ΠΗΓΕΣ</string>
<string name="settings_content_discovery_addons_description">Εγκατάσταση, αφαίρεση, ανανέωση και ταξινόμηση των πηγών περιεχομένου σας.</string>

View file

@ -404,7 +404,7 @@
<string name="compose_settings_category_general">Geral</string>
<string name="compose_settings_page_account">Conta</string>
<string name="compose_settings_page_addons">Addons</string>
<string name="compose_settings_page_advanced">Avançado</string>
<string name="compose_settings_page_advanced">Avançadas</string>
<string name="compose_settings_page_appearance">Esquema</string>
<string name="compose_settings_page_content_discovery">Conteúdo e descoberta</string>
<string name="compose_settings_page_continue_watching">Continuar a ver</string>

View file

@ -614,7 +614,7 @@
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
<string name="settings_continue_watching_section_up_next_behavior">NEXT UP BEHAVIOR</string>
<string name="settings_continue_watching_section_visibility">VISIBILITY</string>
<string name="settings_continue_watching_show_description">Display the Continue Watching shelf on the Home screen.</string>
<string name="settings_continue_watching_show_title">Show Continue Watching</string>
@ -625,7 +625,7 @@
<string name="settings_continue_watching_style_wide">Wide</string>
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
<string name="settings_continue_watching_up_next_title">Next Up From Furthest Episode</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Prefer episode thumbnails when available.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Prefer Episode Thumbnails in Continue Watching</string>
<string name="settings_content_discovery_section_home">HOME</string>
@ -778,6 +778,10 @@
<string name="settings_notifications_test_title">Test notification</string>
<string name="community_section_title">Community</string>
<string name="community_section_description">See the people building and supporting Nuvio across Mobile, TV, and Web.</string>
<string name="community_donation_progress_title">This month&apos;s server &amp; maintenance</string>
<string name="community_donation_progress_complete">Covered. Additional support now goes to development.</string>
<string name="community_donation_progress_remaining">After 100%%, additional support goes to development.</string>
<string name="community_loading_donation_progress">Loading funding progress...</string>
<string name="community_supporters_not_configured">Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.</string>
<string name="community_tab_contributors">Contributors</string>
<string name="community_tab_supporters">Supporters</string>
@ -1192,7 +1196,7 @@
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
<string name="episode_mark_unwatched">Mark as unwatched</string>
<string name="episode_mark_watched">Mark as watched</string>
<string name="home_continue_watching_up_next">Up next</string>
<string name="home_continue_watching_up_next">Next Up</string>
<string name="home_continue_watching_watched">%1$s watched</string>
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm left</string>
<string name="home_continue_watching_minutes_left">%1$dm left</string>
@ -1339,8 +1343,8 @@
<string name="auth_sign_out_failed">Sign-out failed</string>
<string name="auth_sign_up_failed">Sign-up failed</string>
<string name="catalog_load_failed">Unable to load catalog items.</string>
<string name="continue_watching_up_next">Up Next</string>
<string name="continue_watching_up_next_episode">Up Next • S%1$dE%2$d</string>
<string name="continue_watching_up_next">Next Up</string>
<string name="continue_watching_up_next_episode">Next Up • S%1$dE%2$d</string>
<string name="detail_logo_content_description">%1$s logo</string>
<string name="details_comments_load_failed">Failed to load comments</string>
<string name="details_load_failed_all_addons">Could not load details from any addon.</string>

View file

@ -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,

View file

@ -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 =

View file

@ -38,16 +38,6 @@ data class SyncBackendManifest(
val activeBackend: String,
val revision: String = "",
val forceLogoutOnChange: Boolean = true,
val backends: Map<String, SyncBackendManifestBackend> = 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 =

View file

@ -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,
),
)

View file

@ -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,

View file

@ -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<CompletedSeriesCandidate>,
val deferredCandidates: List<CompletedSeriesCandidate>,
)
internal fun planHomeNextUpResolutionCandidates(
candidates: List<CompletedSeriesCandidate>,
): HomeNextUpResolutionPlan =
HomeNextUpResolutionPlan(
initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit),
deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit),
)
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,

View file

@ -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) }
}

View file

@ -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<CommunityContributor> = emptyList(),
val contributorsErrorMessage: String? = null,
val donationProgress: DonationProgress? = null,
val isSupportersLoading: Boolean = false,
val hasLoadedSupporters: Boolean = false,
val supporters: List<SupporterDonation> = 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<DonationDto> = 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<SupporterDonation>,
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<List<SupporterDonation>> = runCatching {
suspend fun getSupporters(): Result<SupportersResult> = 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<DonationsResponseDto>(response.body)
.donations
val donationsResponse = json.decodeFromString<DonationsResponseDto>(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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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) {

1
mpvEx Submodule

@ -0,0 +1 @@
Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48