From 0d09b8331918f6f3c34e978908f641f8909af121 Mon Sep 17 00:00:00 2001
From: albyalex96 <72573722+albyalex96@users.noreply.github.com>
Date: Sat, 23 May 2026 13:33:35 +0200
Subject: [PATCH 01/12] Update Italian strings for various settings and actions
---
.../composeResources/values-it/strings.xml | 229 +++++++++++++++++-
1 file changed, 228 insertions(+), 1 deletion(-)
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index 0c4c6ee80..03b8b3dbb 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -278,7 +278,7 @@
Segna come visto
Rimuovi dalla libreria
Vedi tutti
- Riproduci manualmente
+ Scegli sorgente e riproduci
Logo %1$s
Account
Elimina account
@@ -1170,4 +1170,231 @@
Persona
Regista
TMDB Discover
+ Fonti dati, ringraziamenti e licenze della piattaforma
+ Salvataggio…
+ Convalida
+ Disattivato
+ Aggiungi lista Trakt
+ Modifica lista Trakt
+ Liste Trakt
+ Lista Trakt
+ Cerca titolo, URL Trakt o ID lista
+ Usa l'URL di una lista Trakt pubblica, un ID lista numerico o cerca per nome.
+ Visioni del weekend, Vincitori di premi
+ Risultati della ricerca
+ Liste di tendenza
+ Liste popolari
+ Ordine
+ Crescente
+ Decrescente
+ Ordine della lista
+ Aggiunti di recente
+ Titolo
+ Data di rilascio
+ Durata
+ Popolari
+ Percentuale
+ Voti
+ Più votati
+ Paese di visione
+ Codice paese ISO 3166-1 in cui il titolo è disponibile. Esempio: US, IT.
+ Paesi rapidi
+ ID fornitori di visione
+ Usa gli ID dei fornitori di visione di TMDB. Separa con le virgole per la condizione AND, o con le barre verticali (pipe) per la condizione OR.
+ 8|337|350
+ Fornitori rapidi
+ Netflix
+ Prime Video
+ Disney+
+ Apple TV+
+ Hulu
+ Servizi connessi
+ Licenze e attribuzione
+ Nessuna impostazione trovata.
+ Cerca impostazioni...
+ RISULTATI
+ LICENZA APP
+ DATI E SERVIZI
+ LICENZA RIPRODUZIONE
+ Nuvio Mobile
+ Il codice sorgente e i termini di licenza sono disponibili nel repository del progetto.
+ Rilasciato sotto licenza GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ Nuvio utilizza l'API di TMDB per i metadati di film e serie TV, locandine, trailer, cast, dettagli di produzione, collezioni e consigli. Questo prodotto utilizza l'API di TMDB ma non è approvato o certificato da TMDB.
+ Dataset non commerciali di IMDb
+ Nuvio utilizza i dataset non commerciali di IMDb, incluso title.ratings.tsv.gz, per le valutazioni e il conteggio dei voti di IMDb. Informazioni per gentile concessione di IMDb (https://www.imdb.com). Utilizzato con il permesso. I dati di IMDb sono per uso personale e non commerciale secondo i termini di IMDb.
+ Trakt
+ Nuvio si connette a Trakt per l'autenticazione dell'account, la cronologia di visione, la sincronizzazione dei progressi, i dati della libreria, le valutazioni, le liste e i commenti. Nuvio non è affiliato né approvato da Trakt.
+ Premiumize
+ Nuvio si connette a Premiumize per l'autenticazione dell'account, l'accesso alla libreria cloud, i controlli della cache e le funzioni di riproduzione in cloud. Nuvio non è affiliato né approvato da Premiumize.
+ TorBox
+ Nuvio si connette a TorBox per l'autenticazione dell'account, l'accesso alla libreria cloud, i controlli della cache e le funzioni di riproduzione in cloud. Nuvio non è affiliato né approvato da TorBox.
+ MDBList
+ Nuvio utilizza MDBList per valutazioni e dati di fornitori di punteggi esterni. Nuvio non è affiliato né approvato da MDBList.
+ IntroDB
+ Nuvio utilizza l'API di IntroDB per i timestamp di intro, riassunti, titoli di coda e anteprime forniti dalla community e utilizzati dai controlli di salto. Nuvio non è affiliato né approvato da IntroDB.
+ MPVKit
+ Utilizzato per la riproduzione sulle build iOS.
+ Il solo sorgente di MPVKit è rilasciato sotto licenza LGPL v3.0. Anche i pacchetti di MPVKit, incluse le librerie libmpv e FFmpeg, sono rilasciati sotto licenza LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Utilizzato per la riproduzione sulle build Android.
+ Rilasciato sotto la licenza Apache License, Versione 2.0.
+ Liquid Glass
+ Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Il cambio rapido del profilo dalla barra dei pannelli non è disponibile quando questa opzione è attiva.
+ Nascondi contenuti non rilasciati
+ Nascondi i film e le serie TV che non sono ancora stati rilasciati.
+ Nascondi sottolineatura catalogo
+ Rimuove la linea di evidenziazione sotto i titoli dei cataloghi e delle collezioni in tutta l'app.
+ Nascondi valore
+ Mostra valore
+ Sfoca le miniature del prossimo episodio in Inizia a guardare per evitare spoiler.
+ Sfoca non visti in Inizia a guardare
+ Includi i prossimi episodi in Inizia a guardare prima che vadano in onda.
+ Mostra prossimi episodi non trasmessi
+ ORDINE DI ORDINAMENTO
+ Ordine di ordinamento
+ Predefinito
+ Ordina tutti gli elementi in base ai più recenti
+ Stile streaming
+ Prima gli elementi rilasciati, i prossimi in arrivo alla fine
+ Prediligi le miniature dell'episodio quando disponibili.
+ Prediligi miniature episodio in Inizia a guardare
+ Connetti gli account per i link e l'accesso alla libreria
+ Servizi connessi
+ Queste integrazioni sono sperimentali e potrebbero essere mantenute, modificate o rimosse in seguito.
+ Libreria cloud
+ Sfoglia e riproduci i file già presenti nei tuoi account connessi.
+ Risolvi link riproducibili
+ Richiedi un link riproducibile a un servizio connesso quando un risultato lo richiede. Questo potrebbe aggiungere l'elemento a tale servizio.
+ Risolvi con
+ Scegli quale account connesso gestisce i link riproducibili.
+ Connetti prima un account.
+ Account
+ Connetti il tuo account %1$s.
+ Collega il tuo account %1$s nel browser.
+ Chiave API %1$s
+ Inserisci la tua chiave API %1$s.
+ Inserisci la chiave API %1$s
+ Non impostato
+ Connesso
+ Connetti %1$s
+ Disconnetti %1$s
+ Disconnetti
+ %1$s è connesso su questo dispositivo.
+ Avvio dell'accesso sicuro...
+ Apri il link e inserisci questo codice per approvare Nuvio.
+ Codice copiato.
+ Apri link
+ In attesa di approvazione...
+ Impossibile avviare l'accesso.
+ Questo metodo di accesso non è configurato in questa build.
+ Questo codice è scaduto. Riprova.
+ Preparazione dei link
+ Prepara i link
+ Risolvi i link riproducibili prima dell'inizio della riproduzione.
+ Link da preparare
+ Usa un numero inferiore quando possibile. I servizi connessi potrebbero limitare la frequenza dei link che possono essere risolti in un determinato periodo di tempo. L'apertura di un film o di un episodio può essere conteggiata ai fini di tali limiti anche se non premi Guarda, poiché i link vengono preparati in anticipo.
+ 1 link
+ %1$d link
+ Formattazione
+ Modello del nome
+ Controlla come appaiono i nomi dei risultati.
+ Modello della descrizione
+ Controlla i metadati mostrati sotto ogni risultato.
+ Ripristina formattazione
+ Ripristina la formattazione predefinita dei risultati.
+ Chiave API convalidata.
+ Impossibile convalidare questa chiave API.
+ Sfoca episodi non visti
+ Sfoca le miniature degli episodi finché non vengono visti per evitare spoiler.
+ Abilita invio intro
+ Mostra un pulsante per inviare i timestamp di intro/sigle finali al database della community.
+ Chiave API IntroDB
+ Inserisci la tua chiave API IntroDB per inviare i timestamp. Richiesta per l'invio.
+ Lettore esterno
+ App lettore esterno
+ Apri la nuova riproduzione con l'app video predefinita di Android o con il selettore di sistema.
+ Apri la nuova riproduzione con il lettore installato selezionato.
+ Nessun lettore esterno supportato installato
+ Riusa gruppo binge-watching
+ Ricorda e riutilizza l'ultimo gruppo di binge-watching tra le sessioni (Inizia a guardare, Dettagli, ecc.).
+ Sorgente libreria
+ Scegli quale libreria utilizzare per salvare e visualizzare la tua collezione
+ Sorgente libreria
+ Scegli dove salvare e gestire gli elementi della tua libreria
+ Trakt
+ Libreria Nuvio
+ Libreria Trakt selezionata
+ Libreria Nuvio selezionata
+ Progressi di visione
+ Scegli quale sorgente di progresso gestisce il riprendi visione e inizia a guardare
+ Progressi di visione
+ Scegli se il riprendi visione e inizia a guardare devono utilizzare Trakt o Nuvio Sync, mentre lo scrobbling di Trakt rimane attivo.
+ Trakt
+ Nuvio Sync
+ Sorgente dei progressi di visione impostata su Trakt
+ Sorgente dei progressi di visione impostata su Nuvio Sync
+ Finestra Inizia a guardare
+ Cronologia Trakt considerata per inizia a guardare
+ Finestra Inizia a guardare
+ Scegli quanta attività di Trakt deve apparire in inizia a guardare.
+ Tutta la cronologia
+ %1$d giorni
+ Segna le stagioni precedenti come viste
+ Inserisci un URL immagine http:// o https:// valido.
+ URL dell'avatar personalizzato selezionato.
+ URL avatar personalizzato
+ Incolla il link di un'immagine, oppure lascia vuoto per utilizzare il catalogo di avatar integrato.
+ https://esempio.com/avatar.png
+ Apri nel lettore esterno
+ Apri nel lettore interno
+ Questo tipo di flusso non è supportato
+ Connetti un account nelle Impostazioni.
+ Non presente nella cache di Torbox.
+ Questo link è scaduto. Aggiornamento dei risultati in corso.
+ Impossibile aprire questo link.
+ Impossibile aprire il lettore esterno
+ Scegli prima un lettore esterno nelle impostazioni
+ Nessun lettore esterno disponibile
+ Rimuovere %1$s da %2$s?
+ Alla sorgente %1$d nella cartella '%2$s' manca l'ID della lista Trakt.
+ Cloud
+ Salvati
+ Connetti account
+ Connetti un account nelle impostazioni dei Servizi connessi per sfogliare i file riproducibili dalla tua libreria cloud.
+ Nessun account cloud connesso
+ Apri Servizi connessi
+ Attiva la Libreria cloud nelle impostazioni dei Servizi connessi per sfogliare i file dagli account connessi.
+ La libreria cloud è disattivata
+ Nessun file cloud riproducibile corrisponde ai filtri correnti.
+ Ancora niente qui
+ Scegli un file da riprodurre
+ Impossibile caricare la libreria cloud di %1$s
+ Questo elemento non espone un file video riproducibile.
+ Nessun file riproducibile
+ Nessun file riproducibile
+ La libreria cloud è disattivata.
+ Impossibile riprodurre questo file cloud.
+ Riproduci file
+ Il servizio cloud non è connesso.
+ %1$s non è connesso.
+ %1$d file riproducibili
+ Tutti
+ Aggiorna libreria cloud
+ Seleziona fornitore
+ Seleziona tipo
+ Pronto per la riproduzione
+ Tutti
+ Torrent
+ Usenet
+ Web
+ File
+ Alcol/Droghe
+ Paura
+ Nudità
+ Volgarità
+ Lieve
+ Moderata
+ Grave
+ Violenza
From 43e938f1c0d5d6efafc44216e0a3bef43d6c40c6 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 25 May 2026 13:49:42 +0530
Subject: [PATCH 02/12] Align Trakt sync with TV app
---
.../app/features/details/MetaDetailsScreen.kt | 5 +
.../com/nuvio/app/features/home/HomeScreen.kt | 13 +-
.../nuvio/app/features/player/PlayerScreen.kt | 40 +-
.../features/trakt/TraktProgressRepository.kt | 657 +++++++++++++++++-
.../features/trakt/TraktScrobbleRepository.kt | 112 +--
.../watching/sync/TraktWatchedSyncAdapter.kt | 152 +++-
.../watchprogress/WatchProgressRepository.kt | 33 +
.../nuvio/app/features/home/HomeScreenTest.kt | 6 +
8 files changed, 904 insertions(+), 114 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index 797a168b2..5eb7aec43 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
@@ -344,6 +344,11 @@ fun MetaDetailsScreen(
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
+ LaunchedEffect(meta.id, meta.type, watchProgressUiState.hasLoadedRemoteProgress) {
+ if (meta.type.lowercase() in setOf("series", "show", "tv", "tvshow")) {
+ WatchProgressRepository.refreshEpisodeProgress(meta.id)
+ }
+ }
LaunchedEffect(
meta.id,
meta.type,
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 67986c8ac..e7d59bf65 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
@@ -48,7 +48,6 @@ import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
-import com.nuvio.app.features.watchprogress.ContinueWatchingLimit
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
@@ -234,7 +233,7 @@ fun HomeScreen(
}
val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
- effectiveWatchProgressEntries.continueWatchingEntries()
+ effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
}
LaunchedEffect(visibleContinueWatchingEntries) {
@@ -476,7 +475,7 @@ fun HomeScreen(
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
- val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT)
+ val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit)
val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs }
if (candidatesToResolve.isEmpty()) {
nextUpItemsBySeries = cachedResolvedNextUpItems
@@ -534,9 +533,6 @@ fun HomeScreen(
).toSet()
}
- if (cachedResolvedNextUpItems.size + freshResults.size >= ContinueWatchingLimit) {
- break
- }
}
val results = cachedResolvedNextUpItems + freshResults
@@ -780,10 +776,11 @@ fun HomeScreen(
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
+internal const val HomeContinueWatchingMaxRecentProgressItems = 300
+internal const val HomeNextUpInitialResolutionLimit = 32
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
-private const val NEXT_UP_INITIAL_RESOLUTION_LIMIT = ContinueWatchingLimit * 2
-private const val NEXT_UP_RESOLUTION_CONCURRENCY = 8
+private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4
private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY
internal fun filterEntriesForTraktContinueWatchingWindow(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index b87bfbf67..4cc99511f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -66,6 +66,7 @@ import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.tmdb.TmdbService
+import com.nuvio.app.features.trakt.TraktScrobbleItem
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.WatchProgressClock
@@ -75,6 +76,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.isIos
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -92,6 +94,7 @@ private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
+private const val PlayerSeekProgressSyncDebounceMs = 700L
/** Hard ceiling for next-episode stream search to prevent hanging forever. */
private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
private val PlayerSliderOverlayGap = 12.dp
@@ -246,6 +249,7 @@ fun PlayerScreen(
var lockedOverlayVisible by remember { mutableStateOf(false) }
var gestureMessageJob by remember { mutableStateOf(null) }
var accumulatedSeekResetJob by remember { mutableStateOf(null) }
+ var seekProgressSyncJob by remember { mutableStateOf(null) }
var accumulatedSeekState by remember { mutableStateOf(null) }
var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) }
var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf(null) }
@@ -270,6 +274,12 @@ fun PlayerScreen(
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
+ var currentTraktScrobbleItem by remember(
+ activeSourceUrl,
+ activeVideoId,
+ activeSeasonNumber,
+ activeEpisodeNumber,
+ ) { mutableStateOf(null) }
val backdropArtwork = background ?: poster
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
@@ -419,6 +429,7 @@ fun PlayerScreen(
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
+ currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
item = item,
progressPercent = currentPlaybackProgressPercent(),
@@ -431,13 +442,15 @@ fun PlayerScreen(
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
- scope.launch {
- val item = currentTraktScrobbleItem() ?: return@launch
+ val itemSnapshot = currentTraktScrobbleItem
+ scope.launch(NonCancellable) {
+ val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
item = item,
progressPercent = percent,
)
}
+ currentTraktScrobbleItem = null
hasRequestedScrobbleStartForCurrentItem = false
}
@@ -481,6 +494,25 @@ fun PlayerScreen(
)
}
+ fun scheduleProgressSyncAfterSeek() {
+ seekProgressSyncJob?.cancel()
+ seekProgressSyncJob = scope.launch {
+ delay(PlayerSeekProgressSyncDebounceMs)
+ WatchProgressRepository.upsertPlaybackProgress(
+ session = playbackSession,
+ snapshot = playbackSnapshot,
+ )
+
+ val progressPercent = currentPlaybackProgressPercent()
+ if (progressPercent >= 1f && progressPercent < 80f) {
+ emitTraktScrobbleStop(progressPercent)
+ if (playbackSnapshot.isPlaying) {
+ emitTraktScrobbleStart()
+ }
+ }
+ }
+ }
+
val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) {
{
flushWatchProgress()
@@ -728,6 +760,7 @@ fun PlayerScreen(
fun seekBy(offsetMs: Long) {
playerController?.seekBy(offsetMs)
+ scheduleProgressSyncAfterSeek()
controlsVisible = true
when {
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
@@ -760,6 +793,7 @@ fun PlayerScreen(
}
}
playerController?.seekTo(targetPositionMs)
+ scheduleProgressSyncAfterSeek()
showSeekFeedback(direction, nextState.amountMs)
accumulatedSeekResetJob?.cancel()
@@ -1997,6 +2031,7 @@ fun PlayerScreen(
isScrubbingTimeline = false
scrubbingPositionMs = null
playerController?.seekTo(positionMs)
+ scheduleProgressSyncAfterSeek()
},
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
@@ -2063,6 +2098,7 @@ fun PlayerScreen(
onSkip = {
val interval = activeSkipInterval ?: return@SkipIntroButton
playerController?.seekTo((interval.endTime * 1000).toLong())
+ scheduleProgressSyncAfterSeek()
skipIntervalDismissed = true
},
onDismiss = { skipIntervalDismissed = true },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
index 7aa83580e..5afd6d1a6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
@@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.details.MetaDetailsRepository
+import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
@@ -19,6 +20,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -38,10 +40,19 @@ import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv"
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
-private const val HISTORY_LIMIT = 250
+private const val HISTORY_PAGE_LIMIT = 1000
+private const val HISTORY_MAX_PAGES = 5
+private const val HISTORY_MAX_PAGES_ALL = 20
+private const val MAX_RECENT_EPISODE_HISTORY_ENTRIES = 300
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5
-private const val METADATA_HYDRATION_LIMIT = 30
+private const val METADATA_HYDRATION_LIMIT = 110
+private const val REFRESH_BASE_INTERVAL_MS = 60L * 1000L
+private const val REFRESH_MAX_INTERVAL_MS = 15L * 60L * 1000L
+private const val EPISODE_PROGRESS_CACHE_TTL_MS = 30L * 60L * 1000L
+private const val EPISODE_PROGRESS_FETCH_THROTTLE_MS = 60L * 1000L
+private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
+private const val AMBIGUOUS_ID_MARKER = "__ambiguous__"
data class TraktProgressUiState(
val entries: List = emptyList(),
@@ -64,6 +75,44 @@ object TraktProgressRepository {
private var refreshRequestId: Long = 0L
private val refreshJobMutex = Mutex()
private var inFlightRefresh: Deferred? = null
+ private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
+ private var consecutiveRefreshFailures = 0
+ private var lastKnownMoviesWatchedAt: String? = null
+ private var lastKnownEpisodeActivityFingerprint: String? = null
+ private var lastKnownActivityFingerprint: String? = null
+ private val episodeProgressMutex = Mutex()
+ private val episodeProgressFetchedAtMsByContentId = mutableMapOf()
+ private val episodeProgressLastAttemptAtMsByContentId = mutableMapOf()
+ private val inFlightEpisodeProgressContentIds = mutableSetOf()
+ private var watchedShowEpisodesById: Map>> = emptyMap()
+ private var showIdToTraktPathId: Map = emptyMap()
+ private var showIdSiblingsMap: Map> = emptyMap()
+
+ init {
+ scope.launch {
+ while (true) {
+ delay(refreshIntervalMs)
+ TraktAuthRepository.ensureLoaded()
+ TraktSettingsRepository.ensureLoaded()
+ if (!shouldUseTraktProgress(
+ isAuthenticated = TraktAuthRepository.isAuthenticated.value,
+ source = TraktSettingsRepository.uiState.value.watchProgressSource,
+ )
+ ) {
+ updateRefreshBackoff(success = true)
+ continue
+ }
+
+ val success = runCatching {
+ refreshIfActivityChanged()
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ log.w { "Periodic Trakt activity refresh failed: ${error.message}" }
+ }.isSuccess
+ updateRefreshBackoff(success = success)
+ }
+ }
+ }
fun ensureLoaded() {
if (hasLoaded) return
@@ -79,6 +128,7 @@ object TraktProgressRepository {
parsed.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
parsed.tmdb?.let { add("tmdb:$it") }
parsed.trakt?.let { add("trakt:$it") }
+ showIdSiblingsMap[contentId]?.forEach { add(it) }
}
return keys.any { ids.contains(it) }
}
@@ -87,6 +137,8 @@ object TraktProgressRepository {
invalidateInFlightRefreshes()
hasLoaded = false
hiddenProgressShowIds.value = emptySet()
+ resetActivitySnapshot()
+ resetShowProgressCaches()
_uiState.value = TraktProgressUiState()
ensureLoaded()
}
@@ -95,6 +147,8 @@ object TraktProgressRepository {
invalidateInFlightRefreshes()
hasLoaded = false
hiddenProgressShowIds.value = emptySet()
+ resetActivitySnapshot()
+ resetShowProgressCaches()
_uiState.value = TraktProgressUiState()
}
@@ -123,6 +177,126 @@ object TraktProgressRepository {
}
}
+ suspend fun refreshEpisodeProgress(
+ contentId: String,
+ forceRefresh: Boolean = false,
+ ) {
+ ensureLoaded()
+ val normalizedContentId = contentId.trim()
+ if (normalizedContentId.isBlank()) return
+ val headers = TraktAuthRepository.authorizedHeaders() ?: return
+ val cacheKey = canonicalLookupKey(normalizedContentId)
+ val now = TraktPlatformClock.nowEpochMs()
+
+ var shouldFetch = forceRefresh
+ episodeProgressMutex.withLock {
+ val lastFetchedAt = episodeProgressFetchedAtMsByContentId[cacheKey] ?: 0L
+ val isFresh = lastFetchedAt > 0L && now - lastFetchedAt <= EPISODE_PROGRESS_CACHE_TTL_MS
+ if (!forceRefresh && isFresh) return
+
+ val lastAttemptAt = episodeProgressLastAttemptAtMsByContentId[cacheKey] ?: 0L
+ if (!forceRefresh && now - lastAttemptAt < EPISODE_PROGRESS_FETCH_THROTTLE_MS) return
+
+ if (!inFlightEpisodeProgressContentIds.add(cacheKey)) return
+ episodeProgressLastAttemptAtMsByContentId[cacheKey] = now
+ shouldFetch = true
+ }
+
+ if (!shouldFetch) return
+
+ try {
+ val entries = fetchEpisodeProgressEntries(headers = headers, contentId = normalizedContentId)
+ val existingEntries = _uiState.value.entries
+ val merged = mergeNewestByVideoId(existingEntries + entries)
+ _uiState.value = _uiState.value.copy(entries = merged.sortedByDescending { it.lastUpdatedEpochMs })
+ episodeProgressMutex.withLock {
+ episodeProgressFetchedAtMsByContentId[cacheKey] = TraktPlatformClock.nowEpochMs()
+ }
+ if (entries.isNotEmpty()) {
+ launchHydration(requestId = refreshRequestId, entries = entries)
+ }
+ } finally {
+ episodeProgressMutex.withLock {
+ inFlightEpisodeProgressContentIds.remove(cacheKey)
+ }
+ }
+ }
+
+ private suspend fun refreshIfActivityChanged() {
+ val headers = TraktAuthRepository.authorizedHeaders() ?: return
+ if (hasActivityChanged(headers) || !_uiState.value.hasLoadedRemoteProgress) {
+ refreshNow()
+ }
+ }
+
+ private suspend fun hasActivityChanged(headers: Map): Boolean {
+ val activities = runCatching {
+ json.decodeFromString(
+ httpGetTextWithHeaders(
+ url = "$BASE_URL/sync/last_activities",
+ headers = headers,
+ ),
+ )
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ }.getOrNull() ?: return !_uiState.value.hasLoadedRemoteProgress
+
+ val moviesWatchedAt = activities.movies?.watchedAt
+ if (moviesWatchedAt != lastKnownMoviesWatchedAt) {
+ lastKnownMoviesWatchedAt = moviesWatchedAt
+ }
+
+ val episodeFingerprint = listOfNotNull(
+ activities.episodes?.pausedAt,
+ activities.episodes?.watchedAt,
+ ).joinToString("|")
+ if (episodeFingerprint != lastKnownEpisodeActivityFingerprint) {
+ lastKnownEpisodeActivityFingerprint = episodeFingerprint
+ episodeProgressMutex.withLock {
+ episodeProgressFetchedAtMsByContentId.clear()
+ }
+ }
+
+ val fingerprint = listOfNotNull(
+ activities.movies?.pausedAt,
+ activities.movies?.watchedAt,
+ activities.episodes?.pausedAt,
+ activities.episodes?.watchedAt,
+ ).joinToString("|")
+ val changed = fingerprint != lastKnownActivityFingerprint
+ lastKnownActivityFingerprint = fingerprint
+ return changed
+ }
+
+ private fun updateRefreshBackoff(success: Boolean) {
+ if (success) {
+ consecutiveRefreshFailures = 0
+ refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
+ return
+ }
+
+ consecutiveRefreshFailures += 1
+ refreshIntervalMs = (REFRESH_BASE_INTERVAL_MS shl (consecutiveRefreshFailures - 1))
+ .coerceAtMost(REFRESH_MAX_INTERVAL_MS)
+ }
+
+ private fun resetActivitySnapshot() {
+ lastKnownMoviesWatchedAt = null
+ lastKnownEpisodeActivityFingerprint = null
+ lastKnownActivityFingerprint = null
+ consecutiveRefreshFailures = 0
+ refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
+ }
+
+ private fun resetShowProgressCaches() {
+ watchedShowEpisodesById = emptyMap()
+ showIdToTraktPathId = emptyMap()
+ showIdSiblingsMap = emptyMap()
+ episodeProgressFetchedAtMsByContentId.clear()
+ episodeProgressLastAttemptAtMsByContentId.clear()
+ inFlightEpisodeProgressContentIds.clear()
+ }
+
private suspend fun refreshNowInternal() {
ensureLoaded()
val requestId = nextRefreshRequestId()
@@ -233,6 +407,13 @@ object TraktProgressRepository {
if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) {
current[normalizedEntry.videoId] = normalizedEntry
}
+ if (normalizedEntry.isCompleted && normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) {
+ optimisticallyAddWatchedEpisode(
+ contentId = normalizedEntry.parentMetaId,
+ season = normalizedEntry.seasonNumber,
+ episode = normalizedEntry.episodeNumber,
+ )
+ }
_uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs })
}
@@ -260,6 +441,13 @@ object TraktProgressRepository {
true
}
}
+ if (seasonNumber != null && episodeNumber != null) {
+ optimisticallyRemoveWatchedEpisode(
+ contentId = normalizedContentId,
+ season = seasonNumber,
+ episode = episodeNumber,
+ )
+ }
_uiState.value = _uiState.value.copy(entries = filtered)
}
@@ -407,37 +595,77 @@ object TraktProgressRepository {
}
private suspend fun fetchHistoryEntries(headers: Map): List = withContext(Dispatchers.Default) {
- val payloads = coroutineScope {
- val historyPayload = async {
- httpGetTextWithHeaders(
- url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT",
- headers = headers,
- )
- }
- val movieHistoryPayload = async {
- httpGetTextWithHeaders(
- url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT",
- headers = headers,
- )
- }
-
- awaitAll(historyPayload, movieHistoryPayload)
+ val (episodeHistory, movieHistory) = coroutineScope {
+ val episodeHistory = async { fetchRecentEpisodeHistoryEntries(headers) }
+ val movieHistory = async { fetchRecentMovieHistoryEntries(headers) }
+ episodeHistory.await() to movieHistory.await()
}
- val historyPayload = payloads[0]
- val movieHistoryPayload = payloads[1]
- val episodeHistory = json.decodeFromString>(historyPayload)
- val movieHistory = json.decodeFromString>(movieHistoryPayload)
+ mergeNewestByVideoId(episodeHistory + movieHistory)
+ }
- val completedEpisodes = episodeHistory
- .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) }
- .distinctBy { entry -> entry.videoId }
- val completedMovies = movieHistory
+ private suspend fun fetchRecentEpisodeHistoryEntries(
+ headers: Map,
+ ): List {
+ val cutoffMs = recentWatchCutoffMs()
+ val maxPages = if (isAllHistoryWindow()) HISTORY_MAX_PAGES_ALL else HISTORY_MAX_PAGES
+ val resultsByShow = linkedMapOf()
+ var fallbackIndex = 0
+ var page = 1
+
+ while (page <= maxPages && resultsByShow.size < MAX_RECENT_EPISODE_HISTORY_ENTRIES) {
+ val url = buildString {
+ append("$BASE_URL/sync/history/episodes?page=$page&limit=$HISTORY_PAGE_LIMIT")
+ cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) }
+ }
+ val response = httpRequestRaw(
+ method = "GET",
+ url = url,
+ headers = headers,
+ body = "",
+ )
+ if (response.status !in 200..299) break
+
+ val items = runCatching {
+ json.decodeFromString>(response.body)
+ }.getOrDefault(emptyList())
+ if (items.isEmpty()) break
+
+ var shouldStop = false
+ for (item in items) {
+ val entry = mapHistoryEpisode(item = item, fallbackIndex = fallbackIndex++) ?: continue
+ if (cutoffMs != null && entry.lastUpdatedEpochMs < cutoffMs) {
+ shouldStop = true
+ continue
+ }
+ resultsByShow.putIfAbsent(entry.parentMetaId, entry)
+ if (resultsByShow.size >= MAX_RECENT_EPISODE_HISTORY_ENTRIES) {
+ shouldStop = true
+ break
+ }
+ }
+
+ val pageCount = response.headerInt("x-pagination-page-count")
+ if (items.size < HISTORY_PAGE_LIMIT || shouldStop || (pageCount != null && page >= pageCount)) break
+ page += 1
+ }
+
+ return resultsByShow.values.toList()
+ }
+
+ private suspend fun fetchRecentMovieHistoryEntries(
+ headers: Map,
+ ): List {
+ val cutoffMs = recentWatchCutoffMs()
+ val url = buildString {
+ append("$BASE_URL/sync/history/movies?limit=$HISTORY_PAGE_LIMIT")
+ cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) }
+ }
+ val payload = httpGetTextWithHeaders(url = url, headers = headers)
+ return json.decodeFromString>(payload)
.mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) }
+ .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs }
.distinctBy { entry -> entry.videoId }
-
- val merged = mergeNewestByVideoId(completedEpisodes + completedMovies)
- merged
}
private suspend fun fetchWatchedShowSeedEntries(
@@ -450,17 +678,287 @@ object TraktProgressRepository {
headers = headers,
)
val watchedShows = json.decodeFromString>(payload)
- val mapped = watchedShows
- .mapNotNull { item ->
+ updateWatchedShowCaches(watchedShows)
+ val mapped = fixAmbiguousWatchedShowSeeds(
+ watchedShows.mapNotNull { item ->
mapWatchedShowSeed(
item = item,
useFurthestEpisode = useFurthestEpisode,
)
- }
+ },
+ )
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
mapped
}
+ private fun updateWatchedShowCaches(items: List) {
+ val siblingsMap = mutableMapOf>()
+
+ items.forEach { item ->
+ val keys = watchedShowLookupKeys(item.show?.ids)
+ if (keys.size <= 1) return@forEach
+ for (key in keys) {
+ val existing = siblingsMap[key]
+ if (existing != null) {
+ existing.clear()
+ existing.add(AMBIGUOUS_ID_MARKER)
+ } else {
+ siblingsMap[key] = (keys - key).toMutableSet()
+ }
+ }
+ }
+
+ val ambiguousIds = siblingsMap.entries
+ .filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings }
+ .mapTo(mutableSetOf()) { (key, _) -> key }
+
+ val episodesByKey = mutableMapOf>>()
+ val pathIdsByKey = mutableMapOf()
+
+ items.forEach { item ->
+ val ids = item.show?.ids ?: return@forEach
+ val keys = watchedShowLookupKeys(ids).filter { it !in ambiguousIds }
+ if (keys.isEmpty()) return@forEach
+
+ val traktPathId = ids.slug?.takeIf { it.isNotBlank() } ?: ids.trakt?.toString()
+ if (traktPathId != null) {
+ keys.forEach { key -> pathIdsByKey[key] = traktPathId }
+ }
+
+ val episodes = mutableSetOf>()
+ item.seasons.orEmpty()
+ .filter { (it.number ?: 0) > 0 }
+ .forEach { season ->
+ val seasonNumber = season.number ?: return@forEach
+ season.episodes.orEmpty()
+ .filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
+ .forEach { episode ->
+ val episodeNumber = episode.number ?: return@forEach
+ episodes.add(seasonNumber to episodeNumber)
+ }
+ }
+
+ if (episodes.isNotEmpty()) {
+ keys.forEach { key ->
+ episodesByKey.getOrPut(key) { mutableSetOf() }.addAll(episodes)
+ }
+ }
+ }
+
+ watchedShowEpisodesById = episodesByKey.mapValues { (_, episodes) -> episodes.toSet() }
+ showIdToTraktPathId = pathIdsByKey
+ showIdSiblingsMap = siblingsMap.mapValues { (_, siblings) -> siblings.toSet() }
+ }
+
+ private fun fixAmbiguousWatchedShowSeeds(
+ seeds: List,
+ ): List {
+ val ambiguousIds = showIdSiblingsMap.entries
+ .filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings }
+ .mapTo(mutableSetOf()) { (key, _) -> key }
+ if (ambiguousIds.isEmpty()) return seeds
+
+ return seeds.map { seed ->
+ if (!seed.parentMetaId.startsWith("tt") || seed.parentMetaId !in ambiguousIds) {
+ seed
+ } else {
+ val tmdbSibling = showIdSiblingsMap[seed.parentMetaId]
+ ?.firstOrNull { it.startsWith("tmdb:") }
+ if (tmdbSibling == null) {
+ seed
+ } else {
+ val remappedVideoId = if (seed.seasonNumber != null && seed.episodeNumber != null) {
+ buildPlaybackVideoId(
+ parentMetaId = tmdbSibling,
+ seasonNumber = seed.seasonNumber,
+ episodeNumber = seed.episodeNumber,
+ fallbackVideoId = null,
+ )
+ } else {
+ tmdbSibling
+ }
+ seed.copy(parentMetaId = tmdbSibling, videoId = remappedVideoId)
+ }
+ }
+ }
+ }
+
+ private suspend fun fetchEpisodeProgressEntries(
+ headers: Map,
+ contentId: String,
+ ): List = withContext(Dispatchers.Default) {
+ val pathId = resolveToTraktAcceptedId(headers = headers, contentId = contentId)
+ val response = httpRequestRaw(
+ method = "GET",
+ url = "$BASE_URL/shows/$pathId/progress/watched?hidden=false&specials=false&count_specials=false",
+ headers = headers,
+ body = "",
+ )
+ if (response.status !in 200..299) return@withContext emptyList()
+
+ val progress = runCatching {
+ json.decodeFromString(response.body)
+ }.getOrNull() ?: return@withContext emptyList()
+
+ val completed = mutableListOf()
+ progress.seasons.orEmpty()
+ .filter { season -> (season.number ?: 0) > 0 }
+ .forEach { season ->
+ val seasonNumber = season.number ?: return@forEach
+ season.episodes.orEmpty()
+ .filter { episode -> episode.completed == true && (episode.number ?: 0) > 0 }
+ .forEach { episode ->
+ val episodeNumber = episode.number ?: return@forEach
+ completed += mapShowProgressEpisode(
+ contentId = contentId,
+ season = seasonNumber,
+ episode = episodeNumber,
+ lastWatchedAt = episode.lastWatchedAt,
+ )
+ }
+ }
+
+ val inProgress = fetchPlaybackEntries(headers)
+ .filter { entry ->
+ entry.parentMetaId == contentId &&
+ entry.seasonNumber != null &&
+ entry.episodeNumber != null
+ }
+
+ mergeNewestByVideoId(completed + inProgress)
+ }
+
+ private suspend fun resolveToTraktAcceptedId(
+ headers: Map,
+ contentId: String,
+ ): String {
+ val parsed = parseTraktContentIds(contentId)
+ parsed.imdb?.takeIf { it.isNotBlank() }?.let { return it }
+ parsed.trakt?.let { return it.toString() }
+
+ val tmdb = parsed.tmdb
+ if (tmdb != null) {
+ showIdToTraktPathId["tmdb:$tmdb"]?.let { return it }
+ runCatching {
+ TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "series")
+ ?: TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "movie")
+ }.getOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
+
+ val response = runCatching {
+ httpRequestRaw(
+ method = "GET",
+ url = "$BASE_URL/search/tmdb/$tmdb?type=show",
+ headers = headers,
+ body = "",
+ )
+ }.getOrNull()
+ if (response != null && response.status in 200..299) {
+ val result = runCatching {
+ json.decodeFromString>(response.body)
+ }.getOrDefault(emptyList()).firstOrNull()
+ result?.show?.ids?.let { ids ->
+ ids.imdb?.takeIf { it.isNotBlank() }?.let { return it }
+ ids.slug?.takeIf { it.isNotBlank() }?.let { return it }
+ ids.trakt?.let { return it.toString() }
+ }
+ }
+ return "tmdb:$tmdb"
+ }
+
+ return contentId
+ }
+
+ private suspend fun mapShowProgressEpisode(
+ contentId: String,
+ season: Int,
+ episode: Int,
+ lastWatchedAt: String?,
+ ): WatchProgressEntry {
+ val resolvedEpisode = resolveAddonEpisodeProgress(
+ contentId = contentId,
+ season = season,
+ episode = episode,
+ episodeTitle = null,
+ )
+ val resolvedSeason = resolvedEpisode?.season ?: season
+ val resolvedNumber = resolvedEpisode?.episode ?: episode
+
+ return WatchProgressEntry(
+ contentType = "series",
+ parentMetaId = contentId,
+ parentMetaType = "series",
+ videoId = buildPlaybackVideoId(
+ parentMetaId = contentId,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
+ fallbackVideoId = null,
+ ),
+ title = contentId,
+ seasonNumber = resolvedSeason,
+ episodeNumber = resolvedNumber,
+ episodeTitle = resolvedEpisode?.title,
+ lastPositionMs = 1L,
+ durationMs = 1L,
+ lastUpdatedEpochMs = rankedTimestamp(lastWatchedAt, fallbackIndex = 0),
+ isCompleted = true,
+ progressPercent = 100f,
+ source = WatchProgressSourceTraktShowProgress,
+ )
+ }
+
+ private fun watchedShowLookupKeys(ids: TraktExternalIds?): List {
+ if (ids == null) return emptyList()
+ return buildList {
+ ids.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
+ ids.tmdb?.let { add("tmdb:$it") }
+ ids.trakt?.let { add("trakt:$it") }
+ ids.slug?.takeIf { it.isNotBlank() }?.let { add(it) }
+ }
+ }
+
+ private fun canonicalLookupKey(contentId: String): String {
+ val canonical = normalizeTraktContentId(
+ ids = parseTraktContentIds(contentId),
+ fallback = contentId.trim(),
+ )
+ return canonical.takeIf { it.isNotBlank() } ?: contentId.trim()
+ }
+
+ private fun optimisticallyAddWatchedEpisode(contentId: String, season: Int, episode: Int) {
+ updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = true)
+ }
+
+ private fun optimisticallyRemoveWatchedEpisode(contentId: String, season: Int, episode: Int) {
+ updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = false)
+ }
+
+ private fun updateWatchedEpisodeCache(
+ contentId: String,
+ season: Int,
+ episode: Int,
+ add: Boolean,
+ ) {
+ val key = contentId.trim()
+ if (key.isBlank()) return
+ val keysToUpdate = showIdSiblingsMap[key]
+ ?.let { siblings -> (siblings + key).filter { it != AMBIGUOUS_ID_MARKER && !it.startsWith("trakt:") } }
+ ?: listOf(key)
+ val updated = watchedShowEpisodesById.toMutableMap()
+ var changed = false
+ keysToUpdate.forEach { lookupKey ->
+ val current = updated[lookupKey].orEmpty()
+ val pair = season to episode
+ val next = if (add) current + pair else current - pair
+ if (next != current) {
+ updated[lookupKey] = next
+ changed = true
+ }
+ }
+ if (changed) {
+ watchedShowEpisodesById = updated
+ }
+ }
+
private fun mergeNewestByVideoId(entries: List): List {
val mergedByVideoId = linkedMapOf()
entries.forEach { rawEntry ->
@@ -821,6 +1319,62 @@ object TraktProgressRepository {
return normalized.coerceIn(0f, 100f)
}
+ private fun recentWatchCutoffMs(): Long? {
+ val daysCap = normalizeTraktContinueWatchingDaysCap(
+ TraktSettingsRepository.uiState.value.continueWatchingDaysCap,
+ )
+ if (daysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null
+ return TraktPlatformClock.nowEpochMs() - (daysCap.toLong() * MILLIS_PER_DAY)
+ }
+
+ private fun isAllHistoryWindow(): Boolean =
+ normalizeTraktContinueWatchingDaysCap(
+ TraktSettingsRepository.uiState.value.continueWatchingDaysCap,
+ ) == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
+
+ private fun epochMsToTraktIso(epochMs: Long): String {
+ val totalSeconds = epochMs.coerceAtLeast(0L) / 1000L
+ val second = (totalSeconds % 60).toInt()
+ val minute = ((totalSeconds / 60) % 60).toInt()
+ val hour = ((totalSeconds / 3600) % 24).toInt()
+ var days = (totalSeconds / 86400).toInt()
+
+ var year = 1970
+ while (true) {
+ val daysInYear = if (isLeapYear(year)) 366 else 365
+ if (days < daysInYear) break
+ days -= daysInYear
+ year += 1
+ }
+
+ val monthDays = if (isLeapYear(year)) {
+ intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ } else {
+ intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ }
+ var monthIndex = 0
+ while (monthIndex < monthDays.size && days >= monthDays[monthIndex]) {
+ days -= monthDays[monthIndex]
+ monthIndex += 1
+ }
+ val month = monthIndex + 1
+ val day = days + 1
+
+ return "${year.pad4()}-${month.pad2()}-${day.pad2()}T${hour.pad2()}:${minute.pad2()}:${second.pad2()}.000Z"
+ }
+
+ private fun isLeapYear(year: Int): Boolean =
+ (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
+
+ private fun Int.pad2(): String = if (this < 10) "0$this" else "$this"
+ private fun Int.pad4(): String = toString().padStart(4, '0')
+
+ private fun com.nuvio.app.features.addons.RawHttpResponse.headerInt(name: String): Int? =
+ headers[name.lowercase()]
+ ?.substringBefore(',')
+ ?.trim()
+ ?.toIntOrNull()
+
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
isoDate
?.takeIf { it.isNotBlank() }
@@ -860,6 +1414,19 @@ private data class TraktPlaybackItem(
@SerialName("episode") val episode: TraktEpisode? = null,
)
+@Serializable
+private data class TraktLastActivitiesResponse(
+ @SerialName("all") val all: String? = null,
+ @SerialName("movies") val movies: TraktLastActivitiesMedia? = null,
+ @SerialName("episodes") val episodes: TraktLastActivitiesMedia? = null,
+)
+
+@Serializable
+private data class TraktLastActivitiesMedia(
+ @SerialName("watched_at") val watchedAt: String? = null,
+ @SerialName("paused_at") val pausedAt: String? = null,
+)
+
@Serializable
private data class TraktHistoryEpisodeItem(
@SerialName("watched_at") val watchedAt: String? = null,
@@ -913,6 +1480,34 @@ private data class TraktEpisode(
@SerialName("ids") val ids: TraktExternalIds? = null,
)
+@Serializable
+private data class TraktShowProgressResponse(
+ @SerialName("aired") val aired: Int? = null,
+ @SerialName("completed") val completed: Int? = null,
+ @SerialName("seasons") val seasons: List? = null,
+)
+
+@Serializable
+private data class TraktShowProgressSeason(
+ @SerialName("number") val number: Int? = null,
+ @SerialName("episodes") val episodes: List? = null,
+)
+
+@Serializable
+private data class TraktShowProgressEpisode(
+ @SerialName("number") val number: Int? = null,
+ @SerialName("completed") val completed: Boolean? = null,
+ @SerialName("last_watched_at") val lastWatchedAt: String? = null,
+)
+
+@Serializable
+private data class TraktSearchResult(
+ @SerialName("type") val type: String? = null,
+ @SerialName("score") val score: Float? = null,
+ @SerialName("show") val show: TraktMedia? = null,
+ @SerialName("movie") val movie: TraktMedia? = null,
+)
+
@Serializable
private data class TraktHiddenItem(
@SerialName("hidden_at") val hiddenAt: String? = null,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
index 69445d7d6..6130d1ae5 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
@@ -1,8 +1,11 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
+import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.features.addons.httpRequestRaw
+import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@@ -38,6 +41,7 @@ internal sealed interface TraktScrobbleItem {
internal object TraktScrobbleRepository {
private data class ScrobbleStamp(
+ val profileId: Int,
val action: String,
val itemKey: String,
val progress: Float,
@@ -54,6 +58,9 @@ internal object TraktScrobbleRepository {
private var lastScrobbleStamp: ScrobbleStamp? = null
private val minSendIntervalMs = 8_000L
private val progressWindow = 1.5f
+ private val maxStopRetries = 2
+ private val retryDelayMs = 1_500L
+ private val serverOverloadedRetryDelayMs = 5_000L
suspend fun scrobbleStart(item: TraktScrobbleItem, progressPercent: Float) {
sendScrobble(action = "start", item = item, progressPercent = progressPercent)
@@ -113,8 +120,9 @@ internal object TraktScrobbleRepository {
progressPercent: Float,
) {
val headers = TraktAuthRepository.authorizedHeaders() ?: return
+ val activeProfileId = ProfileRepository.activeProfileId
val clampedProgress = progressPercent.coerceIn(0f, 100f)
- if (shouldSkip(action, item.itemKey, clampedProgress)) return
+ if (shouldSkip(activeProfileId, action, item.itemKey, clampedProgress)) return
val url = "$BASE_URL/scrobble/$action"
val requestBody = json.encodeToString(buildRequestBody(item, clampedProgress))
@@ -140,70 +148,74 @@ internal object TraktScrobbleRepository {
}
}
- val response = runCatching {
- httpRequestRaw(
- method = "POST",
- url = url,
- body = requestBody,
- headers = requestHeaders,
- )
- }.onFailure { error ->
- if (error is CancellationException) throw error
- log.w(error) {
+ val attempts = if (action == "stop") maxStopRetries + 1 else 1
+ var wasSent = false
+ for (attempt in 1..attempts) {
+ val response = runCatching {
+ httpRequestRaw(
+ method = "POST",
+ url = url,
+ body = requestBody,
+ headers = requestHeaders,
+ )
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ log.w(error) {
+ "Trakt scrobble $action transport failure on attempt $attempt/$attempts"
+ }
+ }.getOrNull()
+
+ if (response == null) {
+ if (attempt < attempts) {
+ delay(retryDelayMs * attempt)
+ continue
+ }
+ return
+ }
+
+ log.d {
buildString {
append("Trakt scrobble ")
append(action)
- append(" transport failure")
+ append(" response")
+ append('\n')
+ append("status=")
+ append(response.status)
+ append(' ')
+ append(response.statusText.ifBlank { "" })
append('\n')
append("url=")
- append(url)
+ append(response.url)
append('\n')
append("headers=")
- append(requestHeaders.redactedForLogs().formatForLog())
+ append(response.headers.formatForLog())
append('\n')
append("body=")
- append(requestBody.ifBlank { "" })
+ append(response.body.ifBlank { "" })
}
}
- }.getOrNull()
- if (response == null) return
-
- log.d {
- buildString {
- append("Trakt scrobble ")
- append(action)
- append(" response")
- append('\n')
- append("status=")
- append(response.status)
- append(' ')
- append(response.statusText.ifBlank { "" })
- append('\n')
- append("url=")
- append(response.url)
- append('\n')
- append("headers=")
- append(response.headers.formatForLog())
- append('\n')
- append("body=")
- append(response.body.ifBlank { "" })
+ if (response.status in 200..299 || response.status == 409) {
+ wasSent = true
+ break
}
- }
- val wasSent = when (response.status) {
- in 200..299, 409 -> true
- else -> {
- log.w {
- "Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "" }}"
- }
- false
+ if (response.status in 500..599 && attempt < attempts) {
+ val delayMs = if (response.status in 502..504) serverOverloadedRetryDelayMs else retryDelayMs * attempt
+ delay(delayMs)
+ continue
}
+
+ log.w {
+ "Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "" }}"
+ }
+ return
}
if (!wasSent) return
lastScrobbleStamp = ScrobbleStamp(
+ profileId = activeProfileId,
action = action,
itemKey = item.itemKey,
progress = clampedProgress,
@@ -231,6 +243,7 @@ internal object TraktScrobbleRepository {
ids = item.ids.toRequestBodyOrNull(),
),
progress = clampedProgress,
+ appVersion = AppVersionConfig.VERSION_NAME,
)
is TraktScrobbleItem.Episode -> TraktScrobbleRequest(
@@ -245,21 +258,23 @@ internal object TraktScrobbleRepository {
number = item.number,
),
progress = clampedProgress,
+ appVersion = AppVersionConfig.VERSION_NAME,
)
}
}
- private fun shouldSkip(action: String, itemKey: String, progress: Float): Boolean {
+ private fun shouldSkip(profileId: Int, action: String, itemKey: String, progress: Float): Boolean {
val last = lastScrobbleStamp ?: return false
val now = TraktPlatformClock.nowEpochMs()
val isSameWindow = now - last.timestampMs < minSendIntervalMs
+ val isSameProfile = last.profileId == profileId
val isSameAction = last.action == action
val isSameItem = last.itemKey == itemKey
val isNearProgress = abs(last.progress - progress) <= progressWindow
- if (action == "stop" && last.action == "start" && isSameItem) {
+ if (action == "stop" && last.action == "start" && isSameItem && isSameProfile) {
return false
}
- return isSameWindow && isSameAction && isSameItem && isNearProgress
+ return isSameWindow && isSameProfile && isSameAction && isSameItem && isNearProgress
}
private fun Map.redactedForLogs(): Map =
@@ -302,6 +317,7 @@ private data class TraktScrobbleRequest(
@SerialName("show") val show: TraktShowBody? = null,
@SerialName("episode") val episode: TraktEpisodeBody? = null,
@SerialName("progress") val progress: Float,
+ @SerialName("app_version") val appVersion: String? = null,
)
@Serializable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
index ac647c892..f3c825916 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
@@ -2,7 +2,8 @@ package com.nuvio.app.features.watching.sync
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
-import com.nuvio.app.features.addons.httpPostJsonWithHeaders
+import com.nuvio.app.features.addons.httpRequestRaw
+import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.trakt.TraktPlatformClock
@@ -132,10 +133,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val movies = mutableListOf()
val shows = mutableListOf()
- items.forEach { item ->
- if (!item.shouldSyncToTraktHistory()) return@forEach
+ for (item in items) {
+ if (!item.shouldSyncToTraktHistory()) continue
- val ids = parseIds(item.id) ?: return@forEach
+ val ids = resolveHistoryIds(item) ?: continue
val normalizedType = item.type.trim().lowercase()
if (normalizedType == "movie" || normalizedType == "film") {
@@ -201,20 +202,28 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
- val responseText = runCatching {
- httpPostJsonWithHeaders(
+ val response = runCatching {
+ httpRequestRaw(
+ method = "POST",
url = "$BASE_URL/sync/history",
body = body,
- headers = headers,
+ headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to push watched items to Trakt: ${e.message}" }
}.getOrNull()
- // Retry with remapped numbering for episodes that Trakt didn't recognize
- // (anime with different season structures between addon and Trakt).
- if (responseText != null && shows.isNotEmpty()) {
+ val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload ->
+ runCatching { json.decodeFromString(payload) }.getOrNull()
+ }
+ val shouldRetryRemap = shows.isNotEmpty() && (
+ response == null ||
+ response.status !in 200..299 ||
+ hasHistoryAddNotFound(responseBody) ||
+ !hasSuccessfulHistoryAdd(responseBody)
+ )
+ if (shouldRetryRemap) {
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
@@ -243,7 +252,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
- val ids = parseIds(item.id) ?: continue
+ val ids = resolveHistoryIds(item) ?: continue
val existing = remappedShows.firstOrNull { it.ids == ids }
if (existing != null) {
val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
@@ -297,10 +306,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
)
runCatching {
- httpPostJsonWithHeaders(
+ httpRequestRaw(
+ method = "POST",
url = "$BASE_URL/sync/history",
body = retryBody,
- headers = headers,
+ headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
@@ -319,10 +329,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val movies = mutableListOf()
val shows = mutableListOf()
- items.forEach { item ->
- if (!item.shouldSyncToTraktHistory()) return@forEach
+ for (item in items) {
+ if (!item.shouldSyncToTraktHistory()) continue
- val ids = parseIds(item.id) ?: return@forEach
+ val ids = resolveHistoryIds(item) ?: continue
val normalizedType = item.type.trim().lowercase()
if (normalizedType == "movie" || normalizedType == "film") {
@@ -357,23 +367,32 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
- runCatching {
- httpPostJsonWithHeaders(
+ val response = runCatching {
+ httpRequestRaw(
+ method = "POST",
url = "$BASE_URL/sync/history/remove",
body = body,
- headers = headers,
+ headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
- }
+ }.getOrNull()
- // Retry removal with remapped numbering for anime cases
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
- if (episodeItems.isNotEmpty()) {
+ val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload ->
+ runCatching { json.decodeFromString(payload) }.getOrNull()
+ }
+ val shouldRetryRemap = episodeItems.isNotEmpty() && (
+ response == null ||
+ response.status !in 200..299 ||
+ hasHistoryRemoveNotFound(responseBody) ||
+ (responseBody?.deleted?.episodes ?: 0) == 0
+ )
+ if (shouldRetryRemap) {
retryDeleteWithRemappedEpisodes(headers, episodeItems)
}
}
@@ -396,7 +415,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
- val ids = parseIds(item.id) ?: continue
+ val ids = resolveHistoryIds(item) ?: continue
remappedShowDtos += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
@@ -422,10 +441,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
)
runCatching {
- httpPostJsonWithHeaders(
+ httpRequestRaw(
+ method = "POST",
url = "$BASE_URL/sync/history/remove",
body = retryBody,
- headers = headers,
+ headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
@@ -467,6 +487,53 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
return null
}
+ private suspend fun resolveHistoryIds(item: WatchedItem): TraktSyncIdsDto? {
+ val ids = parseIds(item.id) ?: return null
+ return enrichWithImdb(ids = ids, contentType = item.type)
+ }
+
+ private suspend fun enrichWithImdb(
+ ids: TraktSyncIdsDto,
+ contentType: String,
+ ): TraktSyncIdsDto {
+ if (ids.tmdb == null || !ids.imdb.isNullOrBlank()) return ids
+ val imdb = runCatching {
+ TmdbService.tmdbToImdb(tmdbId = ids.tmdb, mediaType = contentType)
+ }.getOrNull() ?: return ids
+ return ids.copy(imdb = imdb)
+ }
+
+ private fun jsonHeaders(headers: Map): Map =
+ mapOf(
+ "Accept" to "application/json",
+ "Content-Type" to "application/json",
+ ) + headers
+
+ private fun hasSuccessfulHistoryAdd(body: TraktHistoryAddResponseDto?): Boolean {
+ val added = body?.added ?: return false
+ val addedCount = (added.movies ?: 0) +
+ (added.episodes ?: 0) +
+ (added.shows ?: 0) +
+ (added.seasons ?: 0)
+ return addedCount > 0
+ }
+
+ private fun hasHistoryAddNotFound(body: TraktHistoryAddResponseDto?): Boolean {
+ val notFound = body?.notFound ?: return false
+ return !notFound.movies.isNullOrEmpty() ||
+ !notFound.shows.isNullOrEmpty() ||
+ !notFound.seasons.isNullOrEmpty() ||
+ !notFound.episodes.isNullOrEmpty()
+ }
+
+ private fun hasHistoryRemoveNotFound(body: TraktHistoryRemoveResponseDto?): Boolean {
+ val notFound = body?.notFound ?: return false
+ return !notFound.movies.isNullOrEmpty() ||
+ !notFound.shows.isNullOrEmpty() ||
+ !notFound.seasons.isNullOrEmpty() ||
+ !notFound.episodes.isNullOrEmpty()
+ }
+
private val yearRegex = Regex("(19|20)\\d{2}")
private fun parseYear(value: String?): Int? {
if (value.isNullOrBlank()) return null
@@ -581,6 +648,34 @@ private data class TraktHistoryAddRequestDto(
@SerialName("shows") val shows: List? = null,
)
+@Serializable
+private data class TraktHistoryAddResponseDto(
+ @SerialName("added") val added: TraktHistoryMutationCountDto? = null,
+ @SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null,
+)
+
+@Serializable
+private data class TraktHistoryRemoveResponseDto(
+ @SerialName("deleted") val deleted: TraktHistoryMutationCountDto? = null,
+ @SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null,
+)
+
+@Serializable
+private data class TraktHistoryMutationCountDto(
+ @SerialName("movies") val movies: Int? = null,
+ @SerialName("episodes") val episodes: Int? = null,
+ @SerialName("shows") val shows: Int? = null,
+ @SerialName("seasons") val seasons: Int? = null,
+)
+
+@Serializable
+private data class TraktHistoryNotFoundDto(
+ @SerialName("movies") val movies: List? = null,
+ @SerialName("shows") val shows: List? = null,
+ @SerialName("seasons") val seasons: List? = null,
+ @SerialName("episodes") val episodes: List? = null,
+)
+
@Serializable
private data class TraktHistoryMovieRequestDto(
@SerialName("title") val title: String? = null,
@@ -609,6 +704,13 @@ private data class TraktHistoryEpisodeRequestDto(
@SerialName("watched_at") val watchedAt: String? = null,
)
+@Serializable
+private data class TraktSyncEpisodeDto(
+ @SerialName("season") val season: Int? = null,
+ @SerialName("number") val number: Int? = null,
+ @SerialName("ids") val ids: TraktSyncIdsDto? = null,
+)
+
// ── DTOs for delete (POST /sync/history/remove) ─────────────────────────
@Serializable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
index 52274d189..fba8ae33a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
@@ -381,8 +381,25 @@ object WatchProgressRepository {
if (videoIds.isEmpty()) return
if (shouldUseTraktProgress()) {
+ val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds }
videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval)
publish()
+ if (entriesToRemove.isNotEmpty()) {
+ syncScope.launch {
+ entriesToRemove.forEach { entry ->
+ runCatching {
+ TraktProgressRepository.removeProgress(
+ contentId = entry.parentMetaId,
+ seasonNumber = entry.seasonNumber,
+ episodeNumber = entry.episodeNumber,
+ )
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" }
+ }
+ }
+ }
+ }
return
}
@@ -464,6 +481,22 @@ object WatchProgressRepository {
return currentEntries().continueWatchingEntries()
}
+ fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) {
+ ensureLoaded()
+ if (!shouldUseTraktProgress()) return
+ syncScope.launch {
+ runCatching {
+ TraktProgressRepository.refreshEpisodeProgress(
+ contentId = contentId,
+ forceRefresh = forceRefresh,
+ )
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" }
+ }
+ }
+ }
+
private fun upsert(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,
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 b72266641..2ebf8447c 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
@@ -17,6 +17,12 @@ import kotlin.test.assertTrue
class HomeScreenTest {
+ @Test
+ fun `home trakt continue watching candidate limits match TV`() {
+ assertEquals(300, HomeContinueWatchingMaxRecentProgressItems)
+ assertEquals(32, HomeNextUpInitialResolutionLimit)
+ }
+
@Test
fun `build home continue watching items removes duplicate video ids`() {
val inProgress = progressEntry(
From 24620fe9e1d450befc8ee7dab0a56a7570c4bc06 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Costa?=
<79596903+affcosta7@users.noreply.github.com>
Date: Mon, 25 May 2026 11:46:56 +0100
Subject: [PATCH 03/12] Update strings.xml
---
.../composeResources/values-pt/strings.xml | 1151 ++++++++++-------
1 file changed, 688 insertions(+), 463 deletions(-)
diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
index 2fdffa861..725e8cc2e 100644
--- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
@@ -1,5 +1,6 @@
- Reconhecimento aberto e créditos do projeto
+ Fontes de dados, agradecimentos e licenças da plataforma
+ Reconhecimento público e créditos do projeto
Voltar
Cancelar
Fechar
@@ -13,15 +14,18 @@
Anterior
Remover
Reordenar
- Repor
+ Repor predefinições
Retomar
Tentar novamente
Guardar
+ A guardar…
+ Validar
A instalar
Addons
Ativo
%1$d catálogos
Configurável
+ Desativado
A atualizar
%1$d recursos
Indisponível
@@ -29,66 +33,66 @@
Eliminar addon
Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio.
Ainda não tens addons instalados.
- Introduz um URL de addon.
- URL do Addon
- Instalar Addon
+ Introduz o URL de um addon.
+ URL do addon
+ Instalar addon
A carregar detalhes do manifesto...
- A validar o URL do manifesto e a carregar detalhes do addon antes de instalar.
- A verificar Addon
- Falha na Instalação
+ A validar o URL do manifesto e a carregar os detalhes do addon antes de instalar.
+ A verificar addon
+ Falha na instalação
%1$s foi validado e adicionado com sucesso.
- Addon Instalado
+ Addon instalado
Mover addon para baixo
Mover addon para cima
Ativos
Addons
Catálogos
Atualizar addon
- Adicionar Addon
- Addons Instalados
- Resumo
+ Adicionar addon
+ Addons instalados
+ Visão geral
%1$d regras de id
Versão %1$s
Selecionado
Copiar JSON
%1$d coleção(ões), %2$d pasta(s)
- Eliminar "%1$s"? Não poderás desfazer esta ação.
- Eliminar Coleção
- Adicionar Catálogo
- Adicionar Pasta
+ Eliminar "%1$s"? Esta ação não pode ser desfeita.
+ Eliminar coleção
+ Adicionar catálogo
+ Adicionar pasta
Todos os géneros
- Adiciona catálogos dos teis addons instalados para definires o que esta pasta mostra.
+ Adiciona catálogos dos teus addons instalados para definires o que esta pasta mostra.
Ainda sem fontes de catálogo
Escolher
Emoji
- URL da Imagem
+ URL da imagem
Nenhum
Capa
- Criar Coleção
+ Criar coleção
Concluído
- Editar Coleção
- Editar Pasta
- Define a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor de coleções principal.
- Adiciona uma para começar.
+ Editar coleção
+ Editar pasta
+ Define a identidade da pasta, a apresentação e as fontes de catálogo com a mesma estrutura do editor principal de coleções.
+ Adiciona uma para começares.
Ainda sem pastas
Pastas
- Filtro de Género
+ Filtro de género
Mostrar apenas a imagem de capa
- Ocultar Título
- Nova Pasta
- Mostrar esta coleção acima de todos os catálogos normais do início. Múltiplas coleções afixadas seguem a ordem de criação.
- Afixar Acima dos Catálogos
+ Ocultar título
+ Nova pasta
+ Mostra esta coleção acima de todos os catálogos normais da página inicial. Se fixares várias coleções, elas seguirão a ordem de criação.
+ Fixar acima dos catálogos
URL da imagem de fundo (opcional)
Nome da pasta
- URL do GIF animado (reproduz apenas quando focado)
+ URL do GIF animado (reproduz apenas quando destacado)
Nome da coleção
- Guardar Alterações
+ Guardar alterações
Guardar
Aspeto
Básico
- Fontes de Catálogo
- Escolhe os catálogos de addons que esta pasta deve agregar.
- Selecionar Catálogos
+ Fontes de catálogo
+ Escolhe os catálogos dos addons que esta pasta deve agregar.
+ Selecionar catálogos
Selecionar género
%1$d selecionados
%1$d catálogos
@@ -97,97 +101,118 @@
Quadrado
Panorâmico
Combinar todos os catálogos num único separador
- Mostrar Separador \"Tudo\"
- Reproduzir o GIF configurado em vez da capa estática quando disponível.
- Mostrar GIF Quando Configurado
+ Mostrar separador \"Tudo\"
+ Reproduzir o GIF configurado em vez da capa estática, quando disponível.
+ Mostrar GIF quando configurado
%1$d fonte(s) · %2$s
- Formato do Cartão
+ Formato do mosaico
Linhas
Separadores
- Modo de Visualização
+ Modo de visualização
Fontes TMDB
- Lista Pública
- Produção
- Canal/Rede
+ Lista pública
+ Produtora
+ Canal/Plataforma
Coleção
Pessoa
Realizador
Personalizado
- Escolhe uma fonte pronta a usar. Podes editá-la ou removê-la depois de adicionar.
- Cola um URL de uma lista pública do TMDB ou apenas o número do URL.
- Pesquisa pelo nome do estúdio, ou cola um ID/URL de empresa do TMDB para adicionar diretamente.
- Introduz um ID de canal. Os canais comuns estão disponíveis nos Presets e filtros rápidos.
- Pesquisa o nome de uma coleção de filmes ou cola o ID da coleção do TMDB.
- Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no elenco.
- Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no trabalho de realização.
- Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios se não precisares do filtro.
+ Escolhe uma fonte já preparada. Podes editá-la ou removê-la depois de a adicionares.
+ Cola o URL de uma lista pública do TMDB ou apenas o número contido no URL.
+ Pesquisa pelo nome do estúdio ou cola o ID/URL da empresa no TMDB para a adicionares diretamente.
+ Introduz o ID de um canal ou plataforma. Os mais comuns estão disponíveis nas Predefinições e nos filtros rápidos.
+ Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção a partir do TMDB.
+ Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos do elenco.
+ Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos de realização.
+ Cria uma linha dinâmica do TMDB usando filtros opcionais. Deixa os campos vazios se não precisares do filtro.
Lista pública do TMDB
- ID do Canal
- ID da Coleção
- ID da Pessoa
- Nome da produtora, ID ou URL
+ ID do canal/plataforma
+ ID da coleção
+ ID da pessoa
+ Nome, ID ou URL da produtora
ID ou URL do TMDB
https://www.themoviedb.org/list/8504994 ou 8504994
213 para Netflix, 49 para HBO, 2739 para Disney+
- 10 para Coleção Star Wars
+ 10 para a saga Star Wars
Marvel Studios, 420 ou URL da empresa
31 para Tom Hanks ou URL da pessoa
- Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420.
- Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção.
- IDs de exemplo: Netflix 213, HBO 49, Disney+ 2739.
+ Exemplos: Marvel Studios, 420, ou https://www.themoviedb.org/company/420.
+ Exemplo: Star Wars Collection, Harry Potter Collection, ou o URL de uma coleção.
+ Exemplos de IDs: Netflix 213, HBO 49, Disney+ 2739.
Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994.
Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31.
- Título de exibição
- Aparece como o nome da linha/separador. Se estiver vazio, o Nuvio cria um a partir da fonte.
+ Título a apresentar
+ Aparece como o nome da linha/separador. Se ficar em branco, o Nuvio cria um automaticamente a partir da fonte.
Filmes da Marvel, Originais Netflix, Pixar
- Filmes do Tom Hanks, Atores Favoritos
- Filmes do Christopher Nolan, Realizadores Favoritos
- Melhores Filmes de Ação, Dramas Coreanos, Animação 2024
- Resultados da Pesquisa
+ Filmes com Tom Hanks, Atores favoritos
+ Filmes de Christopher Nolan, Realizadores favoritos
+ Melhores filmes de ação, Dramas coreanos, Animação de 2024
+ Resultados da pesquisa
Coleção TMDB
- Empresa TMDB %1$d
+ Produtora TMDB %1$d
Coleção TMDB %1$d
Tipo
Filmes
Séries
Ambos
- Ordenar
+ Ordenar por
Filtros
- Deixa os campos vazios se não precisares desse filtro.
+ Deixa os campos em branco se não precisares desse filtro.
Géneros rápidos
Idiomas rápidos
Países rápidos
Palavras-chave rápidas
Estúdios rápidos
- Canais rápidos
- IDs de Género
- Usa números de género do TMDB. Separa múltiplos com vírgulas para E, ou barras verticais para OU.
- Data de lançamento/emissão de
- Data de lançamento/emissão até
- Usa AAAA-MM-DD, por exemplo 2024-01-01.
+ Canais/Plataformas rápidos
+ IDs de género
+ Usa os números de género do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU".
+ Lançamento ou emissão desde
+ Lançamento ou emissão até
+ Usa o formato AAAA-MM-DD, por exemplo, 2024-01-01.
Classificação mínima
Classificação máxima
- Classificação TMDB de 0 a 10. Exemplo: 7.0.
- Mínimo de votos
+ Classificação do TMDB de 0 a 10. Exemplo: 7.0.
+ Votos mínimos
Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100.
Idioma original
- Usa códigos de idioma de duas letras, por exemplo en, ko, ja, hi.
+ Usa códigos de idioma de duas letras, por exemplo, en, ko, ja, hi.
País de origem
- Usa códigos de país de duas letras, por exemplo US, KR, JP, IN.
- IDs de Palavras-chave
- Usa números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns.
+ Usa códigos de país de duas letras, por exemplo, US, KR, JP, IN.
+ IDs de palavras-chave
+ Usa os números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns.
9715 para super-herói
- IDs de Empresas
- Usa IDs de estúdios/empresas. Os botões rápidos preenchem exemplos comuns.
+ IDs das produtoras
+ Usa os IDs de estúdios/produtoras. Os botões rápidos preenchem exemplos comuns.
420 para Marvel Studios
- IDs de Canais
- Apenas para séries. Usa IDs de canais como Netflix 213 ou HBO 49.
+ IDs dos canais/plataformas
+ Apenas para séries. Usa IDs como Netflix 213 ou HBO 49.
213 para Netflix
Ano
- Usa um ano com quatro dígitos, por exemplo 2024.
- Presets
+ Usa um ano com quatro dígitos, por exemplo, 2024.
+ Predefinições
Pesquisar
- Adicionar Fonte
+ Adicionar fonte
+ Adicionar lista do Trakt
+ Editar lista do Trakt
+ Listas do Trakt
+ Lista do Trakt
+ Pesquisa pelo título, URL do Trakt ou ID da lista
+ Usa o URL de uma lista pública do Trakt, o ID numérico da lista ou pesquisa pelo nome.
+ Para ver ao fim de semana, Vencedores de prémios
+ Resultados da pesquisa
+ Listas do momento
+ Listas populares
+ Ordem
+ Ascendente
+ Descendente
+ Ordem da lista
+ Adicionado recentemente
+ Título
+ Lançamento
+ Duração
+ Popularidade
+ Percentagem
+ Votos
Ação
Aventura
Animação
@@ -196,7 +221,7 @@
Ficção Científica
Drama
Crime
- Reality TV
+ Reality Show
Inglês
Coreano
Japonês
@@ -208,8 +233,8 @@
Índia
Reino Unido
Super-herói
- Baseado num Livro
- Viagem no Tempo
+ Baseado num romance
+ Viagem no tempo
Espaço
Marvel
Disney
@@ -223,69 +248,82 @@
Hulu
Original
Popular
- Melhor Classificados
- Recente
- Lista TMDB
- Coleção de Filmes TMDB
- Produção
- Canal
+ Mais votados
+ Recentes
+ Mais votos
+ Região de transmissão
+ Código de país ISO 3166-1 onde o título está disponível. Exemplo: PT, BR, US.
+ Regiões rápidas
+ IDs dos fornecedores
+ Usa os IDs de fornecedores do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU".
+ 8|337|350
+ Fornecedores rápidos
+ Netflix
+ Prime Video
+ Disney+
+ Apple TV+
+ Hulu
+ Lista do TMDB
+ Coleção de filmes do TMDB
+ Produtora
+ Canal/Plataforma
Pessoa
Realizador
- Descoberta TMDB
+ Explorar TMDB
Cria uma para organizares os teus catálogos.
Ainda sem coleções
%1$d pasta(s)
Nenhum item encontrado
Pasta não encontrada
Coleções
- Importar Coleções
+ Importar coleções
JSON
- Cola abaixo o JSON das tuas coleções.
+ Cola o JSON das tuas coleções abaixo.
Importar
- Nova Coleção
- Afixado
+ Nova coleção
+ Fixadas
Tudo
- As Tuas Coleções
+ As tuas coleções
Feito com ❤️ pela Tapframe e amigos
Versão %1$s (%2$s)
- Desligado
- Ligado
+ Desativado
+ Ativado
Pausa
Recarregar
Já tens uma conta?
- Continuar Sem Conta
- Criar Conta
+ Continuar sem conta
+ Criar conta
Não tens uma conta?
E-mail
- ou
+ ou
Palavra-passe
Inicia sessão para acederes à tua biblioteca e progresso
- Iniciar Sessão
- Cria conta para sincronizares os teus dados entre dispositivos
- Criar Conta
+ Iniciar sessão
+ Regista-te para sincronizares os teus dados entre dispositivos
+ Registar
Os teus dados serão guardados apenas localmente
- Tudo em stream, em qualquer lugar
- Bem-vindo de volta
+ Tudo em streaming, em qualquer lugar
+ Olá novamente
Biblioteca
- Biblioteca Trakt
+ Biblioteca do Trakt
Início
Biblioteca
Perfil
Pesquisar
- Faixas de Áudio
+ Faixas de áudio
Áudio
Integrado
- Ajuste Inferior
- Fechar reprodutor
+ Afastamento inferior
+ Fechar leitor
Cor
- A reproduzir agora
+ A reproduzir
E%1$d
- S%1$dE%2$d
- S%1$dE%2$d • %3$s
+ T%1$dE%2$d
+ T%1$dE%2$d • %3$s
Episódios
- Tamanho da Letra
+ Tamanho da letra
%1$dsp
- Bloquear controlos
+ Bloquear controlos do leitor
Nenhuma faixa de áudio disponível
Nenhum episódio disponível
Nenhum stream encontrado
@@ -298,10 +336,10 @@
A reproduzir
Toca para procurar legendas
Voltar
- Repor Predefinições
+ Repor predefinições
Preencher
Ajustar
- Zoom
+ Ir para o zoom
Recuar 10 segundos
-%1$ds
+%1$ds
@@ -310,77 +348,108 @@
Avançar 10 segundos
Fontes
Estilo
- Legs
+ Legendas
Legendas
Brilho %1$s
Volume %1$s
Sem som
Transferido
- Emissão
+ Emitido em
A anunciar
Toca para desbloquear
Faixa %1$d
- Desbloquear controlos
+ Desbloquear controlos do leitor
Estás a ver
- Adicionar Perfil
+ Adicionar perfil
Limpar pesquisa
- Descobrir
- Os addons instalados falharam ao devolver resultados de pesquisa válidos.
+ Explorar
+ Os addons instalados não devolveram resultados de pesquisa válidos.
Falha na pesquisa
Instala e valida pelo menos um addon antes de pesquisares.
Sem addons ativos
- Os catálogos pesquisáveis instalados não encontraram nada para esta procura.
+ Os catálogos pesquisáveis instalados não devolveram correspondências para esta consulta.
Nenhum resultado encontrado
- Os teus addons instalados não permitem pesquisa em catálogos.
+ Os teus addons instalados não disponibilizam pesquisa de catálogos.
Sem catálogos pesquisáveis
Pesquisa filmes, séries...
- Pesquisas Recentes
+ Pesquisas recentes
Remover pesquisa recente
Sobre
Geral
Conta
Addons
- Aspeto
+ Esquema
Conteúdo e Descoberta
Continuar a Ver
- Ecrã Inicial
+ Serviços Ligados
+ Esquema do Ecrã Inicial
Integrações
+ Licenças e Atribuição
Classificações MDBList
- Ecrã de Metadados
+ Página de Detalhes
Notificações
Reprodução
Plugins
- Personalização de Pósteres
+ Estilo dos Cartões de Póster
Definições
Apoiantes e Colaboradores
Enriquecimento TMDB
Trakt
SOBRE
- Gere a tua conta, termina sessão ou elimina-a.
+ Conta e estado de sincronização
CONTA
- Afina a apresentação do início e as preferências visuais.
- Verifica se há novas versões da aplicação.
- Verificar atualizações
+ Estrutura inicial e estilos de póster
+ Transferir a versão mais recente
+ Procurar atualizações
Gere addons e fontes de descoberta.
Gere os teus filmes e episódios transferidos.
Transferências
GERAL
- Liga os serviços TMDB e MDBList.
- Gere alertas de lançamento de episódios e envia uma notificação de teste.
+ Gere as integrações disponíveis
+ Gere alertas de novos episódios e envia uma notificação de teste.
Muda para um perfil diferente.
Mudar de Perfil
- Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt.
+ Abrir ecrã de ligação ao Trakt
+ Nenhuma definição encontrada.
+ Pesquisar definições...
+ RESULTADOS
+ LICENÇA DA APP
+ DADOS E SERVIÇOS
+ LICENÇA DE REPRODUÇÃO
+ Nuvio Mobile
+ O código-fonte e os termos de licença estão disponíveis no repositório do projeto.
+ Licenciado sob a GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ O Nuvio usa a API do TMDB para metadados de filmes e séries, imagens, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto usa a API do TMDB mas não é endossado ou certificado pelo TMDB.
+ Conjuntos de dados não comerciais do IMDb
+ O Nuvio usa os conjuntos de dados não comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informação cedida gentilmente pelo IMDb (https://www.imdb.com). Usado com permissão. Os dados do IMDb destinam-se a uso pessoal e não comercial sob os termos do IMDb.
+ Trakt
+ O Nuvio liga-se ao Trakt para autenticação de conta, histórico de visualizações, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado nem endossado pelo Trakt.
+ Premiumize
+ O Nuvio liga-se ao Premiumize para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo Premiumize.
+ TorBox
+ O Nuvio liga-se ao TorBox para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo TorBox.
+ MDBList
+ O Nuvio usa o MDBList para classificações e dados de fornecedores de pontuações externas. O Nuvio não é afiliado nem endossado pelo MDBList.
+ IntroDB
+ O Nuvio usa a API do IntroDB para marcas de tempo de introduções, resumos, créditos e antevisões fornecidas pela comunidade, utilizadas pelos controlos de salto. O Nuvio não é afiliado nem endossado pelo IntroDB.
+ MPVKit
+ Usado para a reprodução em versões iOS.
+ O código-fonte do MPVKit, por si só, está licenciado sob a LGPL v3.0. Os pacotes do MPVKit, incluindo as bibliotecas libmpv e FFmpeg, também estão licenciados sob a LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Usado para a reprodução em versões Android.
+ Licenciado sob a Apache License, Versão 2.0.
A carregar as tuas listas do Trakt…
- Escolhe onde guardar este título no Trakt
+ Escolhe onde queres guardar este título no Trakt
Doar
- Ver detalhes
+ Ir para os detalhes
Remover
- Ver do início
+ Começar do início
Reproduzir
%1$d/10
Crítica
Spoiler
- Ainda não há críticas do Trakt disponíveis.
+ Ainda não existem críticas do Trakt disponíveis.
%1$d gostos
Este comentário contém spoilers.
Este comentário contém spoilers e foi ocultado.
@@ -388,187 +457,255 @@
Trailer
%1$s (%2$d)
Trailers
- Sem episódios concluídos
+ Nenhum episódio concluído
Ainda sem transferências
%1$d episódio(s) transferido(s)
- Ativo
+ Ativas
Filmes
Séries
- Mostrar Transferências
+ Mostrar transferências
Concluído • %1$s
A transferir • %1$s
Falhou
- Pausado • %1$s
+ Em pausa • %1$s
Visto
Temporada %1$d
Especiais
- Continua de onde paraste
+ Continua de onde ficaste
Adicionar à biblioteca
Marcar como não visto
Marcar como visto
Remover da biblioteca
- Ver Tudo
+ Ver tudo
Reproduzir manualmente
Logótipo de %1$s
Conta
- Eliminar Conta
+ Eliminar conta
Isto irá eliminar permanentemente a tua conta e todos os dados associados.
Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente.
- Eliminar Conta?
+ Eliminar conta?
E-mail
Sessão não iniciada
- Terminar Sessão
+ Terminar sessão
Irás regressar ao ecrã de início de sessão.
- Terminar Sessão?
+ Terminar sessão?
Estado
Anónimo
- Sessão Iniciada
+ Sessão iniciada
Preto AMOLED
Usa fundos pretos puros para ecrãs OLED.
- Idioma da Aplicação
- Escolher Idioma
- Mostra, oculta e personaliza a linha \"Continuar a Ver\".
- Ajusta a largura do cartão e as predefinições do raio dos cantos.
+ Idioma da app
+ Escolher idioma
+ Definições para a secção Continuar a Ver.
+ Liquid Glass
+ Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo.
+ Ajusta a largura dos cartões e o raio dos cantos.
ECRÃ
- INÍCIO
+ ECRÃ INICIAL
TEMA
Coleção • %1$s
- Nome de Exibição
- Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial.
- Sem catálogos para o início
- Fonte do Destaque
+ Nome a apresentar
+ Instala um addon com catálogos compatíveis com o painel para configurares as linhas do Ecrã Inicial.
+ Sem catálogos no ecrã inicial
+ Fonte do destaque
Oculto
Manter Início focado
- %1$s • Limite atingido (máx %2$d)
+ %1$s • Limite atingido (máx. %2$d)
Nenhuma fonte de destaque selecionada
Fora do destaque
- Remove a afixação no topo da coleção para a moveres
- Afixado
- Afixado no topo
+ Remove a fixação da coleção no topo para a poderes mover
+ Fixado
+ Fixado no topo
Reordenar
CATÁLOGOS
CATÁLOGOS E COLEÇÕES
COLEÇÕES
- DESTAQUE (HERO)
- FONTES DE DESTAQUE
+ Esquema do Ecrã Inicial
+ Catálogos em Destaque
%1$d de %2$d selecionados
- Mostrar Destaque
- Exibe um carrossel de destaque no topo do Início. Escolhe até 2 catálogos fonte abaixo.
+ Mostrar secção de Destaques
+ Apresenta o carrossel de destaques no topo do ecrã inicial.
+ Ocultar conteúdo não lançado
+ Ocultar filmes e séries que ainda não foram lançados.
+ Ocultar sublinhado dos catálogos
+ Remove a linha de destaque sob os títulos de catálogos e coleções em toda a app.
%1$d de %2$d catálogos visíveis • %3$d fontes de destaque selecionadas
Abre um catálogo apenas quando precisares de o renomear ou reordenar.
Visível
- Reprodutor, legendas e reprodução automática
- Raio do Cartão
- ESTILO DO CARTÃO
- Largura do Cartão
+ Ocultar valor
+ Leitor, legendas e reprodução automática
+ Raio dos cantos
+ Estilo dos cartões de póster
+ Largura
Personalizado
- Personaliza a largura e o raio dos cantos dos cartões em toda a aplicação.
+ Ajusta a largura dos cartões e o raio dos cantos.
Ocultar etiquetas
- Modo panorâmico para pósteres em linha
- Pré-visualização ao Vivo
+ Pósteres em modo horizontal
+ Pré-visualização em tempo real
%1$s (%2$s)
- Raio do canto: %1$ddp
+ Raio dos cantos: %1$ddp
Altura: %1$ddp
Largura: %1$ddp
Clássico
- Pílula
+ Formato pílula
Arredondado
Afiado
- Suave
+ Subtil
Equilibrado
Confortável
Compacto
Denso
Grande
Padrão
- Mostra um aviso para continuares de onde paraste ao abrir a aplicação após saíres do reprodutor.
- Aviso de retoma ao iniciar
- ESTILO DO CARTÃO
+ Mostrar valor
+ Mostra uma mensagem para continuares de onde ficaste quando abrires a app após teres saído do leitor.
+ Confirmar retoma ao iniciar
+ Desfoca as miniaturas do próximo episódio no Continuar a Ver para evitar spoilers.
+ Desfocar não vistos no Continuar a Ver
+ Inclui os próximos episódios no Continuar a Ver antes de serem emitidos.
+ Mostrar próximos episódios não emitidos
+ ORDEM DE ORDENAÇÃO
+ Ordem de ordenação
+ Predefinida
+ Ordena todos os itens pelos mais recentes
+ Estilo Plataforma de Streaming
+ Itens já lançados primeiro, os próximos no fim
+ Estilo dos Cartões de Póster
AO INICIAR
COMPORTAMENTO DO SEGUINTE
VISIBILIDADE
- Exibe a linha \"Continuar a Ver\" no ecrã inicial.
+ Apresenta a linha Continuar a Ver no ecrã inicial.
Mostrar Continuar a Ver
Póster
- Cartão focado na imagem
+ Cartão de póster focado na imagem
Panorâmico
- Cartão horizontal rico em informação
- Quando ativado, o \"Seguinte\" continua sempre a partir do episódio mais avançado que foi visto. Quando desativado, segue a partir do último visto. Útil se costumas rever episódios antigos.
- Seguinte do episódio mais avançado
+ Cartão horizontal com mais informação
+ Mostra o próximo episódio com base no episódio visto mais avançado. Desativa para repetições de séries, usando assim o episódio visto mais recentemente.
+ Seguinte a partir do mais avançado
+ Preferir miniaturas dos episódios quando disponíveis.
+ Preferir miniaturas de episódios no Continuar a Ver
INÍCIO
FONTES
Instala, remove, atualiza e ordena as tuas fontes de conteúdo.
- Instala repositórios de scrapers JavaScript e testa fornecedores internamente.
- Controla quais os catálogos que aparecem no Início e por que ordem.
- Desativa secções de detalhes e reordena tudo o que aparece abaixo do Destaque.
- Cria agrupamentos de catálogos personalizados com pastas exibidas no Início.
- INTEGRAÇÕES
- Melhora as páginas de detalhes com imagens, créditos e metadados do TMDB.
- Adiciona classificações do IMDb, Rotten Tomatoes, Metacritic e outros às páginas de detalhes.
+ Instala repositórios de scrapers em JavaScript e testa fornecedores internamente.
+ Ajusta o esquema inicial, a visibilidade do conteúdo e o comportamento dos pósteres
+ Definições para os ecrãs de detalhes e de episódios.
+ Cria agrupamentos de catálogos personalizados com pastas apresentadas no Início.
+ Integrações
+ Controlos de enriquecimento de metadados
+ Fornecedores de classificações externas
+ Liga contas para obter links e acesso à biblioteca
+ Serviços Ligados
+ Estas integrações são experimentais e podem ser mantidas, alteradas ou removidas mais tarde.
+ Biblioteca na nuvem
+ Explora e reproduz ficheiros que já se encontram nas tuas contas ligadas.
+ Resolver links reproduzíveis
+ Solicita links reproduzíveis a um serviço ligado sempre que um resultado precisar. Isto pode adicionar o item a esse serviço.
+ Resolver com
+ Escolhe qual das contas ligadas deve gerir os links reproduzíveis.
+ Liga uma conta primeiro.
+ Contas
+ Liga a tua conta %1$s.
+ Associa a tua conta %1$s no navegador.
+ Chave API do %1$s
+ Introduz a tua chave API do %1$s.
+ Introduzir chave API do %1$s
+ Não definido
+ Ligado
+ Ligar %1$s
+ Desligar %1$s
+ Desligar
+ O %1$s está ligado neste dispositivo.
+ A iniciar início de sessão seguro...
+ Abre o link e introduz este código para aprovares o Nuvio.
+ Código copiado.
+ Abrir link
+ A aguardar aprovação...
+ Não foi possível iniciar o início de sessão.
+ Este método de início de sessão não está configurado nesta versão da app.
+ Este código expirou. Tenta novamente.
+ Preparação de Links
+ Preparar links
+ Resolve links reproduzíveis antes de a reprodução começar.
+ Links a preparar
+ Usa um número mais baixo quando possível. Os serviços ligados podem limitar a taxa de links que podem ser resolvidos num determinado período de tempo. Abrir um filme ou episódio pode contar para esses limites mesmo que não carregues em Ver, porque os links são preparados antecipadamente.
+ 1 link
+ %1$d links
+ Formatação
+ Modelo de nome
+ Controla a forma como os nomes dos resultados aparecem.
+ Modelo de descrição
+ Controla os metadados apresentados sob cada resultado.
+ Repor formatação
+ Restaurar a formatação predefinida dos resultados.
+ Chave API validada.
+ Não foi possível validar esta chave API.
Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações.
- Obtém uma chave em https://mdblist.com/preferences e cola-a aqui.
+ Necessária para obter as classificações do MDBList
Chave API
- Chave API MDBList
- Ativar classificações MDBList
- Mostra classificações externas do MDBList nas páginas de metadados quando um ID IMDb está disponível.
- CHAVE API
- FORNECEDORES DE CLASSIFICAÇÃO
- MDBLIST
+ Chave API
+ Ativar classificações do MDBList
+ Obtém classificações de fornecedores externos no ecrã de detalhes dos metadados
+ Chave API
+ Fornecedores de classificações externos
+ Classificações MDBList
Ações
Controlos de reprodução e gravação.
Elenco
Lista do elenco principal.
- Fundo Cinemático
+ Fundo cinematográfico
Fundo desfocado atrás do conteúdo, semelhante ao ecrã de stream.
Coleção
- Linha de coleções ou franchises relacionados.
+ Linha de franquia ou coleção relacionada.
Comentários
- Secção de comentários do Trakt.
+ Críticas do Trakt
Detalhes
- Duração, estado, lançamento, idioma e info relacionada.
- Cartões de Episódio
+ Duração, estado, lançamento, idioma e informações relacionadas.
+ Cartões de episódios
Escolhe como os episódios são apresentados no ecrã de metadados.
Horizontal
- Cartões em linha tipo miniatura
+ Cartões em linha estilo miniatura de fundo
Lista
- Cartões empilhados com foco no detalhe
+ Cartões empilhados focados nos detalhes
Episódios
Lista de temporadas e episódios para séries.
+ Desfocar episódios não vistos
+ Desfoca as miniaturas dos episódios até serem vistos para evitar spoilers.
Grupo %1$d
Mais como este
- Linha de recomendações.
+ Miniaturas de recomendações do TMDB na página de detalhes
Nenhum
- Resumo
+ Sinopse
Sinopse, classificações, géneros e créditos principais.
Produção
- Estúdios e canais.
+ Estúdios e canais/redes.
ASPETO
SECÇÕES
- Grupo de Separadores %1$d
- Layout de Separadores
- Agrupa secções em separadores como na aplicação de TV. Atribui até 3 secções por grupo.
+ Grupo de separadores %1$d
+ Esquema de separadores
+ Agrupa as secções em separadores como na app de TV. Atribui até 3 secções por grupo de separadores.
Trailers
Linha de trailers e atalhos de reprodução.
As notificações estão atualmente desativadas no Nuvio.
Alertas de lançamento de episódios
Agenda notificações locais para quando um novo episódio de uma série guardada ficar disponível.
As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas e notificações de teste.
- %1$d alertas de lançamento agendados neste dispositivo.
+ Existem atualmente %1$d alertas de lançamento agendados neste dispositivo.
ALERTAS
TESTE
- Enviar Notificação de Teste
- A enviar Notificação de Teste...
- Enviar uma notificação de teste local para %1$s.
- Guarda primeiro uma série na tua biblioteca para testares as notificações.
+ Enviar notificação de teste
+ A enviar notificação de teste...
+ Envia uma notificação de teste local para %1$s.
+ Guarda primeiro uma série na tua biblioteca para poderes testar as notificações.
Notificação de teste
Comunidade
- Vê quem está a construir e a apoiar o Nuvio em Mobile, TV e Web.
+ Vê as pessoas que constroem e apoiam o Nuvio em versões Mobile, TV e Web.
A API de Apoiantes não está configurada. Adiciona DONATIONS_BASE_URL ao local.properties.
Colaboradores
Apoiantes
Abrir GitHub
- Perfil de GitHub indisponível
- Sem mensagem anexada.
+ Perfil do GitHub indisponível
+ Nenhuma mensagem anexada.
A carregar colaboradores...
A carregar apoiantes...
Não foi possível carregar os colaboradores
@@ -592,53 +729,64 @@
Out
Nov
Dez
- %1$s %2$s, %3$s
- Todos os Addons
- Todos os Plugins
- Addons Permitidos
- Plugins Permitidos
+ %1$s de %2$s de %3$s
+ Todos os addons instalados
+ Todos os plugins ativos
+ Addons permitidos
+ Plugins permitidos
Anime Skip
- ID de Cliente AnimeSkip
- Insere o teu ID de cliente da API AnimeSkip. Obtém um em anime-skip.com.
- Pesquisa também no AnimeSkip por marcas de tempo para saltar partes (requer ID de cliente).
- Reproduzir Próximo Episódio Automaticamente
- Procura e reproduz automaticamente o próximo episódio quando o limite for atingido.
- Apenas Dispositivo
- Preferir Aplicação (FFmpeg)
- Preferir Dispositivo
- Prioridade do Descodificador
+ ID de Cliente do AnimeSkip
+ Introduz o teu ID de cliente da API do AnimeSkip. Obtém um em anime-skip.com.
+ Ativar envio de introduções
+ Mostra um botão para enviares marcas de tempo de introduções/finais para a base de dados da comunidade.
+ Chave API do IntroDB
+ Introduz a tua chave API do IntroDB para enviares marcas de tempo. Obrigatório para a submissão.
+ Pesquisa também no AnimeSkip por marcas de tempo para saltar conteúdo (requer ID de cliente).
+ Reproduzir automaticamente o próximo episódio
+ Inicia o próximo episódio automaticamente assim que o aviso aparecer.
+ Apenas descodificadores do dispositivo
+ Preferir descodificadores da app (FFmpeg)
+ Preferir descodificadores do dispositivo
+ Prioridade do descodificador
Toca fora para fechar
Toca fora para guardar e fechar
%1$d dia
%1$d dias
%1$d hora
%1$d horas
- Ativar libass
- Usa o libass para a renderização de legendas ASS/SSA em vez do renderizador padrão.
- Manter Velocidade
- Manter para Acelerar
- Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade de reprodução.
+ Usar libass para legendas ASS/SSA
+ Experimental: renderização avançada de ASS/SSA (estilos, posicionamento, animações)
+ Leitor externo
+ App de leitor externo
+ Abre a nova reprodução com a app de vídeo predefinida do Android ou com o seletor do sistema.
+ Abre a nova reprodução com o leitor instalado selecionado.
+ Nenhum leitor externo compatível instalado
+ Velocidade ao manter premido
+ Manter premido para acelerar
+ Prime longamente em qualquer parte da superfície do leitor para aumentar temporariamente a velocidade de reprodução.
Padrão regex inválido
- Duração do Cache do Último Link
- Mapear DV7 para HEVC
- Fallback de Dolby Vision Profile 7 para HEVC para dispositivos não suportados.
- Minutos Antes do Fim
- Mostra o cartão do próximo episódio estes minutos antes do fim.
+ Duração da cache do último link
+ Alternativa DV7 para HEVC
+ Mapeia o Dolby Vision Profile 7 para HEVC padrão em dispositivos sem suporte de hardware para DV
+ Minutos de tolerância
+ Alternativa quando não existe uma marca de tempo para o final.
%1$s min
Nenhum item disponível
Não definido
- Padrão
- Idioma do Dispositivo
+ Predefinido (ficheiro de média)
+ Idioma do dispositivo
Forçadas
Nenhum
- Preferir Grupo de Maratona
- Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo de maratona da atual.
- Idioma de Áudio Preferido
- Idioma de Legendas Preferido
+ Preferir grupo de maratona (Próximo episódio)
+ Tenta primeiro o mesmo perfil de fonte (mesmo addon/grupo de qualidade) antes das regras normais de reprodução automática.
+ Reutilizar grupo de maratona
+ Lembra-te e reutiliza o último grupo de maratona entre sessões (Continuar a Ver, Detalhes, etc.).
+ Idioma de áudio preferido
+ Idioma preferido
Predefinições
- Compara com o nome da transmissão, etiqueta, descrição, addon e URL.
+ Corresponde ao nome/título/descrição do stream, addon ou URL. Exemplo: 4K|2160p|Remux
Padrão Regex
- 4K|2160p|Remux
+ Nenhum padrão definido. Exemplo: 4K|2160p|Remux
Qualquer 1080p+
AVC / x264
Qualidade BluRay
@@ -652,111 +800,133 @@
4K / Remux
720p / Menor
Fontes WEB
- Tipo de Renderização
- Padrão (Cues)
+ Modo de renderização Libass
+ Standard Cues
Effects Canvas
Effects OpenGL
Overlay Canvas
- Overlay OpenGL
- Reutilizar Último Link
- Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido.
- Idioma de Áudio Secundário
- Idioma de Legendas Secundário
+ Overlay OpenGL (Recomendado)
+ Reutilizar último link
+ Reproduz automaticamente o teu último stream funcional para este mesmo filme/episódio enquanto a cache for válida
+ Idioma de áudio secundário
+ Segundo idioma preferido
DESCODIFICADOR
PRÓXIMO EPISÓDIO
- REPRODUTOR
+ LEITOR
SALTAR SEGMENTOS
- REPRODUÇÃO AUTOMÁTICA
- SELEÇÃO DE TRANSMISSÃO
+ REPRODUÇÃO AUTOMÁTICA DE STREAM
+ SELEÇÃO DE STREAM
LEGENDAS E ÁUDIO
RENDERIZAÇÃO DE LEGENDAS
%1$d selecionados
- Mostrar Sobreposição de Carregamento
- Mostra a sobreposição inicial enquanto uma transmissão começa a ser reproduzida.
- Saltar Introdução/Créditos/Resumo
- Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo.
- Âmbito da Fonte
- Todos os Addons
- Considera transmissões de todos os addons instalados.
- Todas as Fontes
- Considera transmissões de addons e plugins.
- Apenas Plugins Ativos
- Considera apenas transmissões de plugins ativos.
- Apenas Addons Instalados
- Considera apenas transmissões de addons instalados.
- Modo de Seleção de Transmissão
- Primeira Transmissão Disponível
- Reproduz automaticamente a primeira transmissão encontrada.
- Manual
- Seleciona as transmissões manualmente de cada vez.
- Correspondência Regex
- Seleciona automaticamente uma transmissão que corresponda a um padrão regex.
- Tempo Limite da Transmissão
- Quanto tempo esperar pelas transmissões antes da seleção automática.
- Minutos Antes do Fim
- Modo de Limite
- Minutos Antes do Fim
+ Sobreposição de carregamento
+ Mostra o ecrã de carregamento até que surja o primeiro fotograma do vídeo.
+ Saltar introdução
+ Usa o introdb.app para detetar introduções e resumos.
+ Âmbito da reprodução automática
+ Todos os addons instalados
+ A reprodução automática apenas considera streams provenientes dos teus addons instalados.
+ Todas as fontes
+ A reprodução automática pode usar tanto os addons instalados como os plugins ativos.
+ Apenas plugins ativos
+ A reprodução automática apenas considera streams provenientes de plugins ativos.
+ Apenas addons instalados
+ A reprodução automática apenas considera streams provenientes dos teus addons instalados.
+ Seleção automática de stream
+ Reproduzir automaticamente a primeira fonte
+ Reproduz automaticamente a primeira fonte disponível.
+ Manual (escolher stream)
+ Mostra sempre a lista de fontes e deixa-me escolher.
+ Reprodução automática por correspondência regex
+ Reproduz a primeira fonte cujo texto corresponda ao teu padrão regex.
+ Tempo limite de seleção de stream
+ Tempo de espera pelos addons antes de efetuar a seleção.
+ Minutos de tolerância
+ Modo de tolerância do próximo episódio
+ Minutos antes do fim
Percentagem
- Percentagem Limite
- Mostra o cartão do próximo episódio quando a reprodução atingir esta percentagem.
- %1$s%
+ Percentagem de tolerância
+ Alternativa quando não existe uma marca de tempo para o final.
+ %1$s%%
Instantâneo
%1$ss
Ilimitado
- Reprodução em Túnel
- Ativa a reprodução em túnel para menor latência na sincronização de áudio/vídeo.
- Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento de dados.
- Chave API TMDB
- Ativar enriquecimento TMDB
- Usa a tua chave API do TMDB para enriquecer os metadados do addon no ecrã de detalhes quando um ID TMDB ou IMDb está disponível.
- Insere a tua chave API TMDB v3.
- Código de idioma
- Imagens (Artwork)
- Substitui o fundo, o póster e o logótipo por imagens do TMDB.
+ Reprodução em túnel
+ Sincronização de áudio/vídeo ao nível do hardware. Pode melhorar a reprodução em alguns dispositivos Android TV
+ Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento.
+ Chave API
+ Ativar enriquecimento do TMDB
+ Usa o TMDB como fonte de metadados para melhorar os dados dos addons
+ Introduz a tua chave API v3 do TMDB.
+ Código do idioma
+ Imagens artísticas
+ Imagens de logótipos e de fundo do TMDB
Informação básica
- Usa o título, sinopse, géneros e classificação do TMDB.
+ Descrição, géneros e classificação do TMDB
Coleções
- Mostra linhas de franchises e coleções para filmes quando o TMDB as fornece.
+ Coleções de filmes do TMDB por ordem de lançamento
Créditos
- Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB.
+ Elenco com fotos, realizador e guionista do TMDB
Detalhes
- Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB.
+ Duração, estado, país e idioma do TMDB
Episódios
- Usa títulos de episódios, miniaturas, descrições e durações do TMDB para séries.
+ Títulos dos episódios, sinopses, miniaturas e duração do TMDB
Mais como este
- Mostra recomendações do TMDB no fundo das páginas de detalhes.
- Canais (Networks)
- Usa metadados de canais do TMDB para títulos de TV.
+ Miniaturas de recomendações do TMDB na página de detalhes
+ Canais / Redes
+ Canais com logótipos do TMDB
Produtoras
- Usa metadados de produtoras do TMDB no ecrã de detalhes.
- Pósteres de temporadas
- Usa pósteres de temporadas do TMDB no seletor de temporadas do ecrã de metadados.
+ Empresas produtoras do TMDB
+ Pósteres das temporadas
+ Usa os pósteres das temporadas do TMDB no seletor de temporadas do ecrã de metadados das séries.
Trailers
- Obtém e mostra a secção de trailers de vídeo do TMDB nas páginas de detalhes.
+ Trailers candidatos a partir de vídeos do TMDB para a secção de trailers dos detalhes
Chave API pessoal
- Idioma preferido
- Define o código de idioma do TMDB para metadados localizados, por exemplo: `pt-PT`, `en-GB` ou `en-US`.
+ Idioma
+ Idioma dos metadados do TMDB para o título, logótipo e campos ativos
CREDENCIAIS
LOCALIZAÇÃO
MÓDULOS
- TMDB
+ Enriquecimento TMDB
Após a aprovação, serás redirecionado de volta automaticamente.
AUTENTICAÇÃO
Comentários
- Mostra comentários do Trakt nos detalhes de filmes e séries
+ Mostra críticas do Trakt nas páginas de metadados
Ligar Trakt
Ligado como %1$s
- Utilizador Trakt
+ Utilizador do Trakt
Desligar
Falha ao abrir o navegador
FUNCIONALIDADES
Conclui o início de sessão do Trakt no teu navegador
- Regista o que vês, guarda na lista de interesses ou em listas personalizadas e mantém a tua biblioteca sincronizada com o Trakt.
- Faltam credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
- Abrir Login do Trakt
- As tuas ações de Guardar podem agora visar a lista de interesses e listas pessoais do Trakt.
- Inicia sessão com o Trakt para ativares o salvamento em listas e o modo de biblioteca Trakt.
- Pontuação do Público
+ Sincroniza a tua lista de conteúdos, o progresso de visualização, o Continuar a Ver, os scrobbles e as listas pessoais com o Trakt.
+ Faltam as credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
+ Abrir início de sessão do Trakt
+ As tuas ações de Guardar podem agora visar a lista de conteúdos do Trakt e as listas pessoais.
+ Inicia sessão com o Trakt para ativares a gravação baseada em listas e o modo de biblioteca do Trakt.
+ Fonte da biblioteca
+ Escolhe que biblioteca usar para guardar e ver a tua coleção
+ Fonte da biblioteca
+ Escolhe onde guardar e gerir os itens da tua biblioteca
+ Trakt
+ Biblioteca Nuvio
+ Biblioteca do Trakt selecionada
+ Biblioteca do Nuvio selecionada
+ Progresso de visualização
+ Escolhe qual a fonte de progresso que alimenta o retomar e o continuar a ver
+ Progresso de visualização
+ Escolhe se o retomar e o continuar a ver devem usar o Trakt ou o Nuvio Sync enquanto o scrobbling do Trakt permanece ativo.
+ Trakt
+ Nuvio Sync
+ Fonte de progresso de visualização definida para Trakt
+ Fonte de progresso de visualização definida para Nuvio Sync
+ Janela de Continuar a Ver
+ Histórico do Trakt considerado para o continuar a ver
+ Janela de Continuar a Ver
+ Escolhe quanta atividade do Trakt deve aparecer no continuar a ver.
+ Todo o histórico
+ %1$d dias
+ Classificação do público
IMDb
Letterboxd
Metacritic
@@ -771,15 +941,15 @@
Rosa
Violeta
Branco
- Próximo Episódio
+ Próximo episódio
A procurar fonte…
A reproduzir via %1$s em %2$d…
Miniatura do próximo episódio
Não emitido
Saltar
- Saltar Introdução
- Saltar Créditos
- Saltar Resumo
+ Saltar introdução
+ Saltar encerramento
+ Saltar resumo
Nenhuma legenda encontrada
Africânder
Albanês
@@ -823,16 +993,16 @@
Cazaque
Khmer
Coreano
- Lao
+ Laosiano
Letão
Lituano
Macedónio
Malaio
- Malaiala
+ Malayalam
Maltês
Marata
Mongol
- Nepalês
+ Nepali
Norueguês
Persa
Polaco
@@ -867,138 +1037,154 @@
Não
Atualizar
Sim
- Queres sair da aplicação?
- Sair da aplicação
+ Queres sair da app?
+ Sair da app
Este catálogo não devolveu nenhum item.
Nenhum título encontrado
- Verifica a tua ligação Wi-Fi ou dados móveis e tenta novamente.
+ Verifica a tua ligação Wi-Fi ou de dados móveis e tenta novamente.
Realizador
Falha ao carregar
- Mais Como Este
+ Mais como este
Temporadas
Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio.
Este addon não forneceu metadados de episódios para esta série.
Os episódios ainda não foram publicados por este addon.
O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários.
- Mostrar Menos
- Mostrar Mais ▾
- Argumentista
- Todos os Géneros
+ Mostrar menos
+ Mostrar mais ▾
+ Guionista
+ Todos os géneros
Catálogo
%1$s • %2$s
- O catálogo selecionado falhou ao devolver itens de descoberta.
- Não foi possível carregar a descoberta
- Os addons instalados não expõem catálogos compatíveis com o painel de descoberta.
- Sem catálogos de descoberta
+ O catálogo selecionado falhou ao devolver itens de exploração.
+ Não foi possível carregar a exploração
+ Os addons instalados não expõem catálogos compatíveis com o painel para exploração.
+ Sem catálogos de exploração
O catálogo e os filtros selecionados não devolveram nenhum item.
Nenhum título encontrado
- Instala e valida pelo menos um addon antes de navegar nos catálogos de descoberta.
- Selecionar Catálogo
- Selecionar Género
- Selecionar Tipo
+ Instala e valida pelo menos um addon antes de navegares pelos catálogos de exploração.
+ Selecionar catálogo
+ Selecionar género
+ Selecionar tipo
Tipo
Marcar anteriores como não vistos
Marcar anteriores como vistos
Marcar %1$s como não vista
Marcar %1$s como vista
+ Marcar temporadas anteriores como vistas
Marcar como não visto
Marcar como visto
A seguir
- %1$s visto
- Instala e valida pelo menos um addon antes de carregar as linhas de catálogo no Início.
- Os addons instalados não expõem atualmente catálogos compatíveis sem extras obrigatórios.
- Sem linhas disponíveis no início
- Ver Detalhes
+ %1$s visto(s)
+ Instala e valida pelo menos um addon antes de carregares as linhas de catálogo na Página Inicial.
+ Os addons instalados não expõem atualmente catálogos compatíveis com o painel sem extras obrigatórios.
+ Nenhuma linha disponível na página inicial
+ Ver detalhes
Controlos de reprodução e gravação.
Ações
Lista do elenco principal.
- Linha de coleção ou franchise relacionada.
+ Linha de franquia ou coleção relacionada.
Coleção
Secção de comentários do Trakt.
- Duração, estado, lançamento, idioma e info relacionada.
+ Duração, estado, lançamento, idioma e informações relacionadas.
Detalhes
Lista de temporadas e episódios para séries.
Linha de recomendações.
- Mais Como Este
+ Mais como este
Sinopse, classificações, géneros e créditos principais.
- Resumo
- Estúdios e canais.
+ Sinopse
+ Estúdios e canais/redes.
Produção
Linha de trailers e atalhos de reprodução.
Novamente online
- Não foi possível contactar os servidores
- Sem ligação à Internet
+ Não é possível contactar os servidores
+ Sem ligação à internet
(%1$d anos)
Nascimento: %1$s%2$s
Falecimento: %1$s
- Conhecido por: %1$s
- Mais Recente
- Não foi possível carregar detalhes de %1$s
+ Conhecido(a) por: %1$s
+ Mais recente
+ Não foi possível carregar os detalhes de %1$s
Popular
Algo correu mal
Brevemente
- Retroceder
+ Apagar
Cancelar
Introduzir PIN
Introduzir PIN de %1$s
Esqueceste-te do PIN?
- PIN Incorreto
+ PIN incorreto
Bloqueado. Tenta novamente em %1$ds
- As opções de avatar aparecerão aqui quando o catálogo carregar.
+ As opções de avatar vão aparecer aqui quando o catálogo carregar.
Avatar: %1$s
+ Introduz um URL de imagem http:// ou https:// válido.
Escolhe um avatar
Escolhe um avatar abaixo.
Criar Perfil
- Todos os dados de \"%1$s\" serão eliminados permanentemente.
- Eliminar Perfil
- Adicionar Perfil
- Editar Perfil
+ URL de avatar personalizado selecionado.
+ URL de avatar personalizado
+ Cola um link de imagem, ou deixa em branco para usar o catálogo de avatares integrado.
+ https://exemplo.com/avatar.png
+ Todos os dados de "%1$s" serão eliminados permanentemente.
+ Eliminar perfil
+ Adicionar perfil
+ Editar perfil
Introduz o PIN atual
Introduz o novo PIN
Perfil %1$d
A carregar avatares...
- Gerir Perfis
+ Gerir perfis
Nome do perfil
Novo perfil
Addons principais desativados
Addons principais ativos
Remover PIN de %1$s
- Remover Bloqueio por PIN
+ Remover bloqueio por PIN
A guardar...
Segurança
- Adiciona um PIN se quiseres bloquear este perfil antes de alternares para ele.
+ Adiciona un PIN se queres que este perfil seja bloqueado antes de mudares para ele.
Este perfil está protegido com um PIN.
Seleciona um avatar para este perfil.
- Definir Bloqueio por PIN
+ Definir bloqueio por PIN
Perfil sem nome
- Usar Addons Principais
- Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada.
+ Usar addons principais
+ Partilha a configuração de addons do perfil principal em vez de gerires uma lista separada.
Quem está a ver?
Transferido
Retomar
Scrapers ativos
A verificar mais addons…
- Copiar link da transmissão
+ Copiar link do stream
Transferir ficheiro
- Os addons de transmissão instalados falharam ao devolver uma resposta válida.
- Não foi possível carregar as transmissões
- Instala primeiro um addon para carregar transmissões para este título.
- Os teus addons instalados não fornecem transmissões para este tipo de título.
- Nenhum addon de transmissão disponível
- Nenhum dos teus addons instalados devolveu transmissões para este título.
+ Abrir num leitor externo
+ Abrir no leitor interno
+ Os addons de stream instalados falharam ao devolver uma resposta de stream válida.
+ Não foi possível carregar os streams
+ Instala um addon primeiro para carregares streams para este título.
+ Os teus addons instalados não fornecem streams para este tipo de título.
+ Nenhum addon de stream disponível
+ Nenhum dos teus addons instalados devolveu streams para este título.
T%1$d E%2$d
Episódio
T%1$dE%2$d - %3$s
A obter…
A procurar fonte…
- A procurar transmissões…
- Link da transmissão copiado
- Nenhum link direto de transmissão disponível
+ A procurar streams…
+ Link do stream copiado
+ Nenhum link de stream direto disponível
Nenhum metadado disponível
- Atualizar transmissões
- Retomar de %1$d%%
- Retomar de %1$s
+ Atualizar streams
+ Retomar a partir de %1$d%%
+ Retomar a partir de %1$s
TAMANHO %1$s
+ Este tipo de stream não é suportado
+ Liga uma conta nas Definições.
+ Não guardado em cache no Torbox.
+ Este link expirou. A atualizar os resultados.
+ Não foi possível abrir este link.
+ Não foi possível abrir o leitor externo
+ Escolhe primeiro um leitor externo nas definições
+ Nenhum leitor externo disponível
Fechar trailer
Não foi possível reproduzir o trailer
Falha ao carregar as listas do Trakt
@@ -1009,12 +1195,12 @@
A transferir %1$d%%
Não foi possível iniciar a instalação
Estás a usar a versão mais recente.
- Ativa a instalação de aplicações para o Nuvio, depois volta e continua.
+ Ativa as instalações de apps para o Nuvio, depois volta e continua.
A transferir atualização...
Nenhuma atualização encontrada.
- Uma nova versão está pronta para instalar.
- As atualizações na aplicação não estão disponíveis nesta versão (build).
- A preparar a transferência
+ Uma nova versão está pronta para ser instalada.
+ As atualizações na app não estão disponíveis nesta compilação.
+ A preparar transferência
Notas de lançamento
Permitir que as instalações continuem
Atualização disponível
@@ -1023,20 +1209,20 @@
Introduz um URL de addon válido
Não foi possível carregar o manifesto
Nuvio
- Falha ao eliminar conta
+ Falha ao eliminar a conta
Falha ao iniciar sessão
Falha ao terminar sessão
Falha no registo
Não foi possível carregar os itens do catálogo.
A seguir
A seguir • T%1$dE%2$d
- Logótipo %1$s
- Falha ao carregar comentários
- Não foi possível carregar detalhes de nenhum addon.
+ Logótipo de %1$s
+ Falha ao carregar os comentários
+ Não foi possível carregar os detalhes de nenhum addon.
Canais/Redes
Nenhum addon fornece metadados para este conteúdo.
Falha na transferência
- Mostra o progresso e controlos das transferências em direto.
+ Mostra o progresso e os controlos de transferências ao vivo.
Transferências
Transferência concluída
A transferir %1$s • %2$s
@@ -1044,37 +1230,38 @@
Falha na transferência
Pausado %1$s
Remover
+ Remover %1$s de %2$s?
Remover %1$s da tua biblioteca?
- Remover da Biblioteca?
+ Remover da biblioteca?
Filme
- Alertas quando um novo episódio de uma série guardada é lançado.
+ Alerta quando é lançado um novo episódio de uma série guardada.
Pré-visualização do alerta de lançamento de episódio.
Falha ao enviar uma notificação de teste.
Notificação de teste enviada para %1$s.
- Não foi possível reproduzir esta transmissão.
- O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo.
- Não foi possível remover o PIN. Tenta novamente.
- Liga-te à Internet para remover o bloqueio por PIN.
- Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro.
+ Não foi possível reproduzir este stream.
+ O PIN deste perfil foi alterado. Liga-te à internet uma vez para atualizar o bloqueio neste dispositivo.
+ Não foi possível remover o bloqueio por PIN. Tenta novamente.
+ Liga-te à internet para remover o bloqueio por PIN.
+ Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te primeiro à internet e desbloqueia-o online.
Não foi possível definir o PIN. Tenta novamente.
- Liga-te à Internet para definir um PIN.
- Este perfil utiliza addons principais.
+ Liga-te à internet para definir um PIN.
+ Este perfil usa os addons principais.
Falha ao carregar %1$s
- Transmissão
- Incorporado
- Autorização negada
- Conclui o início de sessão no Trakt no teu browser
+ Stream
+ Integrado
+ Autorização recusada
+ Conclui o início de sessão do Trakt no teu navegador
Callback do Trakt inválido
Estado de callback do Trakt inválido
Resposta de token do Trakt inválida
Falha ao carregar a biblioteca do Trakt
Lista %1$d
O Trakt não devolveu um código de autorização
- Credenciais do Trakt em falta
+ Faltam as credenciais do Trakt
Falha ao carregar o progresso do Trakt
- Falha ao concluir o início de sessão no Trakt
- Utilizador Trakt
- Lista de Interesses
+ Falha ao concluir o início de sessão do Trakt
+ Utilizador do Trakt
+ Lista de conteúdos
Trailer
Desconhecido
Addon
@@ -1082,11 +1269,12 @@
Reproduzir %1$s
Retomar %1$s
O JSON está vazio.
- A coleção %1$d tem um ID em branco.
- A coleção \'%1$s\' tem um título em branco.
- A pasta %1$d em \'%2$s\' tem um ID em branco.
- A pasta \'%1$s\' em \'%2$s\' tem um título em branco.
- A fonte %1$d na pasta \'%2$s\' tem campos em branco.
+ A coleção %1$d tem o ID em branco.
+ A coleção '%1$s' tem o título em branco.
+ A pasta %1$d em '%2$s' tem o ID em branco.
+ A pasta '%1$s' em '%2$s' tem o título em branco.
+ A fonte %1$d na pasta '%2$s' tem campos em branco.
+ A fonte %1$d na pasta '%2$s' não tem um ID de lista do Trakt.
JSON inválido: %1$s
Addon não encontrado: %1$s
Janeiro
@@ -1114,78 +1302,115 @@
Nov
Dez
Produtora
- Canal
+ Canal/Rede
Não foi possível carregar %1$s
Popular
- Recente
+ Recentes
%1$s • %2$s
- Melhor Classificados
- Classificação Etária
- Detalhes do Filme
- Idioma Original
- País de Origem
- Info de Lançamento
+ Mais votados
+ Classificação etária
+ Detalhes do filme
+ Idioma original
+ País de origem
+ Informações de lançamento
Duração
Pósteres
Texto
- Detalhes da Série
+ Detalhes da série
Estado
Vídeos
- FICH.
- Nenhum link direto de transmissão disponível
- Transferência anterior substituída
+ FICHEIRO
+ Nenhum link de stream direto disponível
+ Substituiu a transferência anterior
Transferência iniciada
- Formato de transmissão não suportado para transferências
- Corpo da resposta vazio
- Pedido falhou com HTTP %1$d
- O sistema de transferências não foi inicializado
- Pedido de transferência falhou
+ Formato de stream não suportado para transferências
+ Corpo de resposta vazio
+ A solicitação falhou com HTTP %1$d
+ O sistema de transferências não está inicializado
+ Falha na solicitação de transferência
%1$s - %2$s
- Os títulos guardados aparecerão aqui depois de tocares em Guardar no ecrã de detalhes.
+ Os títulos guardados vão aparecer aqui depois de clicares em Guardar no ecrã de detalhes.
A tua biblioteca está vazia
Não foi possível carregar a biblioteca
Outro
+ Nuven
+ Guardado
Biblioteca
- Liga-te ao Trakt e guarda títulos na tua lista de interesses ou listas pessoais.
+ Liga o Trakt e guarda títulos na tua lista de conteúdos ou listas pessoais.
A tua biblioteca do Trakt está vazia
Não foi possível carregar a biblioteca do Trakt
- Biblioteca Trakt
+ Biblioteca do Trakt
+ Ligar conta
+ Liga uma conta nas definições de Serviços Ligados para navegares pelos ficheiros reproduzíveis da tua biblioteca na nuvem.
+ Nenhuma conta na nuvem ligada
+ Abrir Serviços Ligados
+ Ativa a biblioteca na Nuvem nas definições de Serviços Ligados para navegares pelos ficheiros das contas ligadas.
+ A biblioteca na nuvem está desativada
+ Nenhum ficheiro na nuvem reproduzível corresponde aos filtros atuais.
+ Ainda não há nada aqui
+ Escolhe um ficheiro para reproduzir
+ Não foi possível carregar a biblioteca na nuvem de %1$s
+ Este item não disponibiliza um ficheiro de vídeo reproduzível.
+ Sem ficheiros reproduzíveis
+ Sem ficheiros reproduzíveis
+ A biblioteca na nuvem está desativada.
+ Não foi possível reproduzir este ficheiro na nuvem.
+ Reproduzir ficheiro
+ O serviço na nuvem não está ligado.
+ %1$s não está ligado.
+ %1$d ficheiros reproduzíveis
+ Todos
+ Atualizar biblioteca na nuvem
+ Selecionar fornecedor
+ Selecionar tipo
+ Pronto para reproduzir
+ Todos
+ Torrents
+ Usenet
+ Web
+ Ficheiros
Anime
Canais
Filmes
Séries
Televisão
- %1$s lançado(s)
- %1$s • %2$s já lançado(s)
- Um novo episódio lançado
- %1$s já lançado(s)
- Lançamentos de Episódios
- Criador
- Realizador
- Argumentista
- Pontuação do Público
- Nenhuma transmissão de trailer reproduzível encontrada.
+ %1$s já saiu
+ %1$s • %2$s já saiu
+ Um novo episódio já saiu
+ %1$s já saiu
+ Lançamentos de episódios
+ Álcool/Drogas
+ Assustador
+ Nudez
+ Linguagem obscena
+ Ligeiro
+ Moderado
+ Grave
+ Violência
+ Criador(a)
+ Realizador(a)
+ Guionista
+ Classificação do público
+ Nenhum stream de trailer reproduzível encontrado.
Temporada %1$d - %2$s
B
KB
MB
GB
- Disponível em %1$s
- Disponível hoje
- Disponível amanhã
+ Emitido a %1$s
+ Emitido hoje
+ Emitido amanhã
- - Disponível em %1$d dia
- - Disponível em %1$d dias
- - Disponível em %1$d dias
+ - Emitido em %1$d dia
+ - Emitido em %1$d dias
%1$s
Hoje
Amanhã
- Em %1$d dia
- - Em %1$d dias
- Em %1$d dias
- Novo Episódio
- Nova Temporada
+ Novo episódio
+ Nova temporada
From 902b59cc8875a8994366c30c71b35f5e3fa8173a Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 25 May 2026 18:21:50 +0530
Subject: [PATCH 04/12] fix: entry handling in TraktProgressRepository
---
.../com/nuvio/app/features/trakt/TraktProgressRepository.kt | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
index 5afd6d1a6..737490910 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
@@ -638,7 +638,9 @@ object TraktProgressRepository {
shouldStop = true
continue
}
- resultsByShow.putIfAbsent(entry.parentMetaId, entry)
+ if (!resultsByShow.containsKey(entry.parentMetaId)) {
+ resultsByShow[entry.parentMetaId] = entry
+ }
if (resultsByShow.size >= MAX_RECENT_EPISODE_HISTORY_ENTRIES) {
shouldStop = true
break
From 6ac4920b31cd5073a3c9652da051252f24e1f583 Mon Sep 17 00:00:00 2001
From: halibiram <94225244+halibiram@users.noreply.github.com>
Date: Tue, 26 May 2026 11:20:29 +0300
Subject: [PATCH 05/12] feat(i18n): add missing Turkish translations in
conversational style
---
.../composeResources/values-tr/strings.xml | 441 ++++++++++++++++--
1 file changed, 399 insertions(+), 42 deletions(-)
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index 308c234ca..9d54c44b1 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -1,4 +1,5 @@
+ Veri kaynakları, teşekkürler ve platform lisansları
Açık teşekkürler ve proje katkıları
Geri
Vazgeç
@@ -17,23 +18,26 @@
Devam et
Tekrar dene
Kaydet
+ Kaydediliyor...
+ Doğrula
Kuruluyor
Eklentiler
Aktif
%1$d katalog
Ayarlanabilir
+ Devre dışı
Yenileniyor
%1$d kaynak
Kullanılamıyor
Eklentiyi ayarla
Eklentiyi sil
- Nuvio\'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL\'si ekle.
+ Nuvio'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL'si ekle.
Henüz eklenti kurulmamış.
- Bir eklenti URL\'si gir.
- Eklenti URL\'si
+ Bir eklenti URL'si gir.
+ Eklenti URL'si
Eklentiyi kur
Manifest detayları yükleniyor...
- Kurulumdan önce manifest URL\'si doğrulanıyor ve eklenti detayları yükleniyor.
+ Kurulumdan önce manifest URL'si doğrulanıyor ve eklenti detayları yükleniyor.
Eklenti kontrol ediliyor
Kurulum olmadı
%1$s doğrulandı ve başarıyla eklendi.
@@ -50,7 +54,7 @@
%1$d kimlik kuralı
Sürüm %1$s
Seçili
- JSON\'u kopyala
+ JSON'u kopyala
%1$d koleksiyon, %2$d klasör
"%1$s" silinsin mi? Bu işlem geri alınamaz.
Koleksiyonu sil
@@ -61,7 +65,7 @@
Henüz katalog kaynağı yok
Seç
Emoji
- Görsel URL\'si
+ Görsel URL'si
Yok
Kapak
Koleksiyon oluştur
@@ -78,9 +82,9 @@
Yeni klasör
Bu koleksiyonu ana sayfadaki normal katalogların üstünde göster. Birden fazla sabit koleksiyon, oluşturulma sırasına göre dizilir.
Katalogların üstüne sabitle
- Arka plan görseli URL\'si (isteğe bağlı)
+ Arka plan görseli URL'si (isteğe bağlı)
Klasör adı
- Animasyonlu GIF URL\'si (yalnızca odaktayken oynar)
+ Animasyonlu GIF URL'si (yalnızca odaktayken oynar)
Koleksiyon adı
Değişiklikleri kaydet
Kaydet
@@ -90,18 +94,182 @@
Bu klasörün toplayacağı eklenti kataloglarını seç.
Katalogları seç
Tür seç
+ %1$d seçildi
+ %1$d katalog
+ %1$d seçildi
Poster
Kare
Geniş
Tüm katalogları tek sekmede birleştir
"Tümü" sekmesini göster
- Varsa statik kapak yerine ayarlanan GIF\'i oynat.
- Ayarlanmışsa GIF\'i göster
+ Varsa statik kapak yerine ayarlanan GIF'i oynat.
+ Ayarlanmışsa GIF'i göster
%1$d kaynak · %2$s
Kart şekli
Satırlar
Sekmeler
Görünüm modu
+ TMDB Kaynakları
+ Açık Liste
+ Yapım Şirketi
+ Kanal/Platform
+ Koleksiyon
+ Kişi
+ Yönetmen
+ Özel
+ Hazır bir kaynak seç. Ekledikten sonra düzenleyebilir veya kaldırabilirsin.
+ Açık bir TMDB liste URL'sini veya sadece URL'deki numarayı yapıştır.
+ Stüdyo adına göre ara ya da bir TMDB şirket ID'si/URL'si yapıştırıp doğrudan ekle.
+ Bir kanal/platform ID'si gir. Yaygın kanallar Hazır Ayarlar'da ve hızlı filtrelerde var.
+ Bir film koleksiyonu adı ara ya da TMDB'deki koleksiyon ID'sini yapıştır.
+ Oyuncu kadrosuna göre bir satır oluşturmak için TMDB kişi ID'si veya URL'si gir.
+ Yönetmen kadrosuna göre bir satır oluşturmak için TMDB kişi ID'si veya URL'si gir.
+ İsteğe bağlı filtrelerle canlı bir TMDB satırı oluştur. Filtreye ihtiyacın yoksa alanları boş bırak.
+ Açık TMDB listesi
+ Kanal ID'si
+ Koleksiyon ID'si
+ Kişi ID'si
+ Yapım şirketi adı, ID'si ya da URL'si
+ TMDB ID veya URL'si
+ https://www.themoviedb.org/list/8504994 veya 8504994
+ Netflix için 213, HBO için 49, Disney+ için 2739
+ Star Wars Koleksiyonu için 10
+ Marvel Studios, 420 veya şirket URL'si
+ 31 için Tom Hanks veya kişi URL'si
+ Örnekler: Marvel Studios, 420 veya https://www.themoviedb.org/company/420
+ Örnek: Star Wars Koleksiyonu, Harry Potter Koleksiyonu veya koleksiyon URL'si
+ Örnek ID'ler: Netflix 213, HBO 49, Disney+ 2739
+ Örnek: https://www.themoviedb.org/list/8504994 veya 8504994
+ Örnek: https://www.themoviedb.org/person/31-tom-hanks veya 31
+ Görünen başlık
+ Satır/sekme adı olarak gösterilir. Boş bırakılırsa, Nuvio kaynaktan bir tane oluşturur.
+ Marvel Filmleri, Netflix Orijinalleri, Pixar
+ Tom Hanks Filmleri, En Sevilen Oyuncular
+ Christopher Nolan Filmleri, En Sevilen Yönetmenler
+ En İyi Aksiyon Filmleri, Kore Dizileri, 2024 Animasyonları
+ Arama Sonuçları
+ TMDB Koleksiyonu
+ TMDB Şirketi %1$d
+ TMDB Koleksiyonu %1$d
+ Tür
+ Filmler
+ Diziler
+ Her ikisi de
+ Sırala
+ Filtreler
+ Filtreye ihtiyacın yoksa alanları boş bırak.
+ Hızlı türler
+ Hızlı diller
+ Hızlı ülkeler
+ Hızlı anahtar kelimeler
+ Hızlı stüdyolar
+ Hızlı kanallar
+ Tür ID'leri
+ TMDB tür numaralarını kullan. Birden fazlasını ve (AND) için virgülle, veya (OR) için dik çizgiyle (|) ayır.
+ İlk yayın tarihi (başlangıç)
+ İlk yayın tarihi (bitiş)
+ YYYY-AA-GG formatında kullan, örneğin 2024-01-01.
+ En düşük puan
+ En yüksek puan
+ 0 ile 10 arası TMDB puanı. Örnek: 7.0
+ En az oy sayısı
+ Az bilinen az oylu içerikleri gizlemek için kullan. Örnek: 100
+ Orijinal dil
+ İki harfli dil kodlarını kullan, örneğin en, ko, ja, hi.
+ Köken ülke
+ İki harfli ülke kodlarını kullan, örneğin US, KR, JP, IN.
+ Anahtar kelime ID'leri
+ TMDB anahtar kelime numaralarını kullan. Hızlı seçimler yaygın olanları doldurur.
+ Süper kahraman için 9715
+ Şirket ID'leri
+ Stüdyo/şirket ID'lerini kullan. Hızlı seçimler yaygın olanları doldurur.
+ Marvel Studios için 420
+ Kanal ID'leri
+ Sadece diziler için. Netflix için 213 veya HBO için 49 gibi kanal ID'leri kullan.
+ Netflix için 213
+ Yıl
+ Dört haneli yıl gir, örneğin 2024.
+ Hazır Ayarlar
+ Ara
+ Kaynak Ekle
+ Trakt Listesi Ekle
+ Trakt Listesini Düzenle
+ Trakt Listeleri
+ Trakt listesi
+ Başlık, Trakt URL'si veya liste ID'si ara
+ Açık bir Trakt liste URL'si, sayısal liste ID'si kullan ya da isme göre ara.
+ Hafta Sonu İzleme Listesi, Ödüllü Yapımlar
+ Arama Sonuçları
+ Trend Listeler
+ Popüler Listeler
+ Yön
+ Artan
+ Azalan
+ Liste Sırası
+ Son Eklenenler
+ Başlık
+ Yayınlanma Tarihi
+ Süre
+ Popülerlik
+ Beğeni Oranı
+ Oy Sayısı
+ Aksiyon
+ Macera
+ Animasyon
+ Komedi
+ Korku
+ Bilim Kurgu
+ Drama
+ Suç
+ Reality
+ İngilizce
+ Korece
+ Japonca
+ Hintçe
+ İspanyolca
+ Amerika Birleşik Devletleri
+ Güney Kore
+ Japonya
+ Hindistan
+ Birleşik Krallık
+ Süper Kahraman
+ Romandan Uyarlama
+ Zaman Yolculuğu
+ Uzay
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Orijinal
+ Popüler
+ En Yüksek Puanlı
+ Yeni
+ En Çok Oylanan
+ İzleme bölgesi
+ İçeriğin sunulduğu ülkenin ISO 3166-1 ülke kodu. Örnek: TR, US
+ Hızlı izleme bölgeleri
+ Yayın sağlayıcı ID'leri
+ TMDB yayın sağlayıcı ID'lerini kullan. Birden fazlasını ve (AND) için virgülle, veya (OR) için dik çizgiyle (|) ayır.
+ 8|337|350
+ Hızlı yayın sağlayıcıları
+ Netflix
+ Prime Video
+ Disney+
+ Apple TV+
+ Hulu
+ TMDB Listesi
+ TMDB Film Koleksiyonu
+ Yapım Şirketi
+ Kanal/Platform
+ Kişi
+ Yönetmen
+ TMDB Keşfet
Kataloglarını düzenlemek için bir tane oluştur.
Henüz koleksiyon yok
%1$d klasör
@@ -110,7 +278,7 @@
Koleksiyonlar
Koleksiyonları içe aktar
JSON
- Koleksiyon JSON\'unu aşağıya yapıştır.
+ Koleksiyon JSON'unu aşağıya yapıştır.
İçe aktar
Yeni koleksiyon
Sabitlendi
@@ -213,8 +381,10 @@
Görünüm
İçerik & Keşif
İzlemeye devam
+ Bağlı Servisler
Ana ekran
Entegrasyonlar
+ Lisanslar & Teşekkürler
MDBList puanları
Detay ekranı
Bildirimler
@@ -239,9 +409,38 @@
Bölüm yayın bildirimlerini yönet ve test bildirimi gönder.
Farklı bir profile geç.
Profil değiştir
- Trakt\'ı bağla, izleme listelerini eşitle ve içerikleri doğrudan Trakt\'a kaydet.
+ Trakt'ı bağla, izleme listelerini eşitle ve içerikleri doğrudan Trakt'a kaydet.
+ Ayar bulunamadı.
+ Ayarlarda ara...
+ SONUÇLAR
+ UYGULAMA LİSANSI
+ VERİ & SERVİSLER
+ OYNATMA LİSANSI
+ Nuvio Mobil
+ Kaynak kodları ve lisans koşulları proje deposunda bulunabilir.
+ GNU Genel Kamu Lisansı v3.0 kapsamında lisanslanmıştır.
+ The Movie Database (TMDB)
+ Nuvio; film ve TV meta verileri, görseller, fragmanlar, ekip bilgileri, yapım detayları, koleksiyonlar ve öneriler için TMDB API'sini kullanır. Bu ürün TMDB API'sini kullanmaktadır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.
+ IMDb Ticari Olmayan Veri Setleri
+ Nuvio; IMDb puanları ve oy sayıları için title.ratings.tsv.gz dahil olmak üzere IMDb Ticari Olmayan Veri Setlerini kullanır. Bilgiler IMDb (https://www.imdb.com) katkılarıyla sağlanmıştır. İzinle kullanılmıştır. IMDb verileri, IMDb koşulları uyarınca kişisel ve ticari olmayan kullanım içindir.
+ Trakt
+ Nuvio; hesap doğrulama, izleme geçmişi, ilerleme eşitleme, kitaplık verileri, puanlar, listeler ve yorumlar için Trakt'a bağlanır. Nuvio'nun Trakt ile hiçbir bağı yoktur ve Trakt tarafından onaylanmamıştır.
+ Premiumize
+ Nuvio; hesap doğrulama, bulut kitaplığı erişimi, önbellek kontrolleri ve bulut oynatma özellikleri için Premiumize'a bağlanır. Nuvio'nun Premiumize ile hiçbir bağı yoktur ve Premiumize tarafından onaylanmamıştır.
+ TorBox
+ Nuvio; hesap doğrulama, bulut kitaplığı erişimi, önbellek kontrolleri ve bulut oynatma özellikleri için TorBox'a bağlanır. Nuvio'nun TorBox ile hiçbir bağı yoktur ve TorBox tarafından onaylanmamıştır.
+ MDBList
+ Nuvio; puanlar ve dış kaynak puan verileri için MDBList'i kullanır. Nuvio'nun MDBList ile hiçbir bağı yoktur ve MDBList tarafından onaylanmamıştır.
+ IntroDB
+ Nuvio; jenerik ve geçme kontrolleri tarafından kullanılan, topluluk tarafından sağlanan giriş, özet ve önizleme zaman damgaları için IntroDB API'sini kullanır. Nuvio'nun IntroDB ile hiçbir bağı yoktur ve IntroDB tarafından onaylanmamıştır.
+ MPVKit
+ iOS sürümlerinde oynatma için kullanılır.
+ Sadece MPVKit kaynak kodu LGPL v3.0 kapsamında lisanslanmıştır. libmpv ve FFmpeg kütüphanelerini içeren MPVKit paketleri de LGPL v3.0 kapsamında lisanslanmıştır.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Android sürümlerinde oynatma için kullanılır.
+ Apache Lisansı, Sürüm 2.0 kapsamında lisanslanmıştır.
Trakt listelerin yükleniyor…
- Bu içeriğin Trakt\'ta nereye kaydedileceğini seç
+ Bu içeriğin Trakt'ta nereye kaydedileceğini seç
Bağış yap
Detaylara git
Kaldır
@@ -298,6 +497,8 @@
Uygulama dili
Dil seç
İzlemeye Devam Et rafını göster, gizle ve stilini ayarla.
+ Liquid Glass
+ iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Bu özellik açıkken sekme çubuğundan anlık profil geçişi yapılamaz.
Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla.
EKRAN
ANA SAYFA
@@ -324,9 +525,14 @@
%1$d / %2$d seçili
Öne çıkanları göster
Ana sayfanın üstünde öne çıkan bir kaydırmalı alan göster. Aşağıdan en fazla 2 kaynak katalog seç.
+ Henüz Yayınlanmamış İçerikleri Gizle
+ Henüz yayınlanmamış film ve dizileri gizle.
+ Katalog Alt Çizgisini Gizle
+ Uygulama genelinde katalog ve koleksiyon başlıklarının altındaki vurgu çizgisini kaldır.
%1$d / %2$d katalog görünür • %3$d öne çıkan kaynak seçili
Bir kataloğu yalnızca adını değiştirmek veya sıralamak gerektiğinde aç.
Görünür
+ Değeri gizle
Oynatıcı, altyazılar ve otomatik oynatma
Kart yuvarlaklığı
POSTER KART STİLİ
@@ -351,20 +557,33 @@
Sıkı
Büyük
Standart
+ Değeri göster
Oynatıcıdan çıktıktan sonra uygulamayı açınca kaldığın yerden devam etmen için bir pencere göster.
Açılışta devam et uyarısı
+ Sürprizbozanları (spoiler) önlemek için İzlemeye Devam Et rafındaki sonraki bölüm küçük resimlerini bulanıklaştır.
+ İzlemeye Devam Et Rafında İzlenmeyenleri Bulanıklaştır
+ Gelecek bölümleri daha yayınlanmadan önce İzlemeye Devam Et rafına dahil et.
+ Henüz Yayınlanmamış Gelecek Bölümleri Göster
+ SIRALAMA DÜZENİ
+ Sıralama Düzeni
+ Varsayılan
+ Tüm içerikleri son izleme zamanına göre sırala
+ Yayın Tarzı
+ Yayınlanan içerikler önde, yakında çıkacaklar ise sonda yer alır
KART STİLİ
AÇILIŞTA
SONRAKİ DAVRANIŞI
GÖRÜNÜRLÜK
Ana ekranda İzlemeye Devam Et rafını göster.
- İzlemeye Devam Et\'i göster
+ İzlemeye Devam Et'i göster
Poster
Görsel odaklı poster kartı
Geniş
Bilgi ağırlıklı yatay kart
Açıksa Sonraki, her zaman en ileri izlenen bölümden devam eder. Kapalıysa en son izlenen bölümden ilerler. Önceki bölümleri yeniden izliyorsan işe yarar.
Sonraki en ileri bölümden başlasın
+ Varsa bölüm küçük resimlerini tercih et.
+ İzlemeye Devam Et Rafında Bölüm Küçük Resimlerini Tercih Et
ANA SAYFA
KAYNAKLAR
İçerik kaynaklarını kur, kaldır, yenile ve sırala.
@@ -375,12 +594,58 @@
ENTEGRASYONLAR
Detay sayfalarını TMDB görselleri, ekip bilgileri, bölüm meta verileri ve daha fazlasıyla zenginleştir.
Detay sayfalarına IMDb, Rotten Tomatoes, Metacritic ve diğer dış puanları ekle.
+ Bağlantı ve kitaplık erişimi için hesapları bağla
+ Bağlı Servisler
+ Bu entegrasyonlar deneyseldir; gelecekte tutulabilir, değiştirilebilir veya tamamen kaldırılabilir.
+ Bulut kitaplığı
+ Bağlı hesaplarında zaten bulunan dosyaları gözden geçir ve oynat.
+ Oynatılabilir bağlantıları çöz
+ Bir sonuç gerektiğinde bağlı bir servisten oynatılabilir bağlantılar iste. Bu işlem, içeriği o servise ekleyebilir.
+ Şununla çöz
+ Oynatılabilir bağlantıları hangi bağlı hesabın işleyeceğini seç.
+ Önce bir hesap bağla.
+ Hesaplar
+ %1$s hesabını bağla.
+ %1$s hesabını tarayıcıda eşleştir.
+ %1$s API Anahtarı
+ %1$s API anahtarını gir.
+ %1$s API anahtarını gir
+ Ayarlanmadı
+ Bağlandı
+ %1$s Bağla
+ %1$s Bağlantısını Kes
+ Bağlantıyı Kes
+ %1$s bu cihazda bağlı.
+ Güvenli giriş başlatılıyor...
+ Bağlantıyı aç ve Nuvio'yu onaylamak için bu kodu gir.
+ Kod kopyalandı.
+ Bağlantıyı aç
+ Onay bekleniyor...
+ Giriş başlatılamadı.
+ Bu giriş yöntemi bu sürümde yapılandırılmamış.
+ Kodun süresi doldu. Tekrar dene.
+ Bağlantı Hazırlığı
+ Bağlantıları hazırla
+ Oynatma başlamadan önce oynatılabilir bağlantıları çöz.
+ Hazırlanacak bağlantı sayısı
+ Mümkünse daha düşük bir sayı seç. Bağlı servisler, belirli bir sürede çözülebilecek bağlantı sayısını sınırlayabilir. Bir filmi veya bölümü açmak, İzle butonuna basmasan bile limitlerini etkileyebilir, çünkü bağlantılar önceden hazırlanır.
+ 1 bağlantı
+ %1$d bağlantı
+ Biçimlendirme
+ Ad şablonu
+ Sonuç adlarının nasıl görüneceğini belirler.
+ Açıklama şablonu
+ Her sonucun altında gösterilen meta verileri belirler.
+ Biçimlendirmeyi sıfırla
+ Varsayılan sonuç biçimlendirmesine geri dön.
+ API anahtarı doğrulandı.
+ Bu API anahtarı doğrulanamadı.
Puanları açmadan önce aşağıya MDBList API anahtarını ekle.
https://mdblist.com/preferences adresinden bir anahtar alıp buraya yapıştır.
API anahtarı
MDBList API anahtarı
MDBList puanlarını aç
- IMDb ID\'si varsa meta veri sayfalarında MDBList\'ten gelen dış puanları göster.
+ IMDb ID'si varsa meta veri sayfalarında MDBList'ten gelen dış puanları göster.
API ANAHTARI
PUAN SAĞLAYICILARI
MDBLIST
@@ -404,6 +669,8 @@
Detay odaklı alt alta kartlar
Bölümler
Diziler için sezon ve bölüm listesi.
+ İzlenmemiş Bölümleri Bulanıklaştır
+ Sürprizbozanları önlemek için bölümler izlenene kadar küçük resimlerini bulanıklaştır.
Grup %1$d
Buna benzerler
Öneriler alanı.
@@ -419,7 +686,7 @@
Bölümleri TV uygulamasındaki gibi sekmelerde grupla. Her sekme grubuna en fazla 3 bölüm ata.
Fragmanlar
Fragman alanı ve oynatma kısayolları.
- Nuvio\'da bildirimler şu anda kapalı.
+ Nuvio'da bildirimler şu anda kapalı.
Yeni bölüm bildirimleri
Kaydettiğin bir dizinin yeni bölümü yayınlandığında yerel bildirim planla.
Nuvio için sistem bildirimleri kapalı. Uyarıları ve test bildirimlerini almak için aç.
@@ -432,11 +699,11 @@
Bildirimleri test etmek için önce kitaplığına bir dizi kaydet.
Test bildirimi
Topluluk
- Nuvio\'yu Mobile, TV ve Web\'de geliştiren ve destekleyen kişileri gör.
- Destekçiler API\'si ayarlı değil. local.properties dosyasına DONATIONS_BASE_URL ekle.
+ Nuvio'yu Mobile, TV ve Web'de geliştiren ve destekleyen kişileri gör.
+ Destekçiler API'si ayarlı değil. local.properties dosyasına DONATIONS_BASE_URL ekle.
Katkıda bulunanlar
Destekçiler
- GitHub\'ı aç
+ GitHub'ı aç
GitHub profili kullanılamıyor
Mesaj eklenmemiş.
Katkıda bulunanlar yükleniyor...
@@ -469,8 +736,12 @@
İzin verilen pluginler
Anime Skip
AnimeSkip Client ID
- AnimeSkip API client ID\'ni gir. anime-skip.com üzerinden alabilirsin.
- Geçme zamanları için AnimeSkip\'te de ara (client ID gerekir).
+ AnimeSkip API client ID'ni gir. anime-skip.com üzerinden alabilirsin.
+ Giriş Geçme Gönderimini Aç
+ Giriş/jenerik zaman damgalarını topluluk veritabanına göndermek için bir buton göster.
+ IntroDB API Anahtarı
+ Zaman damgası göndermek için IntroDB API anahtarını gir. Gönderim için zorunludur.
+ Geçme zamanları için AnimeSkip'te de ara (client ID gerekir).
Sonraki bölümü otomatik oynat
Eşik değere ulaşılınca sonraki bölümü otomatik bulup oynat.
Sadece cihaz
@@ -483,15 +754,20 @@
%1$d gün
%1$d saat
%1$d saat
- libass\'i aç
+ libass'i aç
ASS/SSA altyazılarını varsayılan işleyici yerine libass ile göster.
+ Harici Oynatıcı
+ Harici Oynatıcı Uygulaması
+ Yeni oynatmayı Android'in varsayılan video uygulamasıyla veya sistem seçicisiyle aç.
+ Yeni oynatmayı seçilen yüklü oynatıcıyla aç.
+ Yüklü ve desteklenen harici oynatıcı yok
Basılı tutma hızı
Hızlandırmak için basılı tut
Oynatıcı yüzeyinde herhangi bir yere uzun basarak oynatma hızını geçici olarak artır.
Geçersiz regex deseni
Son bağlantı önbellek süresi
- DV7\'yi HEVC\'ye eşle
- Desteklenmeyen cihazlar için Dolby Vision Profile 7\'den HEVC\'ye geri dönüş.
+ DV7'yi HEVC'ye eşle
+ Desteklenmeyen cihazlar için Dolby Vision Profile 7'den HEVC'ye geri dönüş.
Bitmeden kaç dakika önce
Sonraki bölüm kartını bitişten kaç dakika önce göstereceğini seç.
%1$s dk
@@ -503,10 +779,12 @@
Yok
Binge grubunu tercih et
Otomatik oynatırken mevcut yayınla aynı binge grubundan bir yayın tercih et.
+ Binge Grubunu Tekrar Kullan
+ Oturumlar arasında en son kullanılan binge grubunu hatırla ve tekrar kullan (İzlemeye Devam Et, Detaylar vb.).
Tercih edilen ses dili
Tercih edilen altyazı dili
Hazır ayarlar
- Yayın adı, etiketi, açıklaması, eklentisi ve URL\'siyle eşleşir.
+ Yayın adı, etiketi, açıklaması, eklentisi ve URL'siyle eşleşir.
Regex deseni
4K|2160p|Remux
Herhangi bir 1080p+
@@ -578,7 +856,7 @@
Zenginleştirmeyi açmadan önce aşağıya kendi TMDB API anahtarını ekle.
TMDB API anahtarı
TMDB zenginleştirmeyi aç
- TMDB veya IMDb ID\'si varsa detay ekranındaki eklenti meta verilerini TMDB API anahtarınla zenginleştir.
+ TMDB veya IMDb ID'si varsa detay ekranındaki eklenti meta verilerini TMDB API anahtarınla zenginleştir.
TMDB v3 API anahtarını gir.
Dil kodu
Görseller
@@ -614,7 +892,7 @@
KİMLİK DOĞRULAMA
Yorumlar
Film ve dizi detaylarında Trakt yorumlarını göster
- Trakt\'ı bağla
+ Trakt'ı bağla
%1$s olarak bağlı
Trakt kullanıcısı
Bağlantıyı kes
@@ -626,6 +904,28 @@
Trakt girişini aç
Kaydet işlemlerin artık Trakt izleme listesine ve kişisel listelere gidebilir.
Liste bazlı kaydetmeyi ve Trakt kitaplığı modunu açmak için Trakt ile giriş yap.
+ Kitaplık Kaynağı
+ Koleksiyonunu kaydetmek ve görüntülemek için hangi kitaplığın kullanılacağını seç
+ Kitaplık Kaynağı
+ Kitaplık ögelerini nerede kaydedip yöneteceğini seç
+ Trakt
+ Nuvio Kitaplığı
+ Trakt kitaplığı seçildi
+ Nuvio kitaplığı seçildi
+ İzleme İlerlemesi
+ Kaldığın yerden devam etme ve izlemeye devam etme özelliğini hangi ilerleme kaynağının besleyeceğini seç
+ İzleme İlerlemesi
+ Trakt eşitlemesi aktifken, kaldığın yerden devam etme ve izlemeye devam etmenin Trakt mı yoksa Nuvio Eşitleme mi kullanacağını seç.
+ Trakt
+ Nuvio Eşitleme
+ İzleme ilerlemesi kaynağı Trakt olarak ayarlandı
+ İzleme ilerlemesi kaynağı Nuvio Eşitleme olarak ayarlandı
+ İzlemeye Devam Et Süresi
+ İzlemeye devam et için dikkate alınacak Trakt geçmişi
+ İzlemeye Devam Et Süresi
+ İzlemeye devam et kısmında ne kadarlık Trakt aktivitesinin görüneceğini seç.
+ Tüm geçmiş
+ %1$d gün
İzleyici puanı
IMDb
Letterboxd
@@ -647,8 +947,8 @@
Sonraki bölüm küçük görseli
Yayınlanmadı
Geç
- Intro\'yu geç
- Outro\'yu geç
+ Intro'yu geç
+ Outro'yu geç
Özeti geç
Altyazı bulunamadı
Afrikaanca
@@ -771,6 +1071,7 @@
Öncekileri izlendi yap
%1$s izlenmedi yapılsın
%1$s izlendi yapılsın
+ Önceki sezonları izlendi olarak işaretle
İzlenmedi olarak işaretle
İzlendi olarak işaretle
Sıradaki
@@ -811,20 +1112,25 @@
Vazgeç
PIN gir
%1$s için PIN gir
- PIN\'i mi unuttun?
+ PIN'i mi unuttun?
PIN hatalı
Kilitli. %1$dsn sonra tekrar dene
Katalog yüklenince avatar seçenekleri burada görünecek.
Avatar: %1$s
+ Geçerli bir http:// veya https:// görsel URL'si gir.
Avatar seç
Aşağıdan bir avatar seç.
Profil oluştur
+ Özel profil resmi URL'si seçildi.
+ Özel profil resmi URL'si
+ Bir görsel bağlantısı yapıştır ya da yerleşik profil resimlerini kullanmak için burayı boş bırak.
+ https://ornek.com/avatar.png
"%1$s" için tüm veriler kalıcı olarak silinecek.
Profili sil
Profil ekle
Profili düzenle
- Mevcut PIN\'i gir
- Yeni PIN\'i gir
+ Mevcut PIN'i gir
+ Yeni PIN'i gir
Profil %1$d
Avatarlar yükleniyor...
Profilleri yönet
@@ -832,7 +1138,7 @@
Yeni profil
Ana eklentiler kapalı
Ana eklentiler açık
- %1$s için PIN\'i kaldır
+ %1$s için PIN'i kaldır
PIN kilidini kaldır
Kaydediliyor...
Güvenlik
@@ -846,10 +1152,12 @@
Kim izliyor?
İndirildi
Devam et
- Aktif scraper\'lar
+ Aktif scraper'lar
Daha fazla eklenti kontrol ediliyor…
Yayın bağlantısını kopyala
Dosyayı indir
+ Harici oynatıcıda aç
+ Dahili oynatıcıda aç
Kurulu yayın eklentileri geçerli bir yayın yanıtı döndüremedi.
Yayınlar yüklenemedi
Bu içerik için yayınları yüklemek üzere önce bir eklenti kur.
@@ -869,6 +1177,14 @@
%1$d% konumundan devam et
%1$s konumundan devam et
BOYUT %1$s
+ Bu yayın türü desteklenmiyor
+ Ayarlar'dan bir hesap bağla.
+ TorBox'ta önbelleğe alınmamış.
+ Bu bağlantının süresi dolmuş. Sonuçlar yenileniyor.
+ Bu bağlantı açılamadı.
+ Harici oynatıcı açılamadı
+ Önce ayarlardan bir harici oynatıcı seç
+ Kullanılabilir harici oynatıcı yok
Fragmanı kapat
Fragman oynatılamıyor
Trakt listeleri yüklenemedi
@@ -890,7 +1206,7 @@
Güncelleme var
Güncelleme durumu
Bu eklenti zaten kurulu.
- Geçerli bir eklenti URL\'si gir
+ Geçerli bir eklenti URL'si gir
Manifest yüklenemedi
Nuvio
Hesap silinemedi
@@ -914,6 +1230,7 @@
İndirme olmadı
Duraklatıldı %1$s
Kaldır
+ %1$s, %2$s listesinden kaldırılsın mı?
%1$s kitaplığından kaldırılsın mı?
Kitaplıktan kaldırılsın mı?
Film
@@ -922,7 +1239,7 @@
Test bildirimi gönderilemedi.
%1$s için test bildirimi gönderildi.
Bu yayın oynatılamıyor.
- Bu profilin PIN\'i değişti. Bu cihazdaki kilidi yenilemek için bir kez bağlan.
+ Bu profilin PIN'i değişti. Bu cihazdaki kilidi yenilemek için bir kez bağlan.
PIN kilidi kaldırılamadı. Tekrar dene.
PIN kilidini kaldırmak için internete bağlan.
Bu PIN bu cihazda henüz çevrimdışı doğrulanamaz. Önce bir kez bağlanıp çevrimiçi kilidini aç.
@@ -953,10 +1270,11 @@
%1$s devam et
JSON boş.
%1$d. koleksiyonun kimliği boş.
- \'%1$s\' koleksiyonunun başlığı boş.
- \'%2$s\' içindeki %1$d. klasörün kimliği boş.
- \'%2$s\' içindeki \'%1$s\' klasörünün başlığı boş.
- \'%2$s\' klasöründeki %1$d. kaynağın alanları boş.
+ '%1$s' koleksiyonunun başlığı boş.
+ '%2$s' içindeki %1$d. klasörün kimliği boş.
+ '%2$s' içindeki '%1$s' klasörünün başlığı boş.
+ '%2$s' klasöründeki %1$d. kaynağın alanları boş.
+ '%2$s' klasöründeki %1$d kaynağının Trakt liste ID'si eksik.
Geçersiz JSON: %1$s
Eklenti bulunamadı: %1$s
Ocak
@@ -1011,15 +1329,46 @@
İndirme sistemi başlatılmamış
İndirme isteği başarısız oldu
%1$s - %2$s
- Detay ekranında Kaydet\'e dokunduktan sonra kaydettiğin içerikler burada görünür.
+ Detay ekranında Kaydet'e dokunduktan sonra kaydettiğin içerikler burada görünür.
Kitaplığın boş
Kitaplık yüklenemedi
Diğer
+ Bulut
+ Kaydedildi
Kitaplık
- Trakt\'ı bağla ve içerikleri izleme listene ya da kişisel listelerine kaydet.
+ Trakt'ı bağla ve içerikleri izleme listene ya da kişisel listelerine kaydet.
Trakt kitaplığın boş
Trakt kitaplığı yüklenemedi
Trakt kitaplığı
+ Hesabı bağla
+ Bulut kitaplığındaki oynatılabilir dosyalara göz atmak için Bağlı Servisler ayarlarından bir hesap bağla.
+ Bağlı bulut hesabı yok
+ Bağlı Servisleri Aç
+ Bağlı hesaplardaki dosyalara göz atmak için Bağlı Servisler ayarlarından Bulut kitaplığını aç.
+ Bulut kitaplığı kapalı
+ Mevcut filtrelere uyan oynatılabilir bulut dosyası yok.
+ Henüz burada hiçbir şey yok
+ Oynatmak için bir dosya seç
+ %1$s bulut kitaplığı yüklenemedi
+ Bu öge oynatılabilir bir video dosyası barındırmıyor.
+ Oynatılabilir dosya yok
+ Oynatılabilir dosya yok
+ Bulut kitaplığı kapalı.
+ Bu bulut dosyası oynatılamadı.
+ Dosyayı oynat
+ Bulut servisi bağlı değil.
+ %1$s bağlı değil.
+ %1$d oynatılabilir dosya
+ Tümü
+ Bulut kitaplığını yenile
+ Sağlayıcı seç
+ Tür seç
+ Oynatmaya hazır
+ Tümü
+ Torrentler
+ Usenet
+ Web
+ Dosyalar
Anime
Kanallar
Filmler
@@ -1030,6 +1379,14 @@
Yeni bölüm şimdi yayında
%1$s şimdi yayında
Yeni bölüm bildirimleri
+ Alkol/Madde Kullanımı
+ Korkutucu
+ Çıplaklık
+ Küfür/Kötü Söz
+ Hafif
+ Orta
+ Şiddetli
+ Şiddet
Oluşturan
Yönetmen
Yazar
From 9b5ea6e3b653ebd05989827efa02424940fc6fd9 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Tue, 26 May 2026 23:08:39 +0530
Subject: [PATCH 06/12] fix: trakt local porgress pushing multiple watched
markers
---
.../nuvio/app/features/player/PlayerScreen.kt | 12 +++++
.../app/features/watched/WatchedRepository.kt | 52 ++++++++++++++++---
.../watching/application/WatchingActions.kt | 2 +-
.../features/watched/WatchedRepositoryTest.kt | 23 ++++++++
4 files changed, 81 insertions(+), 8 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index 4cc99511f..9bd6d364a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -269,6 +269,12 @@ fun PlayerScreen(
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
+ var scrobbleStartRequestGeneration by remember(
+ activeSourceUrl,
+ activeVideoId,
+ activeSeasonNumber,
+ activeEpisodeNumber,
+ ) { mutableStateOf(0L) }
var hasSentCompletionScrobbleForCurrentItem by remember(
activeVideoId,
activeSeasonNumber,
@@ -422,6 +428,8 @@ fun PlayerScreen(
fun emitTraktScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
+ val requestGeneration = scrobbleStartRequestGeneration + 1L
+ scrobbleStartRequestGeneration = requestGeneration
scope.launch {
val item = currentTraktScrobbleItem()
@@ -429,6 +437,9 @@ fun PlayerScreen(
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
+ if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
+ return@launch
+ }
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
item = item,
@@ -452,6 +463,7 @@ fun PlayerScreen(
}
currentTraktScrobbleItem = null
hasRequestedScrobbleStartForCurrentItem = false
+ scrobbleStartRequestGeneration += 1L
}
fun emitStopScrobbleForCurrentProgress() {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
index 3735763f8..fffae188f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
@@ -28,6 +28,16 @@ private data class StoredWatchedPayload(
val lastSuccessfulPushEpochMs: Long = 0L,
)
+internal enum class WatchedTraktHistorySync {
+ Mirror,
+ Skip,
+}
+
+internal fun shouldMirrorWatchedMarkToTraktHistory(
+ sync: WatchedTraktHistorySync,
+ isTraktAuthenticated: Boolean,
+): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated
+
object WatchedRepository {
private const val watchedItemsPageSize = 900
@@ -134,6 +144,17 @@ object WatchedRepository {
}
fun markWatched(items: Collection) {
+ markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror)
+ }
+
+ internal fun markWatchedFromPlaybackCompletion(item: WatchedItem) {
+ markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip)
+ }
+
+ private fun markWatched(
+ items: Collection,
+ traktHistorySync: WatchedTraktHistorySync,
+ ) {
ensureLoaded()
if (items.isEmpty()) return
val markedAt = WatchedClock.nowEpochMs()
@@ -146,7 +167,7 @@ object WatchedRepository {
}
publish()
persist()
- pushMarksToServer(timestampedItems)
+ pushMarksToServer(timestampedItems, traktHistorySync)
}
fun unmarkWatched(item: WatchedItem) {
@@ -223,13 +244,22 @@ object WatchedRepository {
}
}
- private fun pushMarksToServer(items: Collection) {
+ private fun pushMarksToServer(
+ items: Collection,
+ traktHistorySync: WatchedTraktHistorySync,
+ ) {
syncScope.launch {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
- pushToActiveTargets(profileId = profileId, items = items)
- recordSuccessfulPush(profileId = profileId, items = items)
+ val pushed = pushToActiveTargets(
+ profileId = profileId,
+ items = items,
+ traktHistorySync = traktHistorySync,
+ )
+ if (pushed) {
+ recordSuccessfulPush(profileId = profileId, items = items)
+ }
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@@ -296,16 +326,24 @@ object WatchedRepository {
private suspend fun pushToActiveTargets(
profileId: Int,
items: Collection,
- ) {
+ traktHistorySync: WatchedTraktHistorySync,
+ ): Boolean {
+ val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory(
+ sync = traktHistorySync,
+ isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
+ )
+
if (shouldUseTraktWatchedSync()) {
+ if (!shouldMirrorToTrakt) return false
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
- return
+ return true
}
syncAdapter.push(profileId = profileId, items = items)
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (shouldMirrorToTrakt) {
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
}
+ return true
}
private suspend fun deleteFromActiveTargets(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
index 4d2074226..0b73570a3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
@@ -129,7 +129,7 @@ object WatchingActions {
episode = entry.episodeNumber,
markedAtEpochMs = entry.lastUpdatedEpochMs,
)
- WatchedRepository.markWatched(watchedItem)
+ WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem)
if (!entry.isEpisode) return
actionScope.launch {
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
index 433568373..346457289 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
@@ -4,6 +4,7 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WatchedRepositoryTest {
@@ -97,4 +98,26 @@ class WatchedRepositoryTest {
assertTrue(merged.isEmpty())
}
+
+ @Test
+ fun playbackCompletionWatchedMarks_doNotMirrorToTraktHistory() {
+ assertFalse(
+ shouldMirrorWatchedMarkToTraktHistory(
+ sync = WatchedTraktHistorySync.Skip,
+ isTraktAuthenticated = true,
+ ),
+ )
+ assertTrue(
+ shouldMirrorWatchedMarkToTraktHistory(
+ sync = WatchedTraktHistorySync.Mirror,
+ isTraktAuthenticated = true,
+ ),
+ )
+ assertFalse(
+ shouldMirrorWatchedMarkToTraktHistory(
+ sync = WatchedTraktHistorySync.Mirror,
+ isTraktAuthenticated = false,
+ ),
+ )
+ }
}
From 47c494b2617b110cd290d8e004f864ab7d4ec4c4 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 00:25:23 +0530
Subject: [PATCH 07/12] feat: adding subtitle delay, styles management.
---
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
.../features/player/PlayerEngine.android.kt | 141 ++++-
.../player/PlayerSettingsStorage.android.kt | 128 ++++-
.../PlayerTrackPreferenceStorage.android.kt | 108 ++++
.../composeResources/values/strings.xml | 31 ++
.../nuvio/app/features/player/PlayerEngine.kt | 1 +
.../nuvio/app/features/player/PlayerScreen.kt | 310 ++++++++++-
.../player/PlayerSettingsRepository.kt | 33 ++
.../features/player/PlayerSettingsStorage.kt | 14 +
.../player/PlayerSubtitleCueParser.kt | 87 ++++
.../player/PlayerTrackPreferenceStorage.kt | 27 +
.../features/player/SubtitleAudioModels.kt | 41 +-
.../app/features/player/SubtitleModal.kt | 16 +
.../app/features/player/SubtitleRepository.kt | 1 +
.../app/features/player/SubtitleStylePanel.kt | 350 ++++++++++++-
.../features/settings/PlaybackSettingsPage.kt | 482 +++++++++++++++++-
.../app/features/player/NuvioPlayerBridge.kt | 4 +
.../app/features/player/PlayerEngine.ios.kt | 11 +-
.../player/PlayerSettingsStorage.ios.kt | 82 +++
.../PlayerTrackPreferenceStorage.ios.kt | 102 ++++
iosApp/iosApp/Player/MPVPlayerBridge.swift | 35 +-
21 files changed, 1945 insertions(+), 61 deletions(-)
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 94036653b..d9af35b68 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -26,6 +26,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
+import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage
@@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
MetaScreenSettingsStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
+ PlayerTrackPreferenceStorage.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
index 62ebd5213..9e017222f 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
+import android.text.SpannableString
import android.net.Uri
import android.util.Log
import android.util.TypedValue
@@ -34,12 +35,17 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
+import androidx.media3.common.text.Cue
+import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.ForwardingRenderer
+import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
+import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory
@@ -105,6 +111,10 @@ actual fun PlatformPlayerSurface(
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
)
+ var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
+ var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf(null) }
+ val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs)
+ val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType)
var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) }
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
@@ -117,7 +127,13 @@ actual fun PlatformPlayerSurface(
useYoutubeChunkedPlayback,
effectiveDecoderPriority,
) {
- val renderersFactory = DefaultRenderersFactory(context)
+ val renderersFactory = SubtitleOffsetRenderersFactory(
+ context = context,
+ subtitleDelayUsProvider = { latestSubtitleDelayMs.value.toLong() * 1_000L },
+ shouldNormalizeCuePositionProvider = {
+ latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT
+ },
+ )
.setExtensionRendererMode(effectiveDecoderPriority)
.setEnableDecoderFallback(true)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@@ -388,6 +404,7 @@ actual fun PlatformPlayerSurface(
val resolvedMime = withContext(Dispatchers.IO) {
resolveSubtitleMimeType(url)
}
+ selectedExternalSubtitleMimeType = resolvedMime
Log.d(TAG, "setSubtitleUri: currentPosition=$currentPosition, wasPlaying=$wasPlaying")
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
.setMimeType(resolvedMime)
@@ -418,6 +435,8 @@ actual fun PlatformPlayerSurface(
override fun clearExternalSubtitle() {
Log.d(TAG, "clearExternalSubtitle called")
+ subtitleSelectionJob?.cancel()
+ selectedExternalSubtitleMimeType = null
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
@@ -432,6 +451,8 @@ actual fun PlatformPlayerSurface(
override fun clearExternalSubtitleAndSelect(trackIndex: Int) {
Log.d(TAG, "clearExternalSubtitleAndSelect: trackIndex=$trackIndex")
+ subtitleSelectionJob?.cancel()
+ selectedExternalSubtitleMimeType = null
pendingSubtitleTrackIndex.clear()
pendingSubtitleTrackIndex.add(trackIndex)
val currentPosition = exoPlayer.currentPosition
@@ -450,6 +471,10 @@ actual fun PlatformPlayerSurface(
currentSubtitleStyle = style
playerViewRef?.applySubtitleStyle(style)
}
+
+ override fun setSubtitleDelayMs(delayMs: Int) {
+ subtitleDelayMs = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
+ }
}
)
}
@@ -607,11 +632,11 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
setStyle(
CaptionStyleCompat(
style.textColor.toArgb(),
- android.graphics.Color.TRANSPARENT,
+ style.backgroundColor.toArgb(),
android.graphics.Color.TRANSPARENT,
if (style.outlineEnabled) CaptionStyleCompat.EDGE_TYPE_OUTLINE else CaptionStyleCompat.EDGE_TYPE_NONE,
- android.graphics.Color.BLACK,
- Typeface.DEFAULT,
+ style.outlineColor.toArgb(),
+ if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT,
)
)
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat())
@@ -709,6 +734,114 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
+@androidx.annotation.OptIn(UnstableApi::class)
+private class SubtitleOffsetRenderersFactory(
+ context: Context,
+ private val subtitleDelayUsProvider: () -> Long,
+ private val shouldNormalizeCuePositionProvider: () -> Boolean,
+) : DefaultRenderersFactory(context) {
+ override fun buildTextRenderers(
+ context: Context,
+ output: TextOutput,
+ outputLooper: android.os.Looper,
+ extensionRendererMode: Int,
+ out: ArrayList,
+ ) {
+ val normalizingOutput = CueNormalizingTextOutput(
+ delegate = output,
+ shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider,
+ )
+ val startIndex = out.size
+ super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out)
+ for (index in startIndex until out.size) {
+ out[index] = SubtitleOffsetRenderer(
+ baseRenderer = out[index],
+ subtitleDelayUsProvider = subtitleDelayUsProvider,
+ )
+ }
+ }
+}
+
+private class CueNormalizingTextOutput(
+ private val delegate: TextOutput,
+ private val shouldNormalizeCuePositionProvider: () -> Boolean,
+) : TextOutput {
+ override fun onCues(cueGroup: CueGroup) {
+ val processed = cueGroup.cues.map(::processCue)
+ delegate.onCues(CueGroup(processed, cueGroup.presentationTimeUs))
+ }
+
+ @Deprecated("Uses the deprecated Media3 callback for text outputs.")
+ override fun onCues(cues: List) {
+ delegate.onCues(cues.map(::processCue))
+ }
+
+ private fun processCue(cue: Cue): Cue {
+ var processed = fixRtlCueText(cue)
+ if (shouldNormalizeCuePositionProvider()) {
+ processed = normalizeCuePosition(processed)
+ }
+ return processed
+ }
+
+ private fun normalizeCuePosition(cue: Cue): Cue {
+ if (cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET || cue.line == Cue.DIMEN_UNSET) {
+ return cue
+ }
+ return cue.buildUpon()
+ .setLine(Cue.DIMEN_UNSET, Cue.TYPE_UNSET)
+ .setLineAnchor(Cue.TYPE_UNSET)
+ .build()
+ }
+
+ private fun fixRtlCueText(cue: Cue): Cue {
+ val text = cue.text ?: return cue
+ if (!containsRtlChars(text)) return cue
+ val original = text.toString()
+ val fixed = original.split('\n').joinToString("\n") { line ->
+ moveLeadingRtlPunctuationToEnd(line)
+ }
+ if (fixed == original) return cue
+ return cue.buildUpon().setText(SpannableString(fixed)).build()
+ }
+
+ private fun moveLeadingRtlPunctuationToEnd(line: String): String {
+ if (line.isEmpty()) return line
+ var end = 0
+ while (end < line.length && line[end] in RTL_PUNCTUATION) end++
+ if (end == 0) return line
+ return line.substring(end) + line.substring(0, end)
+ }
+
+ private fun containsRtlChars(text: CharSequence): Boolean {
+ for (char in text) {
+ val directionality = Character.getDirectionality(char)
+ if (
+ directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT ||
+ directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC
+ ) {
+ return true
+ }
+ }
+ return false
+ }
+
+ companion object {
+ private val RTL_PUNCTUATION = setOf('.', ',', '?', '!', '-', ':', ';', '…', ')', '(')
+ }
+}
+
+@androidx.annotation.OptIn(UnstableApi::class)
+private class SubtitleOffsetRenderer(
+ baseRenderer: Renderer,
+ private val subtitleDelayUsProvider: () -> Long,
+) : ForwardingRenderer(baseRenderer) {
+ override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
+ val adjustedPositionUs = (positionUs - subtitleDelayUsProvider()).coerceAtLeast(0L)
+ super.render(adjustedPositionUs, elapsedRealtimeUs)
+ }
+}
+
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
mapSubtitleMime(contentType)?.let { return it }
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 743c27f8a..3f75197d2 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -30,9 +30,16 @@ actual object PlayerSettingsStorage {
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
private const val subtitleTextColorKey = "subtitle_text_color"
+ private const val subtitleBackgroundColorKey = "subtitle_background_color"
+ private const val subtitleOutlineColorKey = "subtitle_outline_color"
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
+ private const val subtitleOutlineWidthKey = "subtitle_outline_width"
+ private const val subtitleBoldKey = "subtitle_bold"
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
+ private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
+ private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
+ private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
private const val decoderPriorityKey = "decoder_priority"
@@ -83,9 +90,16 @@ actual object PlayerSettingsStorage {
preferredSubtitleLanguageKey,
secondaryPreferredSubtitleLanguageKey,
subtitleTextColorKey,
+ subtitleBackgroundColorKey,
+ subtitleOutlineColorKey,
subtitleOutlineEnabledKey,
+ subtitleOutlineWidthKey,
+ subtitleBoldKey,
subtitleFontSizeSpKey,
subtitleBottomOffsetKey,
+ subtitleUseForcedSubtitlesKey,
+ subtitleShowOnlyPreferredLanguagesKey,
+ addonSubtitleStartupModeKey,
streamReuseLastLinkEnabledKey,
streamReuseLastLinkCacheHoursKey,
decoderPriorityKey,
@@ -289,11 +303,31 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadSubtitleBackgroundColor(): String? =
+ preferences?.getString(ProfileScopedKey.of(subtitleBackgroundColorKey), null)
+
+ actual fun saveSubtitleBackgroundColor(colorHex: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(subtitleBackgroundColorKey), colorHex)
+ ?.apply()
+ }
+
+ actual fun loadSubtitleOutlineColor(): String? =
+ preferences?.getString(ProfileScopedKey.of(subtitleOutlineColorKey), null)
+
+ actual fun saveSubtitleOutlineColor(colorHex: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(subtitleOutlineColorKey), colorHex)
+ ?.apply()
+ }
+
actual fun loadSubtitleOutlineEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
if (sharedPreferences.contains(key)) {
- sharedPreferences.getBoolean(key, false)
+ sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.outlineEnabled)
} else {
null
}
@@ -306,6 +340,40 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadSubtitleOutlineWidth(): Int? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(subtitleOutlineWidthKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getInt(key, SubtitleStyleState.DEFAULT.outlineWidth)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveSubtitleOutlineWidth(width: Int) {
+ preferences
+ ?.edit()
+ ?.putInt(ProfileScopedKey.of(subtitleOutlineWidthKey), width)
+ ?.apply()
+ }
+
+ actual fun loadSubtitleBold(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(subtitleBoldKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.bold)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveSubtitleBold(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(subtitleBoldKey), enabled)
+ ?.apply()
+ }
+
actual fun loadSubtitleFontSizeSp(): Int? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
@@ -340,6 +408,50 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadSubtitleUseForcedSubtitles(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(subtitleUseForcedSubtitlesKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.useForcedSubtitles)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(subtitleUseForcedSubtitlesKey), enabled)
+ ?.apply()
+ }
+
+ actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey), enabled)
+ ?.apply()
+ }
+
+ actual fun loadAddonSubtitleStartupMode(): String? =
+ preferences?.getString(ProfileScopedKey.of(addonSubtitleStartupModeKey), null)
+
+ actual fun saveAddonSubtitleStartupMode(mode: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(addonSubtitleStartupModeKey), mode)
+ ?.apply()
+ }
+
actual fun loadStreamReuseLastLinkEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
@@ -825,9 +937,16 @@ actual object PlayerSettingsStorage {
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
+ loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
+ loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
+ loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
+ loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
+ loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
+ loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
+ loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
@@ -882,9 +1001,16 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
+ payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
+ payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
+ payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
+ payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
+ payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
+ payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
+ payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt
new file mode 100644
index 000000000..bbd98327d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt
@@ -0,0 +1,108 @@
+package com.nuvio.app.features.player
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+internal actual object PlayerTrackPreferenceStorage {
+ private const val preferencesName = "nuvio_player_track_preferences"
+ private const val subtitleTypeKey = "subtitle_type"
+ private const val subtitleLanguageKey = "subtitle_language"
+ private const val subtitleNameKey = "subtitle_name"
+ private const val subtitleTrackIdKey = "subtitle_track_id"
+ private const val addonSubtitleIdKey = "addon_subtitle_id"
+ private const val addonSubtitleUrlKey = "addon_subtitle_url"
+ private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
+ private const val audioLanguageKey = "audio_language"
+ private const val audioNameKey = "audio_name"
+ private const val audioTrackIdKey = "audio_track_id"
+ private const val subtitleDelayMsKey = "subtitle_delay_ms"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun load(contentId: String): PersistedPlayerTrackPreference? {
+ val id = contentId.normalizedStorageId() ?: return null
+ val preference = PersistedPlayerTrackPreference(
+ subtitleType = loadString(subtitleTypeKey, id),
+ subtitleLanguage = loadString(subtitleLanguageKey, id),
+ subtitleName = loadString(subtitleNameKey, id),
+ subtitleTrackId = loadString(subtitleTrackIdKey, id),
+ addonSubtitleId = loadString(addonSubtitleIdKey, id),
+ addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
+ addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
+ audioLanguage = loadString(audioLanguageKey, id),
+ audioName = loadString(audioNameKey, id),
+ audioTrackId = loadString(audioTrackIdKey, id),
+ )
+ return preference.takeIf {
+ listOf(
+ it.subtitleType,
+ it.subtitleLanguage,
+ it.subtitleName,
+ it.subtitleTrackId,
+ it.addonSubtitleId,
+ it.addonSubtitleUrl,
+ it.addonSubtitleAddonName,
+ it.audioLanguage,
+ it.audioName,
+ it.audioTrackId,
+ ).any { value -> !value.isNullOrBlank() }
+ }
+ }
+
+ actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
+ val id = contentId.normalizedStorageId() ?: return
+ preferences?.edit()?.apply {
+ putOptionalString(subtitleTypeKey, id, preference.subtitleType)
+ putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
+ putOptionalString(subtitleNameKey, id, preference.subtitleName)
+ putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
+ putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
+ putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
+ putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
+ putOptionalString(audioLanguageKey, id, preference.audioLanguage)
+ putOptionalString(audioNameKey, id, preference.audioName)
+ putOptionalString(audioTrackIdKey, id, preference.audioTrackId)
+ }?.apply()
+ }
+
+ actual fun loadSubtitleDelayMs(videoId: String): Int? {
+ val id = videoId.normalizedStorageId() ?: return null
+ val key = scopedKey(subtitleDelayMsKey, id)
+ return preferences?.let { prefs ->
+ if (prefs.contains(key)) prefs.getInt(key, 0) else null
+ }
+ }
+
+ actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
+ val id = videoId.normalizedStorageId() ?: return
+ preferences
+ ?.edit()
+ ?.putInt(scopedKey(subtitleDelayMsKey, id), delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
+ ?.apply()
+ }
+
+ private fun loadString(field: String, contentId: String): String? =
+ preferences
+ ?.getString(scopedKey(field, contentId), null)
+ ?.takeIf { it.isNotBlank() }
+
+ private fun SharedPreferences.Editor.putOptionalString(field: String, contentId: String, value: String?) {
+ val key = scopedKey(field, contentId)
+ if (value.isNullOrBlank()) {
+ remove(key)
+ } else {
+ putString(key, value)
+ }
+ }
+
+ private fun scopedKey(field: String, contentId: String): String =
+ ProfileScopedKey.of("$field|$contentId")
+
+ private fun String.normalizedStorageId(): String? =
+ trim().takeIf { it.isNotBlank() }
+}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 7acbaaa0b..319408a05 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -312,8 +312,11 @@
Search
Audio Tracks
Audio
+ Auto Sync
+ Bold
Built-in
Bottom Offset
+ Capture
Close player
Color
Currently playing
@@ -324,11 +327,13 @@
Font Size
%1$dsp
Lock player controls
+ Loading subtitle lines...
No audio tracks available
No episodes available
No streams found
None
Outline
+ Outline Color
Episodes
Sources
Streams
@@ -336,6 +341,8 @@
Playing
Tap to fetch subtitles
Go back
+ Reload
+ Reset
Reset Defaults
Fill
Fit
@@ -348,8 +355,11 @@
Seek forward 10 seconds
Sources
Style
+ Select an addon subtitle first
Subs
+ Subtitle Delay
Subtitles
+ Text Opacity
Brightness %1$s
Volume %1$s
Muted
@@ -777,6 +787,13 @@
Device language
Forced
None
+ All subtitles
+ Fetch and show every addon subtitle for the video.
+ Fast startup
+ Skip automatic addon subtitle fetch until you request it in the player.
+ Addon Subtitle Startup
+ Preferred only
+ Fetch addon subtitles, but only show preferred-language matches.
Prefer Binge Group (Next Episode)
Try the same source profile first (same addon/quality group) before normal auto-play rules.
Reuse Binge Group
@@ -821,6 +838,20 @@
%1$d selected
Loading Overlay
Show loading screen until first video frame appears.
+ Background Color
+ Bold
+ Use a heavier subtitle font weight.
+ Transparent
+ Outline
+ Outline Color
+ Draw a border around subtitle text.
+ Show Only Preferred Languages
+ Only show subtitles matching your preferred subtitle languages.
+ Subtitle Size
+ Text Color
+ Use Forced Subtitles
+ Prefer forced subtitles when matching your subtitle language settings.
+ Vertical Offset
Skip Intro
Use introdb.app to detect intros and recaps.
Auto-play Source Scope
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
index ac0be69f0..72a3b86fe 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
@@ -18,6 +18,7 @@ interface PlayerEngineController {
fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackIndex: Int)
fun applySubtitleStyle(style: SubtitleStyleState) {}
+ fun setSubtitleDelayMs(delayMs: Int) {}
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index 9bd6d364a..826fa0897 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -47,6 +47,7 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
+import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo
@@ -563,6 +564,146 @@ fun PlayerScreen(
var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) {
mutableStateOf(null)
}
+ var trackPreferenceRestoreApplied by rememberSaveable(activeSourceUrl, parentMetaId) {
+ mutableStateOf(false)
+ }
+ var subtitleDelayMs by rememberSaveable(playbackSession.videoId) {
+ mutableStateOf(
+ PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId)
+ ?: 0
+ )
+ }
+ var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) {
+ mutableStateOf(SubtitleAutoSyncUiState())
+ }
+ val visibleAddonSubtitles = remember(
+ addonSubtitles,
+ playerSettingsUiState.preferredSubtitleLanguage,
+ playerSettingsUiState.secondaryPreferredSubtitleLanguage,
+ subtitleStyle.showOnlyPreferredLanguages,
+ playerSettingsUiState.addonSubtitleStartupMode,
+ selectedAddonSubtitleId,
+ ) {
+ filterAddonSubtitlesForSettings(
+ subtitles = addonSubtitles,
+ settings = playerSettingsUiState,
+ selectedAddonSubtitleId = selectedAddonSubtitleId,
+ )
+ }
+ val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) {
+ addonSubtitles.firstOrNull { subtitle ->
+ subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
+ }
+ }
+
+ fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) {
+ if (parentMetaId.isBlank()) return
+ val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
+ PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
+ }
+
+ fun persistAudioPreference(track: AudioTrack?) {
+ updateTrackPreference { current ->
+ current.copy(
+ audioLanguage = track?.language,
+ audioName = track?.label,
+ audioTrackId = track?.id,
+ )
+ }
+ }
+
+ fun persistInternalSubtitlePreference(track: SubtitleTrack?) {
+ updateTrackPreference { current ->
+ current.copy(
+ subtitleType = if (track == null) {
+ PersistedSubtitleSelectionType.DISABLED
+ } else {
+ PersistedSubtitleSelectionType.INTERNAL
+ },
+ subtitleLanguage = track?.language,
+ subtitleName = track?.label,
+ subtitleTrackId = track?.id,
+ addonSubtitleId = null,
+ addonSubtitleUrl = null,
+ addonSubtitleAddonName = null,
+ )
+ }
+ }
+
+ fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
+ updateTrackPreference { current ->
+ current.copy(
+ subtitleType = PersistedSubtitleSelectionType.ADDON,
+ subtitleLanguage = subtitle.language,
+ subtitleName = subtitle.display,
+ subtitleTrackId = null,
+ addonSubtitleId = subtitle.id,
+ addonSubtitleUrl = subtitle.url,
+ addonSubtitleAddonName = subtitle.addonName,
+ )
+ }
+ }
+
+ fun restorePersistedTrackPreferenceIfNeeded() {
+ if (trackPreferenceRestoreApplied) return
+ val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
+ if (preference == null) {
+ trackPreferenceRestoreApplied = true
+ return
+ }
+
+ if (
+ audioTracks.isNotEmpty() &&
+ (!preference.audioTrackId.isNullOrBlank() ||
+ !preference.audioLanguage.isNullOrBlank() ||
+ !preference.audioName.isNullOrBlank())
+ ) {
+ val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
+ if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
+ playerController?.selectAudioTrack(restoredAudioIndex)
+ selectedAudioIndex = restoredAudioIndex
+ }
+ preferredAudioSelectionApplied = true
+ }
+
+ when (preference.subtitleType) {
+ PersistedSubtitleSelectionType.DISABLED -> {
+ playerController?.selectSubtitleTrack(-1)
+ selectedSubtitleIndex = -1
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ preferredSubtitleSelectionApplied = true
+ }
+ PersistedSubtitleSelectionType.INTERNAL -> {
+ if (subtitleTracks.isNotEmpty()) {
+ val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
+ if (restoredSubtitleIndex >= 0) {
+ if (useCustomSubtitles) {
+ playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
+ } else {
+ playerController?.selectSubtitleTrack(restoredSubtitleIndex)
+ }
+ selectedSubtitleIndex = restoredSubtitleIndex
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ preferredSubtitleSelectionApplied = true
+ }
+ }
+ }
+ PersistedSubtitleSelectionType.ADDON -> {
+ val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
+ if (url != null) {
+ selectedAddonSubtitleId = preference.addonSubtitleId ?: url
+ selectedSubtitleIndex = -1
+ useCustomSubtitles = true
+ playerController?.setSubtitleUri(url)
+ preferredSubtitleSelectionApplied = true
+ }
+ }
+ }
+
+ trackPreferenceRestoreApplied = true
+ }
fun refreshTracks() {
val ctrl = playerController ?: return
@@ -573,6 +714,8 @@ fun PlayerScreen(
val selectedSub = subtitleTracks.firstOrNull { it.isSelected }
if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index
+ restorePersistedTrackPreferenceIfNeeded()
+
if (!preferredAudioSelectionApplied) {
val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
@@ -597,7 +740,11 @@ fun PlayerScreen(
if (!preferredSubtitleSelectionApplied) {
val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
- preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
+ preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
+ SubtitleLanguageOption.FORCED
+ } else {
+ playerSettingsUiState.preferredSubtitleLanguage
+ },
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
)
@@ -622,7 +769,8 @@ fun PlayerScreen(
useCustomSubtitles = false
} else if (
preferredSubtitleIndex < 0 &&
- normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED
+ (subtitleStyle.useForcedSubtitles ||
+ normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED)
) {
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
playerController?.selectSubtitleTrack(-1)
@@ -1441,6 +1589,59 @@ fun PlayerScreen(
SubtitleRepository.fetchAddonSubtitles(type, videoId)
}
+ fun setSubtitleDelay(delayMs: Int) {
+ val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
+ subtitleDelayMs = clamped
+ PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped)
+ playerController?.setSubtitleDelayMs(clamped)
+ }
+
+ fun loadSubtitleAutoSyncCues(force: Boolean = false) {
+ val subtitle = selectedAddonSubtitle ?: return
+ if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null)
+ scope.launch {
+ val result = runCatching {
+ val body = httpGetTextWithHeaders(
+ url = subtitle.url,
+ headers = sanitizePlaybackHeaders(activeSourceHeaders),
+ )
+ PlayerSubtitleCueParser.parse(body, subtitle.url)
+ }
+ result.fold(
+ onSuccess = { cues ->
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ cues = cues,
+ isLoading = false,
+ errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
+ )
+ },
+ onFailure = { error ->
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ isLoading = false,
+ errorMessage = error.message ?: "Unable to load subtitle lines",
+ )
+ },
+ )
+ }
+ }
+
+ fun captureSubtitleAutoSyncTime() {
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L),
+ errorMessage = null,
+ )
+ loadSubtitleAutoSyncCues()
+ }
+
+ fun applySubtitleAutoSyncCue(cue: SubtitleSyncCue) {
+ val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return
+ val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS)
+ .toInt()
+ .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
+ setSubtitleDelay(newDelayMs)
+ }
+
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
errorMessage = null
playerController = null
@@ -1468,12 +1669,28 @@ fun PlayerScreen(
WatchProgressRepository.ensureLoaded()
}
+ LaunchedEffect(playbackSession.videoId) {
+ subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0
+ subtitleAutoSyncState = SubtitleAutoSyncUiState()
+ }
+
+ LaunchedEffect(playerController, subtitleDelayMs) {
+ playerController?.setSubtitleDelayMs(subtitleDelayMs)
+ }
+
+ LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) {
+ subtitleAutoSyncState = SubtitleAutoSyncUiState()
+ }
+
LaunchedEffect(playerController, subtitleStyle) {
playerController?.applySubtitleStyle(subtitleStyle)
}
- LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
+ LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
+ if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
+ return@LaunchedEffect
+ }
if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
autoFetchedAddonSubtitlesForKey = fetchKey
fetchAddonSubtitlesForActiveItem()
@@ -2158,6 +2375,7 @@ fun PlayerScreen(
selectedIndex = selectedAudioIndex,
onTrackSelected = { index ->
selectedAudioIndex = index
+ persistAudioPreference(audioTracks.firstOrNull { it.index == index })
playerController?.selectAudioTrack(index)
scope.launch {
delay(200)
@@ -2172,16 +2390,20 @@ fun PlayerScreen(
activeTab = activeSubtitleTab,
subtitleTracks = subtitleTracks,
selectedSubtitleIndex = selectedSubtitleIndex,
- addonSubtitles = addonSubtitles,
+ addonSubtitles = visibleAddonSubtitles,
selectedAddonSubtitleId = selectedAddonSubtitleId,
isLoadingAddonSubtitles = isLoadingAddonSubtitles,
subtitleStyle = subtitleStyle,
+ subtitleDelayMs = subtitleDelayMs,
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ subtitleAutoSyncState = subtitleAutoSyncState,
onTabSelected = { activeSubtitleTab = it },
onBuiltInTrackSelected = { index ->
val wasCustom = useCustomSubtitles
selectedSubtitleIndex = index
selectedAddonSubtitleId = null
useCustomSubtitles = false
+ persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index })
if (wasCustom) {
playerController?.clearExternalSubtitleAndSelect(index)
} else {
@@ -2192,10 +2414,16 @@ fun PlayerScreen(
selectedAddonSubtitleId = addon.id
selectedSubtitleIndex = -1
useCustomSubtitles = true
+ persistAddonSubtitlePreference(addon)
playerController?.setSubtitleUri(addon.url)
},
onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem,
onStyleChanged = PlayerSettingsRepository::setSubtitleStyle,
+ onSubtitleDelayChanged = ::setSubtitleDelay,
+ onSubtitleDelayReset = { setSubtitleDelay(0) },
+ onAutoSyncCapture = ::captureSubtitleAutoSyncTime,
+ onAutoSyncCueSelected = ::applySubtitleAutoSyncCue,
+ onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) },
onDismiss = { showSubtitleModal = false },
)
@@ -2419,3 +2647,77 @@ private fun findPreferredSubtitleTrackIndex(
return -1
}
+
+private fun filterAddonSubtitlesForSettings(
+ subtitles: List,
+ settings: PlayerSettingsUiState,
+ selectedAddonSubtitleId: String?,
+): List {
+ val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
+ settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
+ if (!shouldFilter) return subtitles
+
+ val targets = preferredSubtitleTargetsForSettings(settings)
+ if (targets.isEmpty()) {
+ return subtitles.filter { subtitle ->
+ subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
+ }
+ }
+
+ val filtered = subtitles.filter { subtitle ->
+ subtitle.id == selectedAddonSubtitleId ||
+ subtitle.url == selectedAddonSubtitleId ||
+ targets.any { target ->
+ languageMatchesPreference(
+ trackLanguage = subtitle.language,
+ targetLanguage = target,
+ )
+ }
+ }
+ return filtered
+}
+
+private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List {
+ val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
+ SubtitleLanguageOption.FORCED
+ } else {
+ settings.preferredSubtitleLanguage
+ }
+ return resolvePreferredSubtitleLanguageTargets(
+ preferredSubtitleLanguage = preferredLanguage,
+ secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
+ deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
+ ).filterNot { it == SubtitleLanguageOption.FORCED }
+}
+
+private fun findPersistedAudioTrackIndex(
+ tracks: List,
+ preference: PersistedPlayerTrackPreference,
+): Int {
+ preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
+ tracks.firstOrNull { it.id == trackId }?.let { return it.index }
+ }
+ preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language ->
+ tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
+ }
+ preference.audioName?.takeIf { it.isNotBlank() }?.let { name ->
+ tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
+ }
+ return -1
+}
+
+private fun findPersistedSubtitleTrackIndex(
+ tracks: List,
+ preference: PersistedPlayerTrackPreference,
+): Int {
+ preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
+ tracks.firstOrNull { it.id == trackId }?.let { return it.index }
+ }
+ preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language ->
+ tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
+ }
+ preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name ->
+ tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
+ }
+ return -1
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index 3ccbfea8c..a2560b1a3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -43,6 +43,7 @@ data class PlayerSettingsUiState(
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
val secondaryPreferredSubtitleLanguage: String? = null,
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
+ val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24,
val decoderPriority: Int = 1,
@@ -99,6 +100,7 @@ object PlayerSettingsRepository {
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
private var secondaryPreferredSubtitleLanguage: String? = null
private var subtitleStyle = SubtitleStyleState.DEFAULT
+ private var addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
private var streamReuseLastLinkEnabled = false
private var streamReuseLastLinkCacheHours = 24
private var decoderPriority = 1
@@ -160,6 +162,7 @@ object PlayerSettingsRepository {
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
secondaryPreferredSubtitleLanguage = null
subtitleStyle = SubtitleStyleState.DEFAULT
+ addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
streamReuseLastLinkEnabled = false
streamReuseLastLinkCacheHours = 24
decoderPriority = 1
@@ -225,13 +228,28 @@ object PlayerSettingsRepository {
subtitleStyle = SubtitleStyleState(
textColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleTextColor())
?: SubtitleStyleState.DEFAULT.textColor,
+ backgroundColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleBackgroundColor())
+ ?: SubtitleStyleState.DEFAULT.backgroundColor,
+ outlineColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleOutlineColor())
+ ?: SubtitleStyleState.DEFAULT.outlineColor,
outlineEnabled = PlayerSettingsStorage.loadSubtitleOutlineEnabled()
?: SubtitleStyleState.DEFAULT.outlineEnabled,
+ outlineWidth = PlayerSettingsStorage.loadSubtitleOutlineWidth()
+ ?: SubtitleStyleState.DEFAULT.outlineWidth,
+ bold = PlayerSettingsStorage.loadSubtitleBold()
+ ?: SubtitleStyleState.DEFAULT.bold,
fontSizeSp = PlayerSettingsStorage.loadSubtitleFontSizeSp()
?: SubtitleStyleState.DEFAULT.fontSizeSp,
bottomOffset = PlayerSettingsStorage.loadSubtitleBottomOffset()
?: SubtitleStyleState.DEFAULT.bottomOffset,
+ useForcedSubtitles = PlayerSettingsStorage.loadSubtitleUseForcedSubtitles()
+ ?: SubtitleStyleState.DEFAULT.useForcedSubtitles,
+ showOnlyPreferredLanguages = PlayerSettingsStorage.loadSubtitleShowOnlyPreferredLanguages()
+ ?: SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages,
)
+ addonSubtitleStartupMode = PlayerSettingsStorage.loadAddonSubtitleStartupMode()
+ ?.let { runCatching { AddonSubtitleStartupMode.valueOf(it) }.getOrNull() }
+ ?: AddonSubtitleStartupMode.ALL_SUBTITLES
streamReuseLastLinkEnabled = PlayerSettingsStorage.loadStreamReuseLastLinkEnabled() ?: false
streamReuseLastLinkCacheHours = PlayerSettingsStorage.loadStreamReuseLastLinkCacheHours() ?: 24
decoderPriority = PlayerSettingsStorage.loadDecoderPriority() ?: 1
@@ -408,9 +426,23 @@ object PlayerSettingsRepository {
subtitleStyle = style
publish()
PlayerSettingsStorage.saveSubtitleTextColor(style.textColor.toStorageHexString())
+ PlayerSettingsStorage.saveSubtitleBackgroundColor(style.backgroundColor.toStorageHexString())
+ PlayerSettingsStorage.saveSubtitleOutlineColor(style.outlineColor.toStorageHexString())
PlayerSettingsStorage.saveSubtitleOutlineEnabled(style.outlineEnabled)
+ PlayerSettingsStorage.saveSubtitleOutlineWidth(style.outlineWidth)
+ PlayerSettingsStorage.saveSubtitleBold(style.bold)
PlayerSettingsStorage.saveSubtitleFontSizeSp(style.fontSizeSp)
PlayerSettingsStorage.saveSubtitleBottomOffset(style.bottomOffset)
+ PlayerSettingsStorage.saveSubtitleUseForcedSubtitles(style.useForcedSubtitles)
+ PlayerSettingsStorage.saveSubtitleShowOnlyPreferredLanguages(style.showOnlyPreferredLanguages)
+ }
+
+ fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
+ ensureLoaded()
+ if (addonSubtitleStartupMode == mode) return
+ addonSubtitleStartupMode = mode
+ publish()
+ PlayerSettingsStorage.saveAddonSubtitleStartupMode(mode.name)
}
fun setStreamReuseLastLinkEnabled(enabled: Boolean) {
@@ -778,6 +810,7 @@ object PlayerSettingsRepository {
preferredSubtitleLanguage = preferredSubtitleLanguage,
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
subtitleStyle = subtitleStyle,
+ addonSubtitleStartupMode = addonSubtitleStartupMode,
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
decoderPriority = decoderPriority,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index 2b07020e0..516f94ecf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -25,12 +25,26 @@ internal expect object PlayerSettingsStorage {
fun saveSecondaryPreferredSubtitleLanguage(language: String?)
fun loadSubtitleTextColor(): String?
fun saveSubtitleTextColor(colorHex: String)
+ fun loadSubtitleBackgroundColor(): String?
+ fun saveSubtitleBackgroundColor(colorHex: String)
+ fun loadSubtitleOutlineColor(): String?
+ fun saveSubtitleOutlineColor(colorHex: String)
fun loadSubtitleOutlineEnabled(): Boolean?
fun saveSubtitleOutlineEnabled(enabled: Boolean)
+ fun loadSubtitleOutlineWidth(): Int?
+ fun saveSubtitleOutlineWidth(width: Int)
+ fun loadSubtitleBold(): Boolean?
+ fun saveSubtitleBold(enabled: Boolean)
fun loadSubtitleFontSizeSp(): Int?
fun saveSubtitleFontSizeSp(fontSizeSp: Int)
fun loadSubtitleBottomOffset(): Int?
fun saveSubtitleBottomOffset(bottomOffset: Int)
+ fun loadSubtitleUseForcedSubtitles(): Boolean?
+ fun saveSubtitleUseForcedSubtitles(enabled: Boolean)
+ fun loadSubtitleShowOnlyPreferredLanguages(): Boolean?
+ fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean)
+ fun loadAddonSubtitleStartupMode(): String?
+ fun saveAddonSubtitleStartupMode(mode: String)
fun loadStreamReuseLastLinkEnabled(): Boolean?
fun saveStreamReuseLastLinkEnabled(enabled: Boolean)
fun loadStreamReuseLastLinkCacheHours(): Int?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt
new file mode 100644
index 000000000..67b52638d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt
@@ -0,0 +1,87 @@
+package com.nuvio.app.features.player
+
+import kotlin.math.max
+
+object PlayerSubtitleCueParser {
+ fun parse(text: String, sourceUrl: String? = null): List {
+ val normalized = text
+ .removePrefix("\uFEFF")
+ .replace("\r\n", "\n")
+ .replace('\r', '\n')
+ .trim()
+ if (normalized.isBlank()) return emptyList()
+
+ return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) {
+ parseWebVtt(normalized)
+ } else {
+ parseSrt(normalized)
+ }
+ }
+
+ private fun parseSrt(text: String): List =
+ text.split(Regex("\n{2,}"))
+ .mapNotNull { block ->
+ val lines = block.lines()
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+ val timingIndex = lines.indexOfFirst { it.contains("-->") }
+ if (timingIndex < 0) return@mapNotNull null
+ val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
+ val body = lines.drop(timingIndex + 1)
+ .joinToString(" ")
+ .cleanSubtitleCueText()
+ if (body.isBlank()) null else SubtitleSyncCue(start, body)
+ }
+ .sortedBy { it.startTimeMs }
+
+ private fun parseWebVtt(text: String): List =
+ text.lines()
+ .dropWhile { it.trim().isEmpty() || it.trim().startsWith("WEBVTT") }
+ .joinToString("\n")
+ .split(Regex("\n{2,}"))
+ .mapNotNull { block ->
+ val lines = block.lines()
+ .map { it.trim() }
+ .filter { it.isNotBlank() && !it.startsWith("NOTE") }
+ val timingIndex = lines.indexOfFirst { it.contains("-->") }
+ if (timingIndex < 0) return@mapNotNull null
+ val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
+ val body = lines.drop(timingIndex + 1)
+ .joinToString(" ")
+ .cleanSubtitleCueText()
+ if (body.isBlank()) null else SubtitleSyncCue(start, body)
+ }
+ .sortedBy { it.startTimeMs }
+
+ private fun parseCueStart(timingLine: String): Long? {
+ val startPart = timingLine.substringBefore("-->").trim()
+ return parseTimestamp(startPart)
+ }
+
+ private fun parseTimestamp(raw: String): Long? {
+ val cleaned = raw.substringBefore(' ').replace(',', '.')
+ val parts = cleaned.split(':')
+ if (parts.size !in 2..3) return null
+
+ val secondsPart = parts.last()
+ val seconds = secondsPart.substringBefore('.').toLongOrNull() ?: return null
+ val millis = secondsPart.substringAfter('.', "")
+ .take(3)
+ .padEnd(3, '0')
+ .toLongOrNull()
+ ?: 0L
+ val minutes = parts[parts.size - 2].toLongOrNull() ?: return null
+ val hours = if (parts.size == 3) parts[0].toLongOrNull() ?: return null else 0L
+
+ return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis)
+ }
+
+ private fun String.cleanSubtitleCueText(): String =
+ replace(Regex("<[^>]+>"), "")
+ .replace(" ", " ")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt
new file mode 100644
index 000000000..e941f20a6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt
@@ -0,0 +1,27 @@
+package com.nuvio.app.features.player
+
+data class PersistedPlayerTrackPreference(
+ val subtitleType: String? = null,
+ val subtitleLanguage: String? = null,
+ val subtitleName: String? = null,
+ val subtitleTrackId: String? = null,
+ val addonSubtitleId: String? = null,
+ val addonSubtitleUrl: String? = null,
+ val addonSubtitleAddonName: String? = null,
+ val audioLanguage: String? = null,
+ val audioName: String? = null,
+ val audioTrackId: String? = null,
+)
+
+object PersistedSubtitleSelectionType {
+ const val INTERNAL = "INTERNAL"
+ const val ADDON = "ADDON"
+ const val DISABLED = "DISABLED"
+}
+
+internal expect object PlayerTrackPreferenceStorage {
+ fun load(contentId: String): PersistedPlayerTrackPreference?
+ fun save(contentId: String, preference: PersistedPlayerTrackPreference)
+ fun loadSubtitleDelayMs(videoId: String): Int?
+ fun saveSubtitleDelayMs(videoId: String, delayMs: Int)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt
index 895e6fdeb..79aa7bb43 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt
@@ -29,6 +29,7 @@ data class AddonSubtitle(
val url: String,
val language: String,
val display: String,
+ val addonName: String? = null,
val isSelected: Boolean = false,
)
@@ -38,17 +39,46 @@ enum class SubtitleTab {
Style,
}
+enum class AddonSubtitleStartupMode {
+ FAST_STARTUP,
+ PREFERRED_ONLY,
+ ALL_SUBTITLES,
+}
+
+const val SUBTITLE_DELAY_MIN_MS = -60_000
+const val SUBTITLE_DELAY_MAX_MS = 60_000
+const val SUBTITLE_DELAY_STEP_MS = 100
+const val SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS = 300L
+
data class SubtitleStyleState(
val textColor: Color = Color.White,
- val outlineEnabled: Boolean = false,
+ val backgroundColor: Color = Color.Transparent,
+ val outlineColor: Color = Color.Black,
+ val outlineEnabled: Boolean = true,
+ val outlineWidth: Int = 2,
+ val bold: Boolean = false,
val fontSizeSp: Int = 18,
val bottomOffset: Int = 20,
+ val useForcedSubtitles: Boolean = false,
+ val showOnlyPreferredLanguages: Boolean = false,
) {
companion object {
val DEFAULT = SubtitleStyleState()
}
}
+data class SubtitleSyncCue(
+ val startTimeMs: Long,
+ val text: String,
+)
+
+data class SubtitleAutoSyncUiState(
+ val capturedPositionMs: Long? = null,
+ val cues: List = emptyList(),
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+)
+
val SubtitleColorSwatches = listOf(
Color.White,
Color(0xFFFFD700),
@@ -62,6 +92,15 @@ val SubtitleColorSwatches = listOf(
Color.Black,
)
+val SubtitleBackgroundColorSwatches = listOf(
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.55f),
+ Color(0xFF111827).copy(alpha = 0.72f),
+ Color(0xFF7F1D1D).copy(alpha = 0.68f),
+ Color(0xFF064E3B).copy(alpha = 0.68f),
+ Color(0xFF1E3A8A).copy(alpha = 0.68f),
+)
+
fun Color.toStorageHexString(): String {
fun component(value: Float): String =
(value * 255f).roundToInt().coerceIn(0, 255).toString(16).padStart(2, '0').uppercase()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt
index e519a8540..415f2e2db 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt
@@ -62,11 +62,19 @@ fun SubtitleModal(
selectedAddonSubtitleId: String?,
isLoadingAddonSubtitles: Boolean,
subtitleStyle: SubtitleStyleState,
+ subtitleDelayMs: Int,
+ selectedAddonSubtitle: AddonSubtitle?,
+ subtitleAutoSyncState: SubtitleAutoSyncUiState,
onTabSelected: (SubtitleTab) -> Unit,
onBuiltInTrackSelected: (Int) -> Unit,
onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
onFetchAddonSubtitles: () -> Unit,
onStyleChanged: (SubtitleStyleState) -> Unit,
+ onSubtitleDelayChanged: (Int) -> Unit,
+ onSubtitleDelayReset: () -> Unit,
+ onAutoSyncCapture: () -> Unit,
+ onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
+ onAutoSyncReload: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -151,8 +159,16 @@ fun SubtitleModal(
)
SubtitleTab.Style -> SubtitleStylePanel(
style = subtitleStyle,
+ subtitleDelayMs = subtitleDelayMs,
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = isCompact,
onStyleChanged = onStyleChanged,
+ onSubtitleDelayChanged = onSubtitleDelayChanged,
+ onSubtitleDelayReset = onSubtitleDelayReset,
+ onAutoSyncCapture = onAutoSyncCapture,
+ onAutoSyncCueSelected = onAutoSyncCueSelected,
+ onAutoSyncReload = onAutoSyncReload,
)
}
}
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 8df539c6f..1a2d41ec5 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
@@ -86,6 +86,7 @@ object SubtitleRepository {
url = url,
language = normalizedLang,
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
+ addonName = addon.displayTitle,
)
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt
index 0f5fc2432..fd3b07fb3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt
@@ -25,17 +25,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
+import kotlin.math.abs
+import kotlin.math.roundToInt
@Composable
fun SubtitleStylePanel(
style: SubtitleStyleState,
+ subtitleDelayMs: Int,
+ selectedAddonSubtitle: AddonSubtitle?,
+ subtitleAutoSyncState: SubtitleAutoSyncUiState,
isCompact: Boolean,
onStyleChanged: (SubtitleStyleState) -> Unit,
+ onSubtitleDelayChanged: (Int) -> Unit,
+ onSubtitleDelayReset: () -> Unit,
+ onAutoSyncCapture: () -> Unit,
+ onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
+ onAutoSyncReload: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val sectionPadding = if (isCompact) 12.dp else 16.dp
@@ -46,10 +57,18 @@ fun SubtitleStylePanel(
) {
StyleControlsCard(
style = style,
+ subtitleDelayMs = subtitleDelayMs,
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = isCompact,
sectionPadding = sectionPadding,
colorScheme = colorScheme,
onStyleChanged = onStyleChanged,
+ onSubtitleDelayChanged = onSubtitleDelayChanged,
+ onSubtitleDelayReset = onSubtitleDelayReset,
+ onAutoSyncCapture = onAutoSyncCapture,
+ onAutoSyncCueSelected = onAutoSyncCueSelected,
+ onAutoSyncReload = onAutoSyncReload,
)
}
}
@@ -57,10 +76,18 @@ fun SubtitleStylePanel(
@Composable
private fun StyleControlsCard(
style: SubtitleStyleState,
+ subtitleDelayMs: Int,
+ selectedAddonSubtitle: AddonSubtitle?,
+ subtitleAutoSyncState: SubtitleAutoSyncUiState,
isCompact: Boolean,
sectionPadding: androidx.compose.ui.unit.Dp,
colorScheme: androidx.compose.material3.ColorScheme,
onStyleChanged: (SubtitleStyleState) -> Unit,
+ onSubtitleDelayChanged: (Int) -> Unit,
+ onSubtitleDelayReset: () -> Unit,
+ onAutoSyncCapture: () -> Unit,
+ onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
+ onAutoSyncReload: () -> Unit,
) {
val btnSize = if (isCompact) 28.dp else 32.dp
val btnRadius = if (isCompact) 14.dp else 16.dp
@@ -78,6 +105,50 @@ private fun StyleControlsCard(
label = stringResource(Res.string.compose_player_style),
)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.compose_player_subtitle_delay),
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ StepperControl(
+ value = formatSubtitleDelay(subtitleDelayMs),
+ onMinus = {
+ onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS))
+ },
+ onPlus = {
+ onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS))
+ },
+ buttonSize = btnSize,
+ buttonRadius = btnRadius,
+ minWidth = 72.dp,
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ SmallActionPill(
+ text = stringResource(Res.string.compose_player_reset),
+ onClick = onSubtitleDelayReset,
+ )
+ }
+
+ AutoSyncControls(
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ state = subtitleAutoSyncState,
+ isCompact = isCompact,
+ onCapture = onAutoSyncCapture,
+ onCueSelected = onAutoSyncCueSelected,
+ onReload = onAutoSyncReload,
+ )
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -137,6 +208,12 @@ private fun StyleControlsCard(
}
}
+ ToggleRow(
+ label = stringResource(Res.string.compose_player_bold),
+ enabled = style.bold,
+ onToggle = { onStyleChanged(style.copy(bold = !style.bold)) },
+ )
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -160,38 +237,47 @@ private fun StyleControlsCard(
)
}
+ ColorPickerRow(
+ label = stringResource(Res.string.compose_player_color),
+ colors = SubtitleColorSwatches,
+ selectedColor = style.textColor,
+ onColorSelected = { onStyleChanged(style.copy(textColor = it)) },
+ )
+
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
+ val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100)
Text(
- text = stringResource(Res.string.compose_player_color),
+ text = stringResource(Res.string.compose_player_text_opacity),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
+ StepperControl(
+ value = "$currentAlphaPercent%",
+ onMinus = {
+ val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f
+ onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
+ },
+ onPlus = {
+ val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f
+ onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
+ },
+ buttonSize = btnSize,
+ buttonRadius = btnRadius,
+ minWidth = 58.dp,
+ )
}
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- SubtitleColorSwatches.forEach { color ->
- val isSelected = style.textColor == color
- Box(
- modifier = Modifier
- .size(22.dp)
- .clip(CircleShape)
- .background(color)
- .border(
- 2.dp,
- if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
- CircleShape,
- )
- .clickable { onStyleChanged(style.copy(textColor = color)) },
- )
- }
- }
+ ColorPickerRow(
+ label = stringResource(Res.string.compose_player_outline_color),
+ colors = SubtitleColorSwatches,
+ selectedColor = style.outlineColor,
+ onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) },
+ )
Row(
modifier = Modifier.fillMaxWidth(),
@@ -216,6 +302,213 @@ private fun StyleControlsCard(
}
}
+@Composable
+private fun AutoSyncControls(
+ selectedAddonSubtitle: AddonSubtitle?,
+ state: SubtitleAutoSyncUiState,
+ isCompact: Boolean,
+ onCapture: () -> Unit,
+ onCueSelected: (SubtitleSyncCue) -> Unit,
+ onReload: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val capturedPositionMs = state.capturedPositionMs
+ val nearestCues = if (capturedPositionMs == null) {
+ emptyList()
+ } else {
+ state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(colorScheme.surface.copy(alpha = 0.55f))
+ .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
+ .padding(if (isCompact) 10.dp else 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.compose_player_auto_sync),
+ color = colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ SmallActionPill(
+ text = stringResource(Res.string.compose_player_reload),
+ enabled = selectedAddonSubtitle != null,
+ onClick = onReload,
+ )
+ SmallActionPill(
+ text = stringResource(Res.string.compose_player_capture_line),
+ enabled = selectedAddonSubtitle != null,
+ onClick = onCapture,
+ )
+ }
+ }
+
+ if (selectedAddonSubtitle == null) {
+ Text(
+ text = stringResource(Res.string.compose_player_select_addon_subtitle_first),
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ )
+ return@Column
+ }
+
+ if (state.isLoading) {
+ Text(
+ text = stringResource(Res.string.compose_player_loading_lines),
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ )
+ }
+
+ state.errorMessage?.let { message ->
+ Text(
+ text = message,
+ color = colorScheme.error,
+ fontSize = 12.sp,
+ )
+ }
+
+ if (capturedPositionMs != null && nearestCues.isNotEmpty()) {
+ nearestCues.forEach { cue ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(colorScheme.surfaceVariant.copy(alpha = 0.52f))
+ .clickable { onCueSelected(cue) }
+ .padding(horizontal = 8.dp, vertical = 7.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = formatCueTimestamp(cue.startTimeMs),
+ color = colorScheme.primary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = cue.text,
+ color = colorScheme.onSurface,
+ fontSize = 12.sp,
+ maxLines = 2,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ToggleRow(
+ label: String,
+ enabled: Boolean,
+ onToggle: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = label,
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ SmallActionPill(
+ text = if (enabled) stringResource(Res.string.compose_action_on)
+ else stringResource(Res.string.compose_action_off),
+ selected = enabled,
+ onClick = onToggle,
+ )
+ }
+}
+
+@Composable
+private fun ColorPickerRow(
+ label: String,
+ colors: List,
+ selectedColor: Color,
+ onColorSelected: (Color) -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = label,
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ colors.forEach { color ->
+ val isSelected = selectedColor == color
+ Box(
+ modifier = Modifier
+ .size(22.dp)
+ .clip(CircleShape)
+ .background(if (color.alpha == 0f) colorScheme.surface else color)
+ .border(
+ 2.dp,
+ if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
+ CircleShape,
+ )
+ .clickable { onColorSelected(color) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SmallActionPill(
+ text: String,
+ enabled: Boolean = true,
+ selected: Boolean = false,
+ onClick: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ when {
+ selected -> colorScheme.primaryContainer
+ enabled -> colorScheme.surface.copy(alpha = 0.82f)
+ else -> colorScheme.surfaceVariant.copy(alpha = 0.48f)
+ }
+ )
+ .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp))
+ .clickable(enabled = enabled, onClick = onClick)
+ .padding(horizontal = 9.dp, vertical = 7.dp),
+ ) {
+ Text(
+ text = text,
+ color = when {
+ selected -> colorScheme.onPrimaryContainer
+ enabled -> colorScheme.onSurface
+ else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f)
+ },
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ )
+ }
+}
+
@Composable
private fun StepperControl(
value: String,
@@ -310,3 +603,18 @@ private fun SectionHeader(
)
}
}
+
+private fun formatSubtitleDelay(delayMs: Int): String {
+ val sign = if (delayMs >= 0) "+" else "-"
+ val absMs = abs(delayMs)
+ val seconds = absMs / 1000
+ val millis = absMs % 1000
+ return "$sign$seconds.${millis.toString().padStart(3, '0')}s"
+}
+
+private fun formatCueTimestamp(timeMs: Long): String {
+ val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L)
+ val minutes = totalSeconds / 60L
+ val seconds = totalSeconds % 60L
+ return "${minutes}:${seconds.toString().padStart(2, '0')}"
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
index 5f09a12ff..313216d61 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
@@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
+import com.nuvio.app.features.player.AddonSubtitleStartupMode
import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp
@@ -61,9 +63,12 @@ import com.nuvio.app.features.player.IosTargetPrimaries
import com.nuvio.app.features.player.IosTargetTransfer
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES
+import com.nuvio.app.features.player.SubtitleBackgroundColorSwatches
+import com.nuvio.app.features.player.SubtitleColorSwatches
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
import com.nuvio.app.features.player.languageLabelForCode
+import com.nuvio.app.features.player.toStorageHexString
import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.streams.StreamAutoPlayMode
@@ -121,6 +126,17 @@ private fun formatStep(value: Float): String {
}
}
+@Composable
+private fun addonSubtitleStartupModeLabel(mode: AddonSubtitleStartupMode): String =
+ when (mode) {
+ AddonSubtitleStartupMode.FAST_STARTUP ->
+ stringResource(Res.string.settings_playback_addon_subtitle_startup_fast)
+ AddonSubtitleStartupMode.PREFERRED_ONLY ->
+ stringResource(Res.string.settings_playback_addon_subtitle_startup_preferred)
+ AddonSubtitleStartupMode.ALL_SUBTITLES ->
+ stringResource(Res.string.settings_playback_addon_subtitle_startup_all)
+ }
+
fun snapToStep(value: Float, step: Float): Float {
return (value / step).roundToInt() * step
}
@@ -154,6 +170,64 @@ fun ValueBox(
}
}
+@Composable
+private fun SettingsSliderRow(
+ title: String,
+ value: Int,
+ valueText: String,
+ valueRange: IntRange,
+ step: Int,
+ isTablet: Boolean,
+ onValueChange: (Int) -> Unit,
+) {
+ val horizontalPadding = if (isTablet) 20.dp else 16.dp
+ var sliderValue by remember(value) { mutableFloatStateOf(value.toFloat()) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ )
+ ValueBox(text = valueText, modifier = Modifier.wrapContentWidth())
+ }
+ Slider(
+ value = sliderValue.coerceIn(valueRange.first.toFloat(), valueRange.last.toFloat()),
+ onValueChange = { sliderValue = snapToStep(it, step.toFloat()) },
+ onValueChangeFinished = {
+ onValueChange(sliderValue.roundToInt().coerceIn(valueRange.first, valueRange.last))
+ },
+ valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),
+ steps = calculateSteps(valueRange.first.toFloat(), valueRange.last.toFloat(), step.toFloat()),
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+private fun subtitleColorLabel(color: Color): String {
+ return if (color.alpha == 0f) {
+ stringResource(Res.string.settings_playback_subtitle_color_transparent)
+ } else {
+ color.toStorageHexString()
+ }
+}
+
@Composable
private fun PlaybackSettingsSection(
isTablet: Boolean,
@@ -176,6 +250,10 @@ private fun PlaybackSettingsSection(
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
+ var showAddonSubtitleStartupModeDialog by remember { mutableStateOf(false) }
+ var showSubtitleTextColorDialog by remember { mutableStateOf(false) }
+ var showSubtitleBackgroundColorDialog by remember { mutableStateOf(false) }
+ var showSubtitleOutlineColorDialog by remember { mutableStateOf(false) }
var showExternalPlayerDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
@@ -314,6 +392,131 @@ private fun PlaybackSettingsSection(
isTablet = isTablet,
onClick = { showSecondarySubtitleDialog = true },
)
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_subtitle_use_forced),
+ description = stringResource(Res.string.settings_playback_subtitle_use_forced_description),
+ checked = autoPlayPlayerSettings.subtitleStyle.useForcedSubtitles,
+ isTablet = isTablet,
+ onCheckedChange = { enabled ->
+ PlayerSettingsRepository.setSubtitleStyle(
+ autoPlayPlayerSettings.subtitleStyle.copy(useForcedSubtitles = enabled),
+ )
+ },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_subtitle_show_preferred_only),
+ description = stringResource(Res.string.settings_playback_subtitle_show_preferred_only_description),
+ checked = autoPlayPlayerSettings.subtitleStyle.showOnlyPreferredLanguages,
+ isTablet = isTablet,
+ onCheckedChange = { enabled ->
+ PlayerSettingsRepository.setSubtitleStyle(
+ autoPlayPlayerSettings.subtitleStyle.copy(showOnlyPreferredLanguages = enabled),
+ )
+ },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
+ description = addonSubtitleStartupModeLabel(autoPlayPlayerSettings.addonSubtitleStartupMode),
+ isTablet = isTablet,
+ onClick = { showAddonSubtitleStartupModeDialog = true },
+ )
+ }
+ }
+
+ SettingsSection(
+ title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ val subtitleStyle = autoPlayPlayerSettings.subtitleStyle
+ SettingsSliderRow(
+ title = stringResource(Res.string.settings_playback_subtitle_size),
+ value = subtitleStyle.fontSizeSp,
+ valueText = stringResource(Res.string.compose_player_font_size_value, subtitleStyle.fontSizeSp),
+ valueRange = 12..40,
+ step = 2,
+ isTablet = isTablet,
+ onValueChange = { value ->
+ PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(fontSizeSp = value))
+ },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSliderRow(
+ title = stringResource(Res.string.settings_playback_subtitle_vertical_offset),
+ value = subtitleStyle.bottomOffset,
+ valueText = subtitleStyle.bottomOffset.toString(),
+ valueRange = 0..200,
+ step = 5,
+ isTablet = isTablet,
+ onValueChange = { value ->
+ PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bottomOffset = value))
+ },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_subtitle_bold),
+ description = stringResource(Res.string.settings_playback_subtitle_bold_description),
+ checked = subtitleStyle.bold,
+ isTablet = isTablet,
+ onCheckedChange = { enabled ->
+ PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = enabled))
+ },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_subtitle_text_color),
+ description = subtitleColorLabel(subtitleStyle.textColor),
+ isTablet = isTablet,
+ onClick = { showSubtitleTextColorDialog = true },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_subtitle_background_color),
+ description = subtitleColorLabel(subtitleStyle.backgroundColor),
+ isTablet = isTablet,
+ onClick = { showSubtitleBackgroundColorDialog = true },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_subtitle_outline),
+ description = stringResource(Res.string.settings_playback_subtitle_outline_description),
+ checked = subtitleStyle.outlineEnabled,
+ isTablet = isTablet,
+ onCheckedChange = { enabled ->
+ PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = enabled))
+ },
+ )
+ if (subtitleStyle.outlineEnabled) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_subtitle_outline_color),
+ description = subtitleColorLabel(subtitleStyle.outlineColor),
+ isTablet = isTablet,
+ onClick = { showSubtitleOutlineColorDialog = true },
+ )
+ }
+ if (!isIos) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_enable_libass),
+ description = stringResource(Res.string.settings_playback_enable_libass_description),
+ checked = useLibass,
+ isTablet = isTablet,
+ onCheckedChange = PlayerSettingsRepository::setUseLibass,
+ )
+ if (useLibass) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_render_type),
+ description = libassRenderTypeLabel(libassRenderType),
+ isTablet = isTablet,
+ onClick = { showLibassRenderTypeDialog = true },
+ )
+ }
+ }
}
}
@@ -545,32 +748,6 @@ private fun PlaybackSettingsSection(
}
}
- if (!isIos) {
- SettingsSection(
- title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
- isTablet = isTablet,
- ) {
- SettingsGroup(isTablet = isTablet) {
- SettingsSwitchRow(
- title = stringResource(Res.string.settings_playback_enable_libass),
- description = stringResource(Res.string.settings_playback_enable_libass_description),
- checked = useLibass,
- isTablet = isTablet,
- onCheckedChange = PlayerSettingsRepository::setUseLibass,
- )
- if (useLibass) {
- SettingsGroupDivider(isTablet = isTablet)
- SettingsNavigationRow(
- title = stringResource(Res.string.settings_playback_render_type),
- description = libassRenderTypeLabel(libassRenderType),
- isTablet = isTablet,
- onClick = { showLibassRenderTypeDialog = true },
- )
- }
- }
- }
- }
-
SettingsSection(
title = stringResource(Res.string.settings_playback_section_skip_segments),
isTablet = isTablet,
@@ -877,6 +1054,56 @@ private fun PlaybackSettingsSection(
)
}
+ if (showAddonSubtitleStartupModeDialog) {
+ AddonSubtitleStartupModeDialog(
+ selectedMode = autoPlayPlayerSettings.addonSubtitleStartupMode,
+ onModeSelected = {
+ PlayerSettingsRepository.setAddonSubtitleStartupMode(it)
+ showAddonSubtitleStartupModeDialog = false
+ },
+ onDismiss = { showAddonSubtitleStartupModeDialog = false },
+ )
+ }
+
+ if (showSubtitleTextColorDialog) {
+ SubtitleColorDialog(
+ title = stringResource(Res.string.settings_playback_subtitle_text_color),
+ colors = SubtitleColorSwatches,
+ selectedColor = autoPlayPlayerSettings.subtitleStyle.textColor,
+ onColorSelected = { color ->
+ PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(textColor = color))
+ showSubtitleTextColorDialog = false
+ },
+ onDismiss = { showSubtitleTextColorDialog = false },
+ )
+ }
+
+ if (showSubtitleBackgroundColorDialog) {
+ SubtitleColorDialog(
+ title = stringResource(Res.string.settings_playback_subtitle_background_color),
+ colors = SubtitleBackgroundColorSwatches,
+ selectedColor = autoPlayPlayerSettings.subtitleStyle.backgroundColor,
+ onColorSelected = { color ->
+ PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(backgroundColor = color))
+ showSubtitleBackgroundColorDialog = false
+ },
+ onDismiss = { showSubtitleBackgroundColorDialog = false },
+ )
+ }
+
+ if (showSubtitleOutlineColorDialog) {
+ SubtitleColorDialog(
+ title = stringResource(Res.string.settings_playback_subtitle_outline_color),
+ colors = SubtitleColorSwatches,
+ selectedColor = autoPlayPlayerSettings.subtitleStyle.outlineColor,
+ onColorSelected = { color ->
+ PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(outlineColor = color))
+ showSubtitleOutlineColorDialog = false
+ },
+ onDismiss = { showSubtitleOutlineColorDialog = false },
+ )
+ }
+
if (showReuseCacheDurationDialog) {
ReuseCacheDurationDialog(
selectedHours = streamReuseLastLinkCacheHours,
@@ -1697,6 +1924,209 @@ private fun LibassRenderTypeDialog(
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun AddonSubtitleStartupModeDialog(
+ selectedMode: AddonSubtitleStartupMode,
+ onModeSelected: (AddonSubtitleStartupMode) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val options = listOf(
+ Triple(
+ AddonSubtitleStartupMode.FAST_STARTUP,
+ Res.string.settings_playback_addon_subtitle_startup_fast,
+ Res.string.settings_playback_addon_subtitle_startup_fast_description,
+ ),
+ Triple(
+ AddonSubtitleStartupMode.PREFERRED_ONLY,
+ Res.string.settings_playback_addon_subtitle_startup_preferred,
+ Res.string.settings_playback_addon_subtitle_startup_preferred_description,
+ ),
+ Triple(
+ AddonSubtitleStartupMode.ALL_SUBTITLES,
+ Res.string.settings_playback_addon_subtitle_startup_all,
+ Res.string.settings_playback_addon_subtitle_startup_all_description,
+ ),
+ )
+
+ BasicAlertDialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ options.forEach { (mode, titleRes, descriptionRes) ->
+ val isSelected = mode == selectedMode
+ val containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onModeSelected(mode) },
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(titleRes),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun SubtitleColorDialog(
+ title: String,
+ colors: List,
+ selectedColor: Color,
+ onColorSelected: (Color) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BasicAlertDialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ colors.forEach { color ->
+ val isSelected = selectedColor.toStorageHexString() == color.toStorageHexString()
+ val containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onColorSelected(color) },
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Surface(
+ modifier = Modifier.size(28.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = if (color.alpha == 0f) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ color
+ },
+ border = BorderStroke(
+ 1.dp,
+ MaterialTheme.colorScheme.outline.copy(alpha = 0.45f),
+ ),
+ ) {}
+ Spacer(modifier = Modifier.size(12.dp))
+ Text(
+ text = subtitleColorLabel(color),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(Res.string.settings_playback_dialog_close),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun StreamAutoPlayModeDialog(
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
index 9012a96c9..423c7ba75 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
@@ -49,9 +49,13 @@ interface NuvioPlayerBridge {
fun setSubtitleUrl(url: String)
fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackId: Int)
+ fun setSubtitleDelayMs(delayMs: Int)
fun applySubtitleStyle(
textColor: String,
+ backgroundColor: String,
+ outlineColor: String,
outlineSize: Float,
+ bold: Boolean,
fontSize: Float,
subPos: Int,
)
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 733bf1629..6c79b4132 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
@@ -206,10 +206,17 @@ actual fun PlatformPlayerSurface(
bridge.clearExternalSubtitleAndSelect(trackId)
}
+ override fun setSubtitleDelayMs(delayMs: Int) {
+ bridge.setSubtitleDelayMs(delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
+ }
+
override fun applySubtitleStyle(style: SubtitleStyleState) {
bridge.applySubtitleStyle(
textColor = style.textColor.toMpvColorString(),
- outlineSize = if (style.outlineEnabled) 1.65f else 0f,
+ backgroundColor = style.backgroundColor.toMpvColorString(),
+ outlineColor = style.outlineColor.toMpvColorString(),
+ outlineSize = if (style.outlineEnabled) style.outlineWidth.toFloat() else 0f,
+ bold = style.bold,
fontSize = style.toMpvSubtitleFontSize(),
subPos = style.toMpvSubtitlePosition(),
)
@@ -313,11 +320,13 @@ private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettin
}
private fun Color.toMpvColorString(): String {
+ val alphaInt = (alpha * 255f).toInt().coerceIn(0, 255)
val redInt = (red * 255f).toInt().coerceIn(0, 255)
val greenInt = (green * 255f).toInt().coerceIn(0, 255)
val blueInt = (blue * 255f).toInt().coerceIn(0, 255)
return buildString {
append('#')
+ append(alphaInt.toHexByte())
append(redInt.toHexByte())
append(greenInt.toHexByte())
append(blueInt.toHexByte())
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index 48649658c..3d68ffa49 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -28,9 +28,16 @@ actual object PlayerSettingsStorage {
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
private const val subtitleTextColorKey = "subtitle_text_color"
+ private const val subtitleBackgroundColorKey = "subtitle_background_color"
+ private const val subtitleOutlineColorKey = "subtitle_outline_color"
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
+ private const val subtitleOutlineWidthKey = "subtitle_outline_width"
+ private const val subtitleBoldKey = "subtitle_bold"
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
+ private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
+ private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
+ private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
private const val decoderPriorityKey = "decoder_priority"
@@ -81,9 +88,16 @@ actual object PlayerSettingsStorage {
preferredSubtitleLanguageKey,
secondaryPreferredSubtitleLanguageKey,
subtitleTextColorKey,
+ subtitleBackgroundColorKey,
+ subtitleOutlineColorKey,
subtitleOutlineEnabledKey,
+ subtitleOutlineWidthKey,
+ subtitleBoldKey,
subtitleFontSizeSpKey,
subtitleBottomOffsetKey,
+ subtitleUseForcedSubtitlesKey,
+ subtitleShowOnlyPreferredLanguagesKey,
+ addonSubtitleStartupModeKey,
streamReuseLastLinkEnabledKey,
streamReuseLastLinkCacheHoursKey,
decoderPriorityKey,
@@ -286,6 +300,26 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleTextColorKey))
}
+ actual fun loadSubtitleBackgroundColor(): String? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(subtitleBackgroundColorKey)
+ return defaults.stringForKey(key)
+ }
+
+ actual fun saveSubtitleBackgroundColor(colorHex: String) {
+ NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleBackgroundColorKey))
+ }
+
+ actual fun loadSubtitleOutlineColor(): String? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(subtitleOutlineColorKey)
+ return defaults.stringForKey(key)
+ }
+
+ actual fun saveSubtitleOutlineColor(colorHex: String) {
+ NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleOutlineColorKey))
+ }
+
actual fun loadSubtitleOutlineEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
@@ -300,6 +334,18 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(subtitleOutlineEnabledKey))
}
+ actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey)
+
+ actual fun saveSubtitleOutlineWidth(width: Int) {
+ saveInt(subtitleOutlineWidthKey, width)
+ }
+
+ actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey)
+
+ actual fun saveSubtitleBold(enabled: Boolean) {
+ saveBoolean(subtitleBoldKey, enabled)
+ }
+
actual fun loadSubtitleFontSizeSp(): Int? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
@@ -328,6 +374,28 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setInteger(bottomOffset.toLong(), forKey = ProfileScopedKey.of(subtitleBottomOffsetKey))
}
+ actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey)
+
+ actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
+ saveBoolean(subtitleUseForcedSubtitlesKey, enabled)
+ }
+
+ actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey)
+
+ actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
+ saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled)
+ }
+
+ actual fun loadAddonSubtitleStartupMode(): String? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(addonSubtitleStartupModeKey)
+ return defaults.stringForKey(key)
+ }
+
+ actual fun saveAddonSubtitleStartupMode(mode: String) {
+ NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(addonSubtitleStartupModeKey))
+ }
+
actual fun loadStreamReuseLastLinkEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
@@ -722,9 +790,16 @@ actual object PlayerSettingsStorage {
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
+ loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
+ loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
+ loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
+ loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
+ loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
+ loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
+ loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
@@ -779,9 +854,16 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
+ payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
+ payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
+ payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
+ payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
+ payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
+ payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
+ payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt
new file mode 100644
index 000000000..f48941f86
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt
@@ -0,0 +1,102 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.core.storage.ProfileScopedKey
+import platform.Foundation.NSUserDefaults
+
+internal actual object PlayerTrackPreferenceStorage {
+ private const val subtitleTypeKey = "subtitle_type"
+ private const val subtitleLanguageKey = "subtitle_language"
+ private const val subtitleNameKey = "subtitle_name"
+ private const val subtitleTrackIdKey = "subtitle_track_id"
+ private const val addonSubtitleIdKey = "addon_subtitle_id"
+ private const val addonSubtitleUrlKey = "addon_subtitle_url"
+ private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
+ private const val audioLanguageKey = "audio_language"
+ private const val audioNameKey = "audio_name"
+ private const val audioTrackIdKey = "audio_track_id"
+ private const val subtitleDelayMsKey = "subtitle_delay_ms"
+
+ actual fun load(contentId: String): PersistedPlayerTrackPreference? {
+ val id = contentId.normalizedStorageId() ?: return null
+ val preference = PersistedPlayerTrackPreference(
+ subtitleType = loadString(subtitleTypeKey, id),
+ subtitleLanguage = loadString(subtitleLanguageKey, id),
+ subtitleName = loadString(subtitleNameKey, id),
+ subtitleTrackId = loadString(subtitleTrackIdKey, id),
+ addonSubtitleId = loadString(addonSubtitleIdKey, id),
+ addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
+ addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
+ audioLanguage = loadString(audioLanguageKey, id),
+ audioName = loadString(audioNameKey, id),
+ audioTrackId = loadString(audioTrackIdKey, id),
+ )
+ return preference.takeIf {
+ listOf(
+ it.subtitleType,
+ it.subtitleLanguage,
+ it.subtitleName,
+ it.subtitleTrackId,
+ it.addonSubtitleId,
+ it.addonSubtitleUrl,
+ it.addonSubtitleAddonName,
+ it.audioLanguage,
+ it.audioName,
+ it.audioTrackId,
+ ).any { value -> !value.isNullOrBlank() }
+ }
+ }
+
+ actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
+ val id = contentId.normalizedStorageId() ?: return
+ saveOptionalString(subtitleTypeKey, id, preference.subtitleType)
+ saveOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
+ saveOptionalString(subtitleNameKey, id, preference.subtitleName)
+ saveOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
+ saveOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
+ saveOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
+ saveOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
+ saveOptionalString(audioLanguageKey, id, preference.audioLanguage)
+ saveOptionalString(audioNameKey, id, preference.audioName)
+ saveOptionalString(audioTrackIdKey, id, preference.audioTrackId)
+ }
+
+ actual fun loadSubtitleDelayMs(videoId: String): Int? {
+ val id = videoId.normalizedStorageId() ?: return null
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = scopedKey(subtitleDelayMsKey, id)
+ return if (defaults.objectForKey(key) != null) {
+ defaults.integerForKey(key).toInt()
+ } else {
+ null
+ }
+ }
+
+ actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
+ val id = videoId.normalizedStorageId() ?: return
+ NSUserDefaults.standardUserDefaults.setInteger(
+ delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS).toLong(),
+ forKey = scopedKey(subtitleDelayMsKey, id),
+ )
+ }
+
+ private fun loadString(field: String, contentId: String): String? =
+ NSUserDefaults.standardUserDefaults
+ .stringForKey(scopedKey(field, contentId))
+ ?.takeIf { it.isNotBlank() }
+
+ private fun saveOptionalString(field: String, contentId: String, value: String?) {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = scopedKey(field, contentId)
+ if (value.isNullOrBlank()) {
+ defaults.removeObjectForKey(key)
+ } else {
+ defaults.setObject(value, forKey = key)
+ }
+ }
+
+ private fun scopedKey(field: String, contentId: String): String =
+ ProfileScopedKey.of("$field|$contentId")
+
+ private fun String.normalizedStorageId(): String? =
+ trim().takeIf { it.isNotBlank() }
+}
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index afcdc6019..41d492574 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -113,10 +113,22 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) }
func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() }
func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) }
- func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int32) {
+ func setSubtitleDelayMs(delayMs: Int32) { playerVC?.setSubtitleDelayMs(Int(delayMs)) }
+ func applySubtitleStyle(
+ textColor: String,
+ backgroundColor: String,
+ outlineColor: String,
+ outlineSize: Float,
+ bold: Bool,
+ fontSize: Float,
+ subPos: Int32
+ ) {
playerVC?.applySubtitleStyle(
textColor: textColor,
+ backgroundColor: backgroundColor,
+ outlineColor: outlineColor,
outlineSize: outlineSize,
+ bold: bold,
fontSize: fontSize,
subPos: Int(subPos)
)
@@ -575,12 +587,29 @@ final class MPVPlayerViewController: UIViewController {
}
}
- func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) {
+ func setSubtitleDelayMs(_ delayMs: Int) {
+ guard mpv != nil else { return }
+ var delaySeconds = Double(max(-60_000, min(60_000, delayMs))) / 1000.0
+ checkError(mpv_set_property(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds))
+ }
+
+ func applySubtitleStyle(
+ textColor: String,
+ backgroundColor: String,
+ outlineColor: String,
+ outlineSize: Float,
+ bold: Bool,
+ fontSize: Float,
+ subPos: Int
+ ) {
guard mpv != nil else { return }
checkError(mpv_set_property_string(mpv, "sub-ass-override", "force"))
checkError(mpv_set_property_string(mpv, "sub-color", textColor))
- checkError(mpv_set_property_string(mpv, "sub-outline-color", "#000000"))
+ checkError(mpv_set_property_string(mpv, "sub-back-color", backgroundColor))
+ checkError(mpv_set_property_string(mpv, "sub-outline-color", outlineColor))
+ checkError(mpv_set_property_string(mpv, "sub-border-style", backgroundColor.hasPrefix("#00") ? "outline-and-shadow" : "opaque-box"))
+ setStringProperty("sub-bold", bold ? "yes" : "no")
var outline = Double(outlineSize)
checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline))
From 0c0c616bed34d3b5d925d99a5aef0e46ea1e2152 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 11:31:54 +0530
Subject: [PATCH 08/12] fix: persist audiotrack on stream reload
fixes #1163
---
.../features/player/PlayerEngine.android.kt | 214 ++++++++++++++----
1 file changed, 173 insertions(+), 41 deletions(-)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
index 9e017222f..9d2c97bd5 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
@@ -30,6 +30,7 @@ import org.jetbrains.compose.resources.getString
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
+import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
@@ -119,6 +120,43 @@ actual fun PlatformPlayerSurface(
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
+ val extractorsFactory = remember {
+ DefaultExtractorsFactory()
+ .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
+ .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
+ }
+ val dataSourceFactory = remember(
+ context,
+ sanitizedSourceHeaders,
+ sanitizedSourceResponseHeaders,
+ useYoutubeChunkedPlayback,
+ ) {
+ PlatformPlaybackDataSourceFactory.create(
+ context = context,
+ defaultRequestHeaders = sanitizedSourceHeaders,
+ defaultResponseHeaders = sanitizedSourceResponseHeaders,
+ useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
+ )
+ }
+
+ fun ExoPlayer.setPlaybackMediaItem(videoMediaItem: MediaItem, startPositionMs: Long? = null) {
+ if (!sourceAudioUrl.isNullOrBlank()) {
+ val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
+ val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem)
+ val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
+ val mergedSource = MergingMediaSource(videoSource, audioSource)
+ if (startPositionMs != null) {
+ setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L))
+ } else {
+ setMediaSource(mergedSource)
+ }
+ } else if (startPositionMs != null) {
+ setMediaItem(videoMediaItem, startPositionMs.coerceAtLeast(0L))
+ } else {
+ setMediaItem(videoMediaItem)
+ }
+ }
+
val exoPlayer = remember(
sourceUrl,
sourceAudioUrl,
@@ -158,17 +196,6 @@ actual fun PlatformPlayerSurface(
)
.build()
- val extractorsFactory = DefaultExtractorsFactory()
- .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
- .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
-
- val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
- context = context,
- defaultRequestHeaders = sanitizedSourceHeaders,
- defaultResponseHeaders = sanitizedSourceResponseHeaders,
- useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
- )
-
val player = if (useLibass) {
ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
@@ -195,21 +222,17 @@ actual fun PlatformPlayerSurface(
}
player.apply {
- if (!sourceAudioUrl.isNullOrBlank()) {
- val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
- val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
- val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
- setMediaSource(MergingMediaSource(videoSource, audioSource))
- } else {
- setMediaItem(MediaItem.fromUri(sourceUrl))
- }
- fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) }
- prepare()
- this.playWhenReady = playWhenReady
- }
+ setPlaybackMediaItem(
+ videoMediaItem = MediaItem.fromUri(sourceUrl),
+ startPositionMs = fallbackStartPositionMs,
+ )
+ prepare()
+ this.playWhenReady = playWhenReady
+ }
}
val pendingSubtitleTrackIndex = remember { mutableListOf() }
+ val pendingAudioTrackSelection = remember { mutableListOf() }
var playerViewRef by remember { mutableStateOf(null) }
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf(null) }
@@ -218,6 +241,13 @@ actual fun PlatformPlayerSurface(
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
}
+ fun preserveAudioSelectionForReload(reason: String) {
+ pendingAudioTrackSelection.clear()
+ val selection = exoPlayer.captureSelectedTrack(C.TRACK_TYPE_AUDIO) ?: return
+ pendingAudioTrackSelection.add(selection)
+ Log.d(TAG, "$reason: preserving audio track index=${selection.index} id=${selection.id}")
+ }
+
DisposableEffect(exoPlayer) {
PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause()
@@ -274,6 +304,13 @@ actual fun PlatformPlayerSurface(
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
Log.d(TAG, "onTracksChanged: ${tracks.groups.size} groups total")
exoPlayer.logCurrentTracks("onTracksChanged")
+ pendingAudioTrackSelection.firstOrNull()?.let { selection ->
+ if (tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO }) {
+ pendingAudioTrackSelection.clear()
+ val restored = exoPlayer.restoreTrackSelection(selection)
+ Log.d(TAG, "onTracksChanged: restored pending audio selection=$restored")
+ }
+ }
if (pendingSubtitleTrackIndex.isNotEmpty() && tracks.groups.isNotEmpty()) {
val idx = pendingSubtitleTrackIndex.removeAt(0)
Log.d(TAG, "onTracksChanged: applying pending subtitle selection index=$idx")
@@ -401,6 +438,7 @@ actual fun PlatformPlayerSurface(
Log.e(TAG, "setSubtitleUri: currentMediaItem is null, aborting")
return@launch
}
+ preserveAudioSelectionForReload("setSubtitleUri")
val resolvedMime = withContext(Dispatchers.IO) {
resolveSubtitleMimeType(url)
}
@@ -426,7 +464,7 @@ actual fun PlatformPlayerSurface(
.setPreferredTextRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
Log.d(TAG, "setSubtitleUri: track params set before prepare, textDisabled=${exoPlayer.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)}")
- exoPlayer.setMediaItem(newMediaItem, currentPosition)
+ exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "setSubtitleUri: prepare() called, waiting for STATE_READY")
@@ -440,10 +478,11 @@ actual fun PlatformPlayerSurface(
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
+ preserveAudioSelectionForReload("clearExternalSubtitle")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
- exoPlayer.setMediaItem(newMediaItem, currentPosition)
+ exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitle: done, position=$currentPosition")
@@ -458,10 +497,11 @@ actual fun PlatformPlayerSurface(
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
+ preserveAudioSelectionForReload("clearExternalSubtitleAndSelect")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
- exoPlayer.setMediaItem(newMediaItem, currentPosition)
+ exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitleAndSelect: done, pending=$trackIndex position=$currentPosition")
@@ -544,6 +584,86 @@ private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
playWhenReady &&
playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY)
+private data class TrackSelectionSnapshot(
+ val trackType: Int,
+ val index: Int,
+ val id: String?,
+ val language: String?,
+ val label: String?,
+ val sampleMimeType: String?,
+ val codecs: String?,
+ val channelCount: Int,
+ val roleFlags: Int,
+)
+
+private fun ExoPlayer.captureSelectedTrack(trackType: Int): TrackSelectionSnapshot? {
+ var idx = 0
+ for (group in currentTracks.groups) {
+ if (group.type != trackType) continue
+ if (group.isSelected) {
+ val format = group.mediaTrackGroup.getFormat(0)
+ return TrackSelectionSnapshot(
+ trackType = trackType,
+ index = idx,
+ id = format.id,
+ language = format.language,
+ label = format.label,
+ sampleMimeType = format.sampleMimeType,
+ codecs = format.codecs,
+ channelCount = format.channelCount,
+ roleFlags = format.roleFlags,
+ )
+ }
+ idx++
+ }
+ return null
+}
+
+private fun ExoPlayer.restoreTrackSelection(selection: TrackSelectionSnapshot): Boolean {
+ selection.id?.takeIf { it.isNotBlank() }?.let { id ->
+ val restored = selectTrackByPredicate(selection.trackType, "id=$id") { _, format ->
+ format.id == id
+ }
+ if (restored) {
+ return true
+ }
+ }
+
+ selection.label?.takeIf { it.isNotBlank() }?.let { label ->
+ val restored = selectTrackByPredicate(selection.trackType, "label=$label") { _, format ->
+ format.label.equals(label, ignoreCase = true) &&
+ (selection.language.isNullOrBlank() ||
+ format.language.equals(selection.language, ignoreCase = true))
+ }
+ if (restored) {
+ return true
+ }
+ }
+
+ val technicalMatchIndexes = mutableListOf()
+ var idx = 0
+ for (group in currentTracks.groups) {
+ if (group.type != selection.trackType) continue
+ val format = group.mediaTrackGroup.getFormat(0)
+ if (
+ !selection.language.isNullOrBlank() &&
+ format.language.equals(selection.language, ignoreCase = true) &&
+ format.sampleMimeType == selection.sampleMimeType &&
+ format.codecs == selection.codecs &&
+ format.channelCount == selection.channelCount &&
+ format.roleFlags == selection.roleFlags
+ ) {
+ technicalMatchIndexes.add(idx)
+ }
+ idx++
+ }
+ if (technicalMatchIndexes.size == 1) {
+ return selectTrackByIndex(selection.trackType, technicalMatchIndexes.first())
+ }
+
+ return selectTrackByIndex(selection.trackType, selection.index)
+}
+
private fun PlaybackException.isDecoderFailure(): Boolean =
errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
@@ -694,27 +814,39 @@ private fun ExoPlayer.extractSubtitleTracks(context: Context): List
+ idx == targetIndex
+ }
+}
+
+private fun ExoPlayer.selectTrackByPredicate(
+ trackType: Int,
+ targetDescription: String,
+ predicate: (index: Int, format: Format) -> Boolean,
+): Boolean {
val typeName = if (trackType == C.TRACK_TYPE_AUDIO) "AUDIO" else "TEXT"
- Log.d(TAG, "selectTrackByIndex: type=$typeName targetIndex=$targetIndex")
+ Log.d(TAG, "selectTrack: type=$typeName target=$targetDescription")
var idx = 0
for (group in currentTracks.groups) {
if (group.type != trackType) continue
- if (idx == targetIndex) {
- val format = group.mediaTrackGroup.getFormat(0)
- Log.d(TAG, "selectTrackByIndex: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
- trackSelectionParameters = trackSelectionParameters
- .buildUpon()
- .setOverrideForType(
- TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
- )
- .build()
- Log.d(TAG, "selectTrackByIndex: override applied")
- return
+ val format = group.mediaTrackGroup.getFormat(0)
+ if (!predicate(idx, format)) {
+ idx++
+ continue
}
- idx++
+ Log.d(TAG, "selectTrack: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
+ trackSelectionParameters = trackSelectionParameters
+ .buildUpon()
+ .setOverrideForType(
+ TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
+ )
+ .build()
+ Log.d(TAG, "selectTrack: override applied")
+ return true
}
- Log.w(TAG, "selectTrackByIndex: no group found for type=$typeName at index=$targetIndex (total groups scanned=$idx)")
+ Log.w(TAG, "selectTrack: no group found for type=$typeName target=$targetDescription (total groups scanned=$idx)")
+ return false
}
private fun ExoPlayer.logCurrentTracks(context: String) {
From 1d7bdd60f9ef290ae1a07fc4ee4efd8a5910773e Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 11:39:57 +0530
Subject: [PATCH 09/12] fix(authscreen): keyboard to dismiss while tapping
outside
fixes #1138
---
.../com/nuvio/app/features/auth/AuthScreen.kt | 50 +++++++++++++++++--
1 file changed, 47 insertions(+), 3 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
index c6362e855..a2774cf9f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
@@ -7,6 +7,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -40,13 +42,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -85,18 +95,34 @@ fun AuthScreen(
) {
val authError by AuthRepository.error.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
+ val focusManager = LocalFocusManager.current
var isSignUp by rememberSaveable { mutableStateOf(false) }
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var isLoading by rememberSaveable { mutableStateOf(false) }
+ var emailFieldBounds by remember { mutableStateOf(null) }
+ var passwordFieldBounds by remember { mutableStateOf(null) }
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = modifier
.fillMaxSize()
- .background(Color.Black),
+ .background(Color.Black)
+ .pointerInput(emailFieldBounds, passwordFieldBounds) {
+ awaitEachGesture {
+ val down = awaitFirstDown(
+ requireUnconsumed = false,
+ pass = PointerEventPass.Initial,
+ )
+ val tappedTextField = listOfNotNull(emailFieldBounds, passwordFieldBounds)
+ .any { bounds -> bounds.contains(down.position) }
+ if (!tappedTextField) {
+ focusManager.clearFocus(force = true)
+ }
+ }
+ },
) {
Box(
modifier = Modifier
@@ -162,7 +188,11 @@ fun AuthScreen(
email = it
AuthRepository.clearError()
},
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ emailFieldBounds = coordinates.boundsInRoot()
+ },
singleLine = true,
placeholder = {
Text(
@@ -195,7 +225,11 @@ fun AuthScreen(
password = it
AuthRepository.clearError()
},
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ passwordFieldBounds = coordinates.boundsInRoot()
+ },
singleLine = true,
placeholder = {
Text(
@@ -386,3 +420,13 @@ fun AuthScreen(
}
}
}
+
+private fun LayoutCoordinates.boundsInRoot(): Rect {
+ val position = positionInRoot()
+ return Rect(
+ left = position.x,
+ top = position.y,
+ right = position.x + size.width,
+ bottom = position.y + size.height,
+ )
+}
From 34f3d6ff7c010159afa70ee9386529c87803fd05 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 11:51:38 +0530
Subject: [PATCH 10/12] fix(authscreen): update layout to use widthIn for
larger screen responsiveness
---
.../kotlin/com/nuvio/app/features/auth/AuthScreen.kt | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
index a2774cf9f..11e3a9e65 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
@@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@@ -136,6 +136,12 @@ fun AuthScreen(
.padding(start = 24.dp, end = 24.dp, top = statusBarTop + 60.dp, bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = 460.dp)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
Image(
painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = null,
@@ -417,6 +423,7 @@ fun AuthScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
+ }
}
}
}
From 7a60a6603bf3465d1468281132ec062d44199dfd Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 12:09:57 +0530
Subject: [PATCH 11/12] fix(player): implement pending scrobble start after
seek
---
.../nuvio/app/features/player/PlayerScreen.kt | 24 +++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index 826fa0897..7c354fb41 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -276,6 +276,12 @@ fun PlayerScreen(
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(0L) }
+ var pendingScrobbleStartAfterSeek by remember(
+ activeSourceUrl,
+ activeVideoId,
+ activeSeasonNumber,
+ activeEpisodeNumber,
+ ) { mutableStateOf(false) }
var hasSentCompletionScrobbleForCurrentItem by remember(
activeVideoId,
activeSeasonNumber,
@@ -508,6 +514,7 @@ fun PlayerScreen(
}
fun scheduleProgressSyncAfterSeek() {
+ val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
seekProgressSyncJob?.cancel()
seekProgressSyncJob = scope.launch {
delay(PlayerSeekProgressSyncDebounceMs)
@@ -519,8 +526,12 @@ fun PlayerScreen(
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
- if (playbackSnapshot.isPlaying) {
+ val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
+ if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
+ pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
+ } else if (shouldRestartScrobbleNow) {
+ pendingScrobbleStartAfterSeek = true
}
}
}
@@ -1056,6 +1067,7 @@ fun PlayerScreen(
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
playerController?.seekTo(targetPositionMs)
+ scheduleProgressSyncAfterSeek()
}
fun resolveDebridForPlayer(
@@ -1655,6 +1667,9 @@ fun PlayerScreen(
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
+ pendingScrobbleStartAfterSeek = false
+ seekProgressSyncJob?.cancel()
+ seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
accumulatedSeekResetJob = null
accumulatedSeekState = null
@@ -1810,14 +1825,19 @@ fun PlayerScreen(
if (playbackSnapshot.isEnded) {
flushWatchProgress()
previousIsPlaying = false
+ pendingScrobbleStartAfterSeek = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
+ pendingScrobbleStartAfterSeek = false
flushWatchProgress()
}
- if (!previousIsPlaying && playbackSnapshot.isPlaying) {
+ if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
+ pendingScrobbleStartAfterSeek = false
+ emitTraktScrobbleStart()
+ } else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
}
From e13e3d0cf77beab9786626f7b7550a64f44bf0a5 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 27 May 2026 12:10:56 +0530
Subject: [PATCH 12/12] bump version
---
iosApp/Configuration/Version.xcconfig | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index b22bc3fa3..05c1dbc62 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=66
-MARKETING_VERSION=0.1.24
+CURRENT_PROJECT_VERSION=67
+MARKETING_VERSION=0.1.25