mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 08:27:54 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite
This commit is contained in:
commit
a764046e29
17 changed files with 402 additions and 84 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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's server & 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
mpvEx
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48
|
||||
Loading…
Reference in a new issue