This commit is contained in:
Nayif 2026-07-26 00:30:55 -04:00 committed by GitHub
commit b8512a4a80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 13440 additions and 2102 deletions

View file

@ -40,6 +40,17 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="nuvio"
android:host="auth"
android:path="/simkl" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="nuvio"
android:host="auth"

View file

@ -108,6 +108,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/simkl").apply {
mkdirs()
resolve("SimklConfig.kt").writeText(
"""
|package com.nuvio.app.features.simkl
|
|object SimklConfig {
| const val CLIENT_ID = "${props.getProperty("SIMKL_CLIENT_ID", "")}"
| const val REDIRECT_URI = "${props.getProperty("SIMKL_REDIRECT_URI", "nuvio://auth/simkl")}"
| const val APP_NAME = "${props.getProperty("SIMKL_APP_NAME", "nuvio")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/player/skip").apply {
mkdirs()
resolve("IntroDbConfig.kt").writeText(

View file

@ -48,6 +48,8 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.simkl.SimklAuthStorage
import com.nuvio.app.features.simkl.SimklSyncStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.CardDepthStyleStorage
@ -104,6 +106,8 @@ class MainActivity : AppCompatActivity() {
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
SimklAuthStorage.initialize(applicationContext)
SimklSyncStorage.initialize(applicationContext)
LibraryDisplaySettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)

View file

@ -19,6 +19,8 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings",
"nuvio_auth",
"nuvio_trakt_auth",
"nuvio_simkl_auth",
"nuvio_simkl_sync",
"nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched",

View file

@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.nuvio.app.R
import com.nuvio.app.features.simkl.SimklBrandAsset
import com.nuvio.app.features.simkl.simklBrandPainter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.rating_tmdb
@ -14,6 +16,7 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
when (logo) {
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph)
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.simkl
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.nuvio.app.R
@Composable
actual fun simklBrandPainter(asset: SimklBrandAsset): Painter =
painterResource(
id = when (asset) {
SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph
SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark
},
)

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.simkl
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.nuvio.app.core.storage.ProfileScopedKey
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal actual object SimklPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}
internal actual object SimklPkceCrypto {
private val secureRandom = SecureRandom()
actual fun secureRandomBytes(size: Int): ByteArray =
ByteArray(size).also(secureRandom::nextBytes)
actual fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
}
internal actual object SimklAuthStorage {
private const val PREFERENCES_NAME = "nuvio_simkl_auth"
private const val METADATA_KEY = "simkl_auth_metadata"
private const val ACCESS_TOKEN_KEY = "simkl_access_token"
private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "nuvio.simkl.credentials.v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
actual fun loadMetadataPayload(): String? =
preferences?.getString(ProfileScopedKey.of(METADATA_KEY), null)
actual fun saveMetadataPayload(payload: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(METADATA_KEY), payload)?.apply()
}
actual fun loadAccessToken(): String? = loadEncrypted(ACCESS_TOKEN_KEY)
actual fun saveAccessToken(value: String?) = saveEncrypted(ACCESS_TOKEN_KEY, value)
actual fun loadCodeVerifier(): String? = loadEncrypted(CODE_VERIFIER_KEY)
actual fun saveCodeVerifier(value: String?) = saveEncrypted(CODE_VERIFIER_KEY, value)
actual fun removeProfile(profileId: Int) {
preferences?.edit()
?.remove(ProfileScopedKey.of(METADATA_KEY, profileId))
?.remove(ProfileScopedKey.of(ACCESS_TOKEN_KEY, profileId))
?.remove(ProfileScopedKey.of(CODE_VERIFIER_KEY, profileId))
?.apply()
}
private fun loadEncrypted(key: String): String? {
val scopedKey = ProfileScopedKey.of(key)
val stored = preferences?.getString(scopedKey, null) ?: return null
return runCatching { decrypt(stored) }
.onFailure { preferences?.edit()?.remove(scopedKey)?.apply() }
.getOrNull()
}
private fun saveEncrypted(key: String, value: String?) {
val scopedKey = ProfileScopedKey.of(key)
val editor = preferences?.edit() ?: return
if (value.isNullOrBlank()) {
editor.remove(scopedKey).apply()
} else {
editor.putString(scopedKey, encrypt(value)).apply()
}
}
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
val ciphertext = cipher.doFinal(value.encodeToByteArray())
return "${cipher.iv.toBase64()}.${ciphertext.toBase64()}"
}
private fun decrypt(value: String): String {
val separator = value.indexOf('.')
require(separator > 0 && separator < value.lastIndex) { "Invalid encrypted credential" }
val iv = value.substring(0, separator).fromBase64()
val ciphertext = value.substring(separator + 1).fromBase64()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(ciphertext).decodeToString()
}
private fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
private fun ByteArray.toBase64(): String =
Base64.encodeToString(this, Base64.NO_WRAP)
private fun String.fromBase64(): ByteArray =
Base64.decode(this, Base64.NO_WRAP)
}

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.simkl
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object SimklSyncStorage {
private const val PREFERENCES_NAME = "nuvio_simkl_sync"
private const val PAYLOAD_KEY = "simkl_sync_snapshot"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(PAYLOAD_KEY), null)
actual fun savePayload(payload: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(PAYLOAD_KEY), payload)?.apply()
}
actual fun removeProfile(profileId: Int) {
preferences?.edit()
?.remove(ProfileScopedKey.of(PAYLOAD_KEY, profileId))
?.apply()
}
}

View file

@ -23,4 +23,11 @@ internal actual object TraktAuthStorage {
?.putString(ProfileScopedKey.of(payloadKey, profileId), payload)
?.apply()
}
actual fun removeProfile(profileId: Int) {
preferences
?.edit()
?.remove(ProfileScopedKey.of(payloadKey, profileId))
?.apply()
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="48dp"
android:height="48dp"
android:viewportWidth="1536"
android:viewportHeight="1536">
<path android:pathData="M245.76,0H1290.24C1425.97,0 1536,110.03 1536,245.76V1290.24C1536,1425.97 1425.97,1536 1290.24,1536H245.76C110.03,1536 0,1425.97 0,1290.24V245.76C0,110.03 110.03,0 245.76,0Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="768"
android:startY="0"
android:endX="768"
android:endY="1536"
android:startColor="#FF000000"
android:endColor="#FF333333"
android:type="linear" />
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M205.832,970.284c0,35.322 1.82,65.9 5.433,91.774 6.709,53.176 28.198,94.359 64.507,123.51 33.579,27.302 88.907,45.656 165.984,55.065 63.178,7.983 165.983,11.993 308.45,11.993 234.287,0 377.455,-7.982 429.428,-23.947 52.859,-15.964 90.936,-45.77 114.232,-89.46 23.295,-44.153 34.944,-113.446 34.944,-207.843 0,-84.525 -11.907,-145.798 -35.645,-183.858 -15.702,-25.836 -36.64,-45.772 -62.885,-59.885 -26.244,-14.074 -61.52,-24.64 -105.9,-31.697 -43.937,-7.018 -145.452,-12.917 -304.58,-17.622 -175.752,-5.63 -274.576,-11.49 -296.545,-17.584 -26.024,-7.519 -39,-28.188 -39,-62.006 0,-35.205 12.312,-57.03 36.972,-65.514 21.97,-7.018 92.115,-10.566 210.476,-10.566 119.65,0 194.955,1.427 225.92,4.203 34.942,3.317 55.584,18.04 61.85,44.26 2.25,8.908 3.835,24.833 4.72,47.777h271c0.368,-22.442 0.589,-38.83 0.589,-49.127 0,-95.475 -21.527,-161.676 -64.58,-198.657 -31.847,-26.683 -83.858,-45.386 -156.032,-56.183 -54.7,-7.944 -148.4,-11.915 -281.099,-11.915 -204.024,0 -333.149,4.704 -387.407,14.075 -60.082,10.797 -104.942,33.047 -134.505,66.748 -39.477,44.538 -59.198,121.158 -59.198,229.823 0,51.555 4.017,93.278 12.09,125.129 14.338,57.649 44.822,98.407 91.451,122.276 36.309,18.741 98.861,29.538 187.622,32.353 34.058,0.926 108.444,4.204 223.228,9.833 111.172,5.63 184.23,8.445 219.212,8.445 50.645,0.926 82.678,8.213 96.168,21.787 9.843,9.832 14.783,26.915 14.783,51.286 0,35.63 -7.852,58.612 -23.518,68.908 -11.206,7.018 -34.502,11.953 -69.887,14.73 -18.837,0.963 -87.84,1.658 -207.011,2.12 -86.954,-0.462 -138.265,-0.924 -153.93,-1.387 -31.369,-0.926 -53.338,-2.584 -65.871,-4.936 -12.532,-2.314 -23.738,-6.44 -33.58,-12.996 -18.835,-13.573 -28.014,-44.036 -27.571,-91.389H205.832v50.477Z" />
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="122dp"
android:height="20dp"
android:viewportWidth="989"
android:viewportHeight="162.133">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M0,115.012a105.623,105.623 0,0 0,0.935 15.345c1.15,8.9 4.959,15.778 11.356,20.556 5.894,4.632 15.669,7.672 29.253,9.192q16.6,2.063 54.337,2.027c41.257,0 66.413,-1.3 75.613,-3.909 9.344,-2.606 16.028,-7.455 20.125,-14.548s6.181,-18.385 6.181,-33.73c0,-13.68 -2.012,-23.668 -6.037,-29.893a27.048,27.048 0,0 0,-10.638 -9.7c-4.456,-2.244 -10.35,-3.981 -17.825,-5.139s-24.581,-2.1 -51.462,-2.9q-44.527,-1.3 -50.025,-2.823c-4.457,-1.158 -6.613,-4.56 -6.613,-10.061q0,-8.577 6.253,-10.64c3.666,-1.158 15.454,-1.737 35.435,-1.737 20.125,0 32.775,0.217 38.022,0.579 5.893,0.507 9.343,2.678 10.421,6.442a34.224,34.224 0,0 1,0.791 6.876h47.941a56.136,56.136 0,0 1,-0.863 -7.962c0,-15.344 -3.737,-26.057 -11.212,-31.992 -5.607,-4.27 -14.663,-7.31 -27.241,-9.047Q140.517,-0.007 105.8,-0.007q-53.259,0 -67.49,2.317C27.816,4.047 20.053,7.666 14.878,13.167 7.978,20.405 4.6,32.926 4.6,50.587c0,8.4 0.647,15.2 2.013,20.412 2.443,9.409 7.547,16.068 15.453,19.9 6.109,3.113 16.675,4.85 31.7,5.284 5.75,0.145 18.256,0.724 37.662,1.592 18.76,0.941 31.122,1.376 37.016,1.376 8.553,0.217 13.944,1.375 16.244,3.546 1.653,1.665 2.515,4.488 2.515,8.4 0,5.791 -1.365,9.555 -3.953,11.219 -1.941,1.158 -5.894,1.955 -11.787,2.389 -3.235,0.145 -14.879,0.289 -34.932,0.362 -14.734,-0.073 -23.431,-0.145 -26.018,-0.217a77.288,77.288 0,0 1,-11.141 -0.8,14.991 14.991,0 0,1 -5.606,-2.243c-3.235,-2.244 -4.816,-7.311 -4.672,-15.273H1.15L0,115.012ZM216.2,162.133h50.6V0h-50.6v162.133ZM289.8,162.133h48.875l-1.222,-120.441h8.7l61.381,120.441h41.113l61.378,-120.441h8.122l-1.222,120.441H565.8V0h-89.412l-48.372,105.676L379.644,0H289.8v162.133ZM588.8,162.133h50.6V97.28h27.169l72.741,64.853h75.18L712.641,76.579 800.71,0h-73.2l-60.938,55.589H639.4V0h-50.6v162.133ZM818.8,162.133H989v-41.691H869.4V0h-50.6v162.133Z" />
<path
android:fillAlpha="0.302"
android:fillColor="#FFFFFFFF"
android:pathData="M928.39,8.912V2.204H887.2v6.708h17.16v40.245h6.87V8.912ZM986.73,49.157 976.44,2.204H973l-15.44,33.538 -15.44,-33.538h-3.44l-10.29,46.953h6.86l5.15,-30.184 15.44,30.184h3.44l15.44,-30.184 5.15,30.184Z" />
</vector>

View file

@ -0,0 +1 @@
<svg style="background-color:#ffffff00" xmlns="http://www.w3.org/2000/svg" width="1536" height="1536"><defs><linearGradient id="a" x1="49.89%" y1="-.045%" x2="49.89%" y2="99.858%"><stop offset="0%"/><stop stop-color="#333" offset="100%"/></linearGradient></defs><rect width="1536" height="1536" fill="url(#a)" rx="245.76" ry="245.76"/><path d="M205.832 970.284c0 35.322 1.82 65.9 5.433 91.774 6.709 53.176 28.198 94.359 64.507 123.51 33.579 27.302 88.907 45.656 165.984 55.065 63.178 7.983 165.983 11.993 308.45 11.993 234.287 0 377.455-7.982 429.428-23.947 52.859-15.964 90.936-45.77 114.232-89.46 23.295-44.153 34.944-113.446 34.944-207.843 0-84.525-11.907-145.798-35.645-183.858-15.702-25.836-36.64-45.772-62.885-59.885-26.244-14.074-61.52-24.64-105.9-31.697-43.937-7.018-145.452-12.917-304.58-17.622-175.752-5.63-274.576-11.49-296.545-17.584-26.024-7.519-39-28.188-39-62.006 0-35.205 12.312-57.03 36.972-65.514 21.97-7.018 92.115-10.566 210.476-10.566 119.65 0 194.955 1.427 225.92 4.203 34.942 3.317 55.584 18.04 61.85 44.26 2.25 8.908 3.835 24.833 4.72 47.777h271c.368-22.442.589-38.83.589-49.127 0-95.475-21.527-161.676-64.58-198.657-31.847-26.683-83.858-45.386-156.032-56.183-54.7-7.944-148.4-11.915-281.099-11.915-204.024 0-333.149 4.704-387.407 14.075-60.082 10.797-104.942 33.047-134.505 66.748-39.477 44.538-59.198 121.158-59.198 229.823 0 51.555 4.017 93.278 12.09 125.129 14.338 57.649 44.822 98.407 91.451 122.276 36.309 18.741 98.861 29.538 187.622 32.353 34.058.926 108.444 4.204 223.228 9.833 111.172 5.63 184.23 8.445 219.212 8.445 50.645.926 82.678 8.213 96.168 21.787 9.843 9.832 14.783 26.915 14.783 51.286 0 35.63-7.852 58.612-23.518 68.908-11.206 7.018-34.502 11.953-69.887 14.73-18.837.963-87.84 1.658-207.011 2.12-86.954-.462-138.265-.924-153.93-1.387-31.369-.926-53.338-2.584-65.871-4.936-12.532-2.314-23.738-6.44-33.58-12.996-18.835-13.573-28.014-44.036-27.571-91.389H205.832v50.824-.347z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="989" height="162.133"><g fill="#fff"><path d="M0 115.012a105.623 105.623 0 0 0 .935 15.345c1.15 8.9 4.959 15.778 11.356 20.556 5.894 4.632 15.669 7.672 29.253 9.192q16.6 2.063 54.337 2.027c41.257 0 66.413-1.3 75.613-3.909 9.344-2.606 16.028-7.455 20.125-14.548s6.181-18.385 6.181-33.73c0-13.68-2.012-23.668-6.037-29.893a27.048 27.048 0 0 0-10.638-9.7c-4.456-2.244-10.35-3.981-17.825-5.139s-24.581-2.1-51.462-2.9q-44.527-1.3-50.025-2.823c-4.457-1.158-6.613-4.56-6.613-10.061q0-8.577 6.253-10.64c3.666-1.158 15.454-1.737 35.435-1.737 20.125 0 32.775.217 38.022.579 5.893.507 9.343 2.678 10.421 6.442a34.224 34.224 0 0 1 .791 6.876h47.941a56.136 56.136 0 0 1-.863-7.962c0-15.344-3.737-26.057-11.212-31.992-5.607-4.27-14.663-7.31-27.241-9.047Q140.517-.007 105.8-.007q-53.259 0-67.49 2.317C27.816 4.047 20.053 7.666 14.878 13.167 7.978 20.405 4.6 32.926 4.6 50.587c0 8.4.647 15.2 2.013 20.412 2.443 9.409 7.547 16.068 15.453 19.9 6.109 3.113 16.675 4.85 31.7 5.284 5.75.145 18.256.724 37.662 1.592 18.76.941 31.122 1.376 37.016 1.376 8.553.217 13.944 1.375 16.244 3.546 1.653 1.665 2.515 4.488 2.515 8.4 0 5.791-1.365 9.555-3.953 11.219-1.941 1.158-5.894 1.955-11.787 2.389-3.235.145-14.879.289-34.932.362-14.734-.073-23.431-.145-26.018-.217a77.288 77.288 0 0 1-11.141-.8 14.991 14.991 0 0 1-5.606-2.243c-3.235-2.244-4.816-7.311-4.672-15.273H1.15L0 115.012ZM216.2 162.133h50.6V0h-50.6v162.133ZM289.8 162.133h48.875l-1.222-120.441h8.7l61.381 120.441h41.113l61.378-120.441h8.122l-1.222 120.441H565.8V0h-89.412l-48.372 105.676L379.644 0H289.8v162.133ZM588.8 162.133h50.6V97.28h27.169l72.741 64.853h75.18L712.641 76.579 800.71 0h-73.2l-60.938 55.589H639.4V0h-50.6v162.133ZM818.8 162.133H989v-41.691H869.4V0h-50.6v162.133Z"/><g opacity=".302"><path d="M928.39 8.912V2.204H887.2v6.708h17.16v40.245h6.87V8.912ZM986.73 49.157 976.44 2.204H973l-15.44 33.538-15.44-33.538h-3.44l-10.29 46.953h6.86l5.15-30.184 15.44 30.184h3.44l15.44-30.184 5.15 30.184Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104.55 45">
<svg xmlns="http://www.w3.org/2000/svg" width="104.55" height="45" viewBox="0 0 104.55 45">
<g transform="translate(-65.25 0)">
<path fill="#FFFFFF" d="M65.25,19.5199v-5.73h18.18v5.73H65.25zM76.93,44.7099c-2.42,0 -4.3,-0.62 -5.64,-1.86c-1.34,-1.24 -2.01,-3.01 -2.01,-5.32l-0.03,-15.44c0,-0.6 0.13,-1.14 0.41,-1.63c0.27,-0.49 0.65,-0.8 1.13,-0.93l-1.54,-4.52v-0.81l1.51,-9.1299h5V35.8999c0,1.08 0.24,1.83 0.71,2.26c0.48,0.43 1.36,0.64 2.67,0.64c0.84,0 1.61,0 2.32,-0.01c0.71,-0.01 1.35,-0.02 1.93,-0.04v5.61c-0.99,0.19 -2.05,0.29 -3.2,0.32c-1.15,0.03 -2.23,0.04 -3.25,0.04zM86,44.2599v-30.47h5.64l0.73,11.73l-0.59,-1.62c0.13,-1.23 0.38,-2.48 0.75,-3.74c0.37,-1.27 0.89,-2.43 1.56,-3.49c0.67,-1.06 1.54,-1.91 2.62,-2.56c1.08,-0.64 2.38,-0.96 3.91,-0.96c0.39,0 0.77,0.02 1.13,0.06c0.36,0.04 0.7,0.1 1.02,0.2v7.04c-0.63,-0.22 -1.3,-0.36 -2.01,-0.42c-0.71,-0.06 -1.37,-0.08 -1.98,-0.08c-0.73,0 -1.51,0.14 -2.35,0.43c-0.84,0.29 -1.61,0.72 -2.32,1.3c-0.71,0.58 -1.22,1.29 -1.54,2.15v20.44H86zM112.7,44.6c-1.82,0 -3.46,-0.35 -4.9,-1.05c-1.44,-0.7 -2.57,-1.68 -3.38,-2.93c-0.81,-1.26 -1.21,-2.7 -1.21,-4.34c0,-1.79 0.33,-3.26 0.98,-4.43c0.65,-1.16 1.59,-2.1 2.82,-2.82c1.23,-0.72 2.7,-1.25 4.41,-1.61c1.17,-0.26 2.42,-0.4701 3.74,-0.6301c1.32,-0.16 2.56,-0.2899 3.7,-0.3899c1.14,-0.1001 2.02,-0.17 2.64,-0.21v-1.26c0,-2.08 -0.41,-3.64 -1.24,-4.68c-0.83,-1.03 -2.23,-1.55 -4.2,-1.55c-1.27,0 -2.33,0.16 -3.18,0.47c-0.86,0.32 -1.5,0.9 -1.94,1.75c-0.44,0.85 -0.67,2.07 -0.71,3.67h-6c-0.21,-2.62 0.17,-4.78 1.13,-6.46c0.96,-1.68 2.39,-2.9301 4.3,-3.73c1.91,-0.8 4.17,-1.2 6.77,-1.2c1.53,0 2.98,0.17 4.36,0.52c1.38,0.35 2.6,0.93 3.66,1.76c1.06,0.83 1.9,1.96 2.51,3.39c0.61,1.43 0.92,3.25 0.92,5.45v19.94h-6.45l0.08,-6.8101c-0.35,2.05 -1.25,3.75 -2.69,5.11c-1.44,1.3601 -3.48,2.0401 -6.1,2.0401zM114.49,39.6c0.97,0 1.96,-0.1601 2.96,-0.4901c1,-0.33 1.88,-0.8 2.64,-1.4399c0.75,-0.63 1.22,-1.38 1.41,-2.23v-5.59c-0.74,0.08 -1.62,0.2 -2.64,0.38c-1.02,0.18 -1.89,0.35 -2.61,0.52c-2.08,0.41 -3.59,0.96 -4.52,1.66c-0.93,0.7 -1.4,1.77 -1.4,3.23c0,0.82 0.2,1.53 0.59,2.12c0.39,0.6 0.9,1.06 1.54,1.38c0.63,0.33 1.31,0.48 2.04,0.46zM129.44,44.2599v-30.48h6.67v30.48H129.44zM135.4,36.6499l-0.67,-5.81l3.71,-6.46l7.53,-10.61h7.8l-10.42,14.23l-2.66,2.01l-5.28,6.62zM146.84,44.2599l-8.89,-15.43l5.03,-3.86l11.8,19.28H146.84z"/>
<path fill="#FFFFFF" d="M151.62,19.5199v-5.73h18.18v5.73H151.62zM163.29,44.7099c-2.42,0 -4.3,-0.62 -5.64,-1.86c-1.34,-1.24 -2.01,-3.01 -2.01,-5.32l-0.03,-15.44c0,-0.6 0.13,-1.14 0.41,-1.63c0.27,-0.49 0.65,-0.8 1.13,-0.93l-1.54,-4.52v-0.81l1.51,-9.1299h5V35.8999c0,1.08 0.24,1.83 0.71,2.26c0.48,0.43 1.36,0.64 2.67,0.64c0.84,0 1.61,0 2.32,-0.01c0.71,-0.01 1.35,-0.02 1.93,-0.04v5.61c-0.99,0.19 -2.05,0.29 -3.2,0.32c-1.15,0.03 -2.23,0.04 -3.25,0.04z"/>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -13,6 +13,7 @@
<string name="action_play">Play</string>
<string name="action_previous">Previous</string>
<string name="action_remove">Remove</string>
<string name="action_remove_anyway">Remove anyway</string>
<string name="action_reorder">Reorder</string>
<string name="action_reset">Reset to Default</string>
<string name="action_resume">Resume</string>
@ -332,6 +333,7 @@
<string name="compose_auth_welcome_back">Welcome Back</string>
<string name="compose_catalog_subtitle_library">Library</string>
<string name="compose_catalog_subtitle_trakt_library">Trakt Library</string>
<string name="compose_catalog_subtitle_simkl_library">Simkl Watchlist</string>
<string name="compose_nav_home">Home</string>
<string name="compose_nav_library">Library</string>
<string name="compose_nav_profile">Profile</string>
@ -438,6 +440,7 @@
<string name="compose_settings_page_supporters_contributors">Supporters &amp; Contributors</string>
<string name="compose_settings_page_tmdb_enrichment">TMDB Enrichment</string>
<string name="compose_settings_page_trakt">Trakt</string>
<string name="compose_settings_page_tracking">Tracking</string>
<string name="compose_settings_root_about_section">ABOUT</string>
<string name="compose_settings_root_account_description">Account and sync status</string>
<string name="compose_settings_root_account_section">ACCOUNT</string>
@ -458,6 +461,7 @@
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
<string name="compose_settings_root_tracking_description">Connect tracking providers and choose which one powers your Library and Continue Watching.</string>
<string name="settings_search_empty">No settings found.</string>
<string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string>
@ -496,6 +500,8 @@
<string name="settings_licenses_attributions_imdb_body">Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb&apos;s terms.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt.</string>
<string name="settings_licenses_attributions_simkl_title">Simkl</string>
<string name="settings_licenses_attributions_simkl_body">Nuvio connects to Simkl for account authentication, watchlists, watched history, playback progress, and scrobbling. Movie, TV, and anime tracking data is provided by Simkl. Nuvio is not affiliated with or endorsed by Simkl.</string>
<string name="settings_licenses_attributions_premiumize_title">Premiumize</string>
<string name="settings_licenses_attributions_premiumize_body">Nuvio connects to Premiumize for account authentication, cloud library access, cache checks, and cloud playback features. Nuvio is not affiliated with or endorsed by Premiumize.</string>
<string name="settings_licenses_attributions_torbox_title">TorBox</string>
@ -512,6 +518,8 @@
<string name="settings_licenses_attributions_exoplayer_license">Licensed under the Apache License, Version 2.0.</string>
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
<string name="compose_tracking_list_picker_loading">Loading lists…</string>
<string name="compose_tracking_list_picker_subtitle">Choose where to save this title</string>
<string name="action_donate">Donate</string>
<string name="cw_action_go_to_details">Go to details</string>
<string name="cw_action_remove">Remove</string>
@ -1108,16 +1116,62 @@
<string name="settings_trakt_disconnect">Disconnect</string>
<string name="settings_trakt_failed_open_browser">Failed to open browser</string>
<string name="settings_trakt_features">FEATURES</string>
<string name="settings_trakt_finish_sign_in">Finish Trakt sign in in your browser</string>
<string name="settings_trakt_finish_sign_in">Finish connection approval</string>
<string name="settings_trakt_intro_description">Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt.</string>
<string name="settings_trakt_missing_credentials">Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).</string>
<string name="settings_trakt_missing_credentials">Missing TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET in local.properties.</string>
<string name="settings_trakt_open_login">Open Trakt Login</string>
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
<string name="settings_trakt_save_actions_description">Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt.</string>
<string name="settings_trakt_sign_in_description">Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt.</string>
<string name="settings_tracking_data_sources">Sources</string>
<string name="settings_tracking_features">SOURCE PREFERENCES</string>
<string name="settings_tracking_viewing_discovery">Trakt features</string>
<string name="settings_tracking_services">Accounts</string>
<string name="settings_tracking_approval_redirect">After approval, you will be redirected back automatically.</string>
<string name="settings_tracking_connect_first">Connect %1$s first</string>
<string name="settings_tracking_disconnect_title">Disconnect %1$s?</string>
<string name="settings_tracking_disconnect_description">This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio.</string>
<string name="settings_trakt_disconnect_description">This will disconnect your Trakt account from Nuvio.</string>
<string name="settings_simkl_disconnect_description">This removes the Simkl account and cached Simkl data from this profile.</string>
<string name="settings_tracking_source_fallback">%1$s is unavailable. Using %2$s until it reconnects.</string>
<string name="settings_tracking_progress_refresh_failed">The source changed, but the latest watch progress could not be refreshed.</string>
<string name="settings_tracking_nuvio_library_description">Keep saved titles in your Nuvio library.</string>
<string name="settings_tracking_trakt_library_description">Use your Trakt watchlist and personal lists.</string>
<string name="settings_tracking_simkl_library_description">Use your Simkl plan-to-watch and status lists.</string>
<string name="settings_tracking_nuvio_progress_description">Resume with progress stored by Nuvio Sync.</string>
<string name="settings_tracking_trakt_progress_description">Resume with playback progress from Trakt.</string>
<string name="settings_tracking_simkl_progress_description">Resume with playback progress from Simkl.</string>
<string name="settings_tracking_tmdb_recommendations_description">Use TMDB recommendations on details pages.</string>
<string name="settings_tracking_trakt_recommendations_description">Use personalized related titles from Trakt.</string>
<string name="settings_simkl_connect">Connect Simkl</string>
<string name="settings_simkl_connected_as">Connected as %1$s</string>
<string name="settings_simkl_default_user">Simkl user</string>
<string name="settings_simkl_connected_description">Connect Simkl to sync lists, watched history, playback progress, and scrobbles.</string>
<string name="settings_simkl_disconnect">Disconnect</string>
<string name="settings_simkl_finish_sign_in">Finish connection approval</string>
<string name="settings_simkl_open_login">Open Simkl Login</string>
<string name="settings_simkl_sign_in_description">Connect Simkl to sync lists, watched history, playback progress, and scrobbles.</string>
<string name="settings_simkl_missing_credentials">Missing SIMKL_CLIENT_ID</string>
<string name="settings_simkl_visit">Visit Simkl</string>
<string name="settings_simkl_invalid_callback">Simkl did not accept the sign-in callback. Please try again.</string>
<string name="settings_simkl_authorization_expired">Simkl code expired. Start again.</string>
<string name="settings_simkl_sign_in_failed">Could not complete Simkl sign in. Please try again.</string>
<string name="settings_simkl_sync_now">Sync now</string>
<string name="settings_simkl_sync_info_action">How syncing works</string>
<string name="settings_simkl_sync_info_title">How Simkl syncing works</string>
<string name="settings_simkl_sync_info_description">Nuvio automatically checks Simkl at most once every %1$d minutes. Changes made through another app or the Simkl website may take that long to appear.</string>
<string name="settings_simkl_sync_info_activity">Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkls API rules, reduces unnecessary data use, and helps keep the service stable.</string>
<string name="settings_simkl_sync_info_manual">Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner.</string>
<string name="settings_simkl_sync_info_library_statuses">Shows placed in Simkls On Hold or Dropped lists are hidden from Continue Watching. They remain hidden for as long as they stay in either list.</string>
<string name="settings_simkl_sync_info_docs">Read the Simkl sync guide</string>
<string name="settings_simkl_authorization_revoked">Simkl authorization was revoked. Connect again.</string>
<string name="tracking_source_simkl">Simkl</string>
<string name="tracking_library_source_simkl_selected">Simkl watchlist selected</string>
<string name="tracking_watch_progress_simkl_selected">Watch progress source set to Simkl</string>
<string name="tracking_watch_progress_dialog_subtitle">Choose the service Nuvio reads for resume and Continue Watching. Scrobbling remains active for every connected service.</string>
<string name="trakt_library_source_title">Library Source</string>
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
<string name="trakt_library_source_subtitle">Choose which source Nuvio reads for your Library</string>
<string name="trakt_library_source_dialog_title">Library Source</string>
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
<string name="trakt_library_source_dialog_subtitle">Choose the service Nuvio reads for your Library. Playback scrobbles to every connected service.</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Nuvio Library</string>
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
@ -1415,6 +1469,13 @@
<string name="trailer_unable_to_play">Unable to play trailer</string>
<string name="trakt_lists_load_failed">Failed to load Trakt lists</string>
<string name="trakt_lists_update_failed">Failed to update Trakt lists</string>
<string name="tracking_lists_update_failed">Failed to update tracking lists</string>
<string name="tracking_list_status_rewritten">%1$s placed this title in %2$s instead of %3$s</string>
<string name="tracking_remove_confirmation_title">Remove from %1$s?</string>
<string name="tracking_remove_confirmation_message">Removing “%1$s” from %2$s will also clear its %3$s there. This cant be undone.</string>
<string name="tracking_removal_impact_history">watched history</string>
<string name="tracking_removal_impact_rating">rating</string>
<string name="tracking_removal_impact_history_and_rating">watched history and rating</string>
<string name="updates_asset_line">%1$s • %2$s</string>
<string name="updates_check_failed">Update check failed</string>
<string name="updates_download_failed">Download failed</string>
@ -1627,7 +1688,7 @@
<string name="library_sort_added_desc">Recently added</string>
<string name="library_sort_title_asc">Title AZ</string>
<string name="library_sort_title_desc">Title ZA</string>
<string name="library_sort_trakt_order">Trakt order</string>
<string name="library_sort_provider_order">Tracker order</string>
<string name="library_source_cloud">Cloud</string>
<string name="library_source_saved">Saved</string>
<string name="library_title">Library</string>
@ -1635,6 +1696,10 @@
<string name="library_trakt_empty_title">Your Trakt library is empty</string>
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
<string name="library_trakt_title">Trakt Library</string>
<string name="library_simkl_empty_message">Add titles to your Simkl plan-to-watch list to see them here.</string>
<string name="library_simkl_empty_title">Your Simkl watchlist is empty</string>
<string name="library_simkl_load_failed">Couldnt load Simkl watchlist</string>
<string name="library_simkl_title">Simkl Watchlist</string>
<string name="cloud_library_connect_action">Connect account</string>
<string name="cloud_library_connect_message">Connect an account in Connected Services settings to browse playable files from your cloud library.</string>
<string name="cloud_library_connect_title">No cloud account connected</string>

View file

@ -107,6 +107,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import com.nuvio.app.core.ui.PosterZoomOverlayExitAnimation
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.core.ui.NuvioStatusModal
@ -117,7 +118,7 @@ import com.nuvio.app.core.ui.NuvioToastHost
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.ProfileMeshBackground
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.TrackingListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
@ -162,6 +163,10 @@ import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.library.LibrarySortOption
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.PendingTrackingMembershipRemoval
import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost
import com.nuvio.app.features.library.executeTrackingMembershipOperation
import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.library.toMetaPreview
@ -220,9 +225,14 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.buildTrackingMediaReference
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -812,10 +822,12 @@ private fun MainAppContent(
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerTabs by remember { mutableStateOf<List<TrackingLibraryTab>>(emptyList()) }
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
var pendingTrackingRemoval by remember { mutableStateOf<PendingTrackingMembershipRemoval?>(null) }
val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed)
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
@ -885,7 +897,7 @@ private fun MainAppContent(
val collectionsTitle = stringResource(Res.string.collections_header)
val newCollectionTitle = stringResource(Res.string.collections_new)
val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
val isRemoteLibrarySource = libraryUiState.sourceMode != LibrarySourceMode.LOCAL
var initialHomeReady by rememberSaveable(ownsAppRuntime) {
mutableStateOf(!ownsAppRuntime)
}
@ -1233,8 +1245,8 @@ private fun MainAppContent(
null
}
val playerLaunch = lastExternalPlayerLaunch
if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) {
val scrobbleItem = TraktScrobbleRepository.buildItem(
if (progressPercent != null && playerLaunch != null) {
val trackingMedia = buildTrackingMediaReference(
contentType = playerLaunch.parentMetaType,
parentMetaId = playerLaunch.parentMetaId,
videoId = playerLaunch.videoId,
@ -1243,12 +1255,15 @@ private fun MainAppContent(
episodeNumber = playerLaunch.episodeNumber,
episodeTitle = playerLaunch.episodeTitle,
)
if (scrobbleItem != null) {
if (trackingMedia.hasResolvableIdentity) {
runCatching {
TraktScrobbleRepository.scrobbleStop(
TrackingScrobbleCoordinator.scrobble(
profileId = playerLaunch.profileId,
item = scrobbleItem,
progressPercent = progressPercent,
action = TrackingScrobbleAction.STOP,
event = TrackingScrobbleEvent(
media = trackingMedia,
progressPercent = progressPercent.toDouble(),
),
)
}
}
@ -1677,10 +1692,10 @@ private fun MainAppContent(
)
}
val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) {
stringResource(Res.string.compose_catalog_subtitle_trakt_library)
} else {
stringResource(Res.string.compose_catalog_subtitle_library)
val librarySectionSubtitle = when (libraryUiState.sourceMode) {
LibrarySourceMode.LOCAL -> stringResource(Res.string.compose_catalog_subtitle_library)
LibrarySourceMode.TRAKT -> stringResource(Res.string.compose_catalog_subtitle_trakt_library)
LibrarySourceMode.SIMKL -> stringResource(Res.string.compose_catalog_subtitle_simkl_library)
}
val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption ->
@ -3293,10 +3308,8 @@ private fun MainAppContent(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
// Trakt items long-pressed outside the library open the list picker
// instead of removing, so only true removals disintegrate.
val removesFromLibrary = isSaved &&
(posterActionTarget.libraryItem != null || !isTraktLibrarySource)
(posterActionTarget.libraryItem != null || !isRemoteLibrarySource)
NuvioPosterZoomActionOverlay(
imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster,
title = preview.name,
@ -3317,39 +3330,70 @@ private fun MainAppContent(
stringResource(Res.string.hero_add_to_library)
},
isDestructive = removesFromLibrary,
exitAnimation = if (removesFromLibrary && !isRemoteLibrarySource) {
PosterZoomOverlayExitAnimation.DISINTEGRATE
} else {
PosterZoomOverlayExitAnimation.COLLAPSE
},
onSelected = {
val libraryItem = posterActionTarget.libraryItem
?: preview.toLibraryItem(savedAtEpochMs = 0L)
if (posterActionTarget.libraryItem != null) {
if (isTraktLibrarySource) {
if (isRemoteLibrarySource) {
coroutineScope.launch {
runCatching {
val listKey = posterActionTarget.libraryListKey
val listKey = posterActionTarget.libraryListKey
val removeMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
if (listKey.isNullOrBlank()) {
val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem)
LibraryRepository.applyMembershipChanges(
item = libraryItem,
desiredMembership = currentMembership.mapValues { false },
confirmedRemovalProviders = confirmedProviders,
)
} else {
LibraryRepository.removeFromList(libraryItem, listKey)
LibraryRepository.removeFromList(
item = libraryItem,
listKey = listKey,
confirmedRemovalProviders = confirmedProviders,
)
}
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.trakt_lists_update_failed),
)
}
executeTrackingMembershipOperation(
operation = { removeMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = libraryItem.name,
confirmations = result.requiredRemovalConfirmations,
retry = removeMembership,
onApplied = {},
onFailure = { error ->
NuvioToastController.show(
error.message
?: trackingListsUpdateFailedMessage,
)
},
)
}
},
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
}
} else {
LibraryRepository.remove(libraryItem.id)
}
} else {
if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
if (!isRemoteLibrarySource) {
LibraryRepository.toggleLocalSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerTabs = LibraryRepository.libraryListTabs(libraryItem)
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
@ -3357,7 +3401,7 @@ private fun MainAppContent(
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
val tabs = LibraryRepository.libraryListTabs(libraryItem)
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
@ -3486,7 +3530,7 @@ private fun MainAppContent(
},
)
TraktListPickerDialog(
TrackingListPickerDialog(
visible = showLibraryListPicker,
title = pickerTitle,
tabs = pickerTabs,
@ -3494,9 +3538,11 @@ private fun MainAppContent(
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
pickerMembership = toggleTrackingLibraryMembership(
tabs = pickerTabs,
membership = pickerMembership,
key = listKey,
)
},
onDismiss = {
if (!pickerPending) {
@ -3506,27 +3552,56 @@ private fun MainAppContent(
}
},
onSave = {
val item = pickerItem ?: return@TraktListPickerDialog
val item = pickerItem ?: return@TrackingListPickerDialog
coroutineScope.launch {
pickerPending = true
pickerError = null
runCatching {
val desiredMembership = pickerMembership.toMap()
val applyMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.applyMembershipChanges(
item = item,
desiredMembership = pickerMembership,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedProviders,
)
}.onSuccess {
}
val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
pickerItem = null
pickerError = null
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
executeTrackingMembershipOperation(
operation = { applyMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = applyMembership,
onApplied = completeMembershipUpdate,
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
} else {
completeMembershipUpdate(result)
}
},
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
pickerPending = false
}
},
)
TrackingMembershipRemovalConfirmationHost(
pending = pendingTrackingRemoval,
onPendingChange = { pendingTrackingRemoval = it },
)
NuvioStatusModal(
title = stringResource(Res.string.app_exit_title),
message = stringResource(Res.string.app_exit_message),

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.deeplink
import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.flow.MutableStateFlow
@ -41,7 +42,8 @@ fun handleAppUrl(url: String) {
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) return
handleTraktAuthCallbackUrl(normalizedUrl)
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.handleAuthCallback(normalizedUrl)
AppDeepLinkRepository.handleUrl(normalizedUrl)
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
@ -20,14 +21,15 @@ import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.player.SubtitleRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.MAX_PROFILES
import com.nuvio.app.features.search.SearchRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -38,6 +40,8 @@ import com.nuvio.app.features.watched.WatchedRepository
internal object LocalAccountDataCleaner {
fun wipe() {
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.removeStoredProfiles(1..MAX_PROFILES)
SyncManager.cancelAccountSync()
WatchProgressSourceCoordinator.clearLocalState()
ProfileSettingsSync.clearAccountState()
@ -65,8 +69,8 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
CardDepthStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
TrackingProviderRegistry.clearLocalState()
TrackingSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()
StreamBadgeSettingsRepository.clearLocalState()
P2pSettingsRepository.clearLocalState()

View file

@ -30,7 +30,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest
@ -184,7 +184,7 @@ object ProfileSettingsSync {
MetaScreenSettingsRepository.uiState.map { "meta" },
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TrackingSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
)
@ -278,7 +278,7 @@ object ProfileSettingsSync {
ContinueWatchingPreferencesRepository.onProfileChanged()
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
TraktSettingsRepository.onProfileChanged()
TrackingSettingsRepository.onProfileChanged()
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged()
@ -298,7 +298,7 @@ object ProfileSettingsSync {
MetaScreenSettingsRepository.ensureLoaded()
CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@ -321,7 +321,7 @@ object ProfileSettingsSync {
"meta=${MetaScreenSettingsRepository.uiState.value}",
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_settings=${TrackingSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||")

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.time.EpisodeReleaseDatePlatform
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionSyncService
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
@ -11,11 +12,11 @@ import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.tracking.effectiveLibrarySourceMode
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
@ -61,6 +62,28 @@ internal data class ProfileSyncResult(
get() = failedSteps.isEmpty()
}
internal data class ProfilePullFreshness(
val profileId: Int? = null,
val completedAtEpochMs: Long = 0L,
) {
fun isRecent(profileId: Int, nowEpochMs: Long, minIntervalMs: Long): Boolean =
this.profileId == profileId && nowEpochMs - completedAtEpochMs < minIntervalMs
fun recordIfSuccessful(
profileId: Int,
completedAtEpochMs: Long,
result: ProfileSyncResult,
): ProfilePullFreshness =
if (result.succeeded) {
ProfilePullFreshness(
profileId = profileId,
completedAtEpochMs = completedAtEpochMs,
)
} else {
this
}
}
internal suspend fun runOrderedProfileSync(
profileId: Int,
pluginsEnabled: Boolean,
@ -210,8 +233,7 @@ object SyncManager {
private var foregroundPullProfileId: Int? = null
private var periodicNuvioSyncPullJob: Job? = null
private var periodicNuvioSyncProfileId: Int? = null
private var lastFullPullAtMs: Long = 0L
private var lastFullPullProfileId: Int? = null
private var pullFreshness = ProfilePullFreshness()
private val profileSyncOperations = ProfileSyncOperations(
pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) },
@ -246,8 +268,7 @@ object SyncManager {
foregroundPullJob.also {
foregroundPullJob = null
foregroundPullProfileId = null
lastFullPullAtMs = 0L
lastFullPullProfileId = null
pullFreshness = ProfilePullFreshness()
}
}
foregroundJob?.cancel()
@ -303,8 +324,11 @@ object SyncManager {
private fun hasRecentFullPull(profileId: Int): Boolean =
synchronized(pullStateLock) {
lastFullPullProfileId == profileId &&
TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS
pullFreshness.isRecent(
profileId = profileId,
nowEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(),
minIntervalMs = FOREGROUND_PULL_MIN_INTERVAL_MS,
)
}
private suspend fun pullForegroundForProfile(profileId: Int) {
@ -312,36 +336,24 @@ object SyncManager {
runCatching { ProfileRepository.pullProfiles() }
.onFailure { log.e(it) { "Foreground profiles pull failed" } }
runCatching { ProfileSettingsSync.pull(profileId) }
.onFailure { log.e(it) { "Foreground profile settings pull failed" } }
coroutineScope {
launch {
runCatching { AddonRepository.pullFromServer(profileId) }
.onFailure { log.e(it) { "Foreground addons pull failed" } }
}
if (AppFeaturePolicy.pluginsEnabled) {
launch {
runCatching { PluginRepository.pullFromServer(profileId) }
.onFailure { log.e(it) { "Foreground plugins pull failed" } }
}
}
launch {
runCatching { LibraryRepository.pullFromServer(profileId) }
.onFailure { log.e(it) { "Foreground library pull failed" } }
}
launch {
runCatching {
WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true)
}.onFailure { log.e(it) { "Foreground active watch source pull failed" } }
}
launch {
runCatching { CollectionSyncService.pullFromServer(profileId) }
.onFailure { log.e(it) { "Foreground collections pull failed" } }
}
launch {
runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) }
.onFailure { log.e(it) { "Foreground home catalog settings pull failed" } }
val syncResult = runOrderedProfileSync(
profileId = profileId,
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
operations = profileSyncOperations,
onFailure = { step, error ->
log.e(error) { "Foreground profile sync step failed profile=$profileId step=$step" }
},
)
synchronized(pullStateLock) {
pullFreshness = pullFreshness.recordIfSuccessful(
profileId = profileId,
completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(),
result = syncResult,
)
}
if (!syncResult.succeeded) {
log.w {
"Foreground profile sync incomplete profile=$profileId failedSteps=${syncResult.failedSteps}"
}
}
@ -380,12 +392,14 @@ object SyncManager {
} finally {
WatchProgressSourceCoordinator.resumeAutomaticTransitions()
}
if (syncResult.succeeded) {
synchronized(pullStateLock) {
lastFullPullAtMs = TraktPlatformClock.nowEpochMs()
lastFullPullProfileId = profileId
}
} else {
synchronized(pullStateLock) {
pullFreshness = pullFreshness.recordIfSuccessful(
profileId = profileId,
completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(),
result = syncResult,
)
}
if (!syncResult.succeeded) {
log.w {
"Full profile sync incomplete profile=$profileId reason=$reason " +
"failedSteps=${syncResult.failedSteps}"
@ -427,19 +441,18 @@ object SyncManager {
continue
}
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
val traktAuthenticated = TraktAuthRepository.isAuthenticated.value
val settings = TraktSettingsRepository.uiState.value
val settings = TrackingSettingsRepository.uiState.value
val shouldPullLibrary = effectiveLibrarySourceMode(
isAuthenticated = traktAuthenticated,
source = settings.librarySourceMode,
requestedSource = settings.librarySourceMode,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
) == LibrarySourceMode.LOCAL
val shouldPullWatchProgress = !shouldUseTraktProgress(
isAuthenticated = traktAuthenticated,
source = settings.watchProgressSource,
)
val shouldPullWatchProgress = effectiveWatchProgressSource(
requestedSource = settings.watchProgressSource,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
) == WatchProgressSource.NUVIO_SYNC
if (!shouldPullLibrary && !shouldPullWatchProgress) {
continue

View file

@ -0,0 +1,32 @@
package com.nuvio.app.core.tracking
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklMutationRepository
import com.nuvio.app.features.simkl.SimklLibraryRepository
import com.nuvio.app.features.simkl.SimklProgressRepository
import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider
import com.nuvio.app.features.simkl.SimklTrackingProgressProvider
import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider
import com.nuvio.app.features.trakt.TraktTrackingProgressProvider
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
fun ensureTrackingProvidersRegistered() {
TraktAuthRepository.descriptor
TraktScrobbleRepository.ensureRegistered()
SimklAuthRepository.descriptor
SimklSyncRepository.state
SimklLibraryRepository.uiState
SimklProgressRepository.uiState
SimklMutationRepository.ensureRegistered()
TrackingProviderRegistry.registerLibraryProvider(TraktTrackingLibraryProvider)
TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider)
TrackingProviderRegistry.registerWatchedProvider(TraktWatchedSyncAdapter)
TrackingProviderRegistry.registerWatchedProvider(SimklWatchedSyncAdapter)
TrackingProviderRegistry.registerProgressProvider(TraktTrackingProgressProvider)
TrackingProviderRegistry.registerProgressProvider(SimklTrackingProgressProvider)
}

View file

@ -87,10 +87,20 @@ object PosterZoomAnchorHolder {
fun consume(): PosterZoomAnchor? = pending.also { pending = null }
}
enum class PosterZoomOverlayExitAnimation {
COLLAPSE,
DISINTEGRATE,
}
class PosterZoomOverlayAction(
val icon: ImageVector,
val label: String,
val isDestructive: Boolean = false,
val exitAnimation: PosterZoomOverlayExitAnimation = if (isDestructive) {
PosterZoomOverlayExitAnimation.DISINTEGRATE
} else {
PosterZoomOverlayExitAnimation.COLLAPSE
},
val onSelected: () -> Unit,
)
@ -181,7 +191,7 @@ fun NuvioPosterZoomActionOverlay(
fun select(action: PosterZoomOverlayAction) {
if (phase != PosterZoomPhase.Open) return
if (action.isDestructive) {
if (action.exitAnimation == PosterZoomOverlayExitAnimation.DISINTEGRATE) {
phase = PosterZoomPhase.Disintegrating
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
action.onSelected()

View file

@ -0,0 +1,71 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
internal data class DisintegrationTrackedItem<K, T>(
val key: K,
val item: T,
val exiting: Boolean,
)
internal class ScopedDisintegrationTracker<S, K, T>(
private val itemKey: (T) -> K,
) {
private val exiting = LinkedHashMap<K, Pair<T, Int>>()
private var previous = LinkedHashMap<K, Pair<T, Int>>()
private var activeScope: S? = null
private var hasActiveScope = false
private var invalidations by mutableStateOf(0)
fun onDisintegrated(key: K) {
if (exiting.remove(key) != null) invalidations++
}
fun reset() {
exiting.clear()
previous = LinkedHashMap()
activeScope = null
hasActiveScope = false
}
fun sync(scope: S, items: List<T>): List<DisintegrationTrackedItem<K, T>> {
@Suppress("UNUSED_EXPRESSION")
invalidations
if (!hasActiveScope || activeScope != scope) {
exiting.clear()
previous = LinkedHashMap()
activeScope = scope
hasActiveScope = true
}
val current = LinkedHashMap<K, Pair<T, Int>>()
items.forEachIndexed { index, item -> current[itemKey(item)] = item to index }
for ((key, info) in previous) {
if (key !in current && key !in exiting) {
exiting[key] = info
}
}
for (key in current.keys) {
exiting.remove(key)
}
previous = current
val entries = ArrayList<DisintegrationTrackedItem<K, T>>(items.size + exiting.size)
items.forEach { item ->
val key = itemKey(item)
entries += DisintegrationTrackedItem(key, item, exiting = false)
}
exiting.entries
.sortedBy { it.value.second }
.forEach { (key, info) ->
val insertAt = info.second.coerceIn(0, entries.size)
entries.add(insertAt, DisintegrationTrackedItem(key, info.first, exiting = true))
}
return entries
}
}

View file

@ -25,20 +25,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.trackingMembershipDestinations
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_save
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading
import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle
import nuvio.composeapp.generated.resources.compose_tracking_list_picker_loading
import nuvio.composeapp.generated.resources.compose_tracking_list_picker_subtitle
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TraktListPickerDialog(
fun TrackingListPickerDialog(
visible: Boolean,
title: String,
tabs: List<TraktListTab>,
tabs: List<TrackingLibraryTab>,
membership: Map<String, Boolean>,
isPending: Boolean,
errorMessage: String?,
@ -48,6 +49,7 @@ fun TraktListPickerDialog(
) {
if (!visible) return
val tokens = MaterialTheme.nuvio
val destinations = trackingMembershipDestinations(tabs)
BasicAlertDialog(
onDismissRequest = onDismiss,
@ -67,7 +69,7 @@ fun TraktListPickerDialog(
color = tokens.colors.textPrimary,
)
Text(
text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
text = stringResource(Res.string.compose_tracking_list_picker_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
@ -80,7 +82,7 @@ fun TraktListPickerDialog(
)
}
if (isPending && tabs.isEmpty()) {
if (isPending && destinations.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -95,7 +97,7 @@ fun TraktListPickerDialog(
modifier = Modifier.size(tokens.icons.lg),
)
Text(
text = stringResource(Res.string.compose_trakt_list_picker_loading),
text = stringResource(Res.string.compose_tracking_list_picker_loading),
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
@ -108,7 +110,7 @@ fun TraktListPickerDialog(
.height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
items(items = tabs, key = { it.key }) { tab ->
items(items = destinations, key = { it.key }) { tab ->
val selected = membership[tab.key] == true
Row(
modifier = Modifier

View file

@ -16,7 +16,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktRelatedRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException
@ -408,15 +408,15 @@ object MetaDetailsRepository {
fallbackItemId: String,
fallbackItemType: String,
): MetaDetails {
TraktSettingsRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val traktSettings = TraktSettingsRepository.uiState.value
val trackingSettings = TrackingSettingsRepository.uiState.value
val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
val shouldUseTrakt = shouldUseTraktMoreLikeThis(
isAuthenticated = isTraktAuthenticated,
source = traktSettings.moreLikeThisSource,
source = trackingSettings.moreLikeThisSource,
) && supportsMoreLikeThis(meta, fallbackItemType)
if (shouldUseTrakt) {
@ -466,32 +466,32 @@ object MetaDetailsRepository {
}
private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean {
TraktSettingsRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val traktSettings = TraktSettingsRepository.uiState.value
val trackingSettings = TrackingSettingsRepository.uiState.value
val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
val tmdbSettings = TmdbSettingsRepository.snapshot()
return shouldUseTraktMoreLikeThis(
isAuthenticated = isTraktAuthenticated,
source = traktSettings.moreLikeThisSource,
source = trackingSettings.moreLikeThisSource,
) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty()
}
private fun buildMetaScreenSettingsFingerprint(
settings: com.nuvio.app.features.mdblist.MdbListSettings,
): String {
TraktSettingsRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val providers = settings.enabledProvidersInPriorityOrder().joinToString(",")
val traktSettings = TraktSettingsRepository.uiState.value
val trackingSettings = TrackingSettingsRepository.uiState.value
val traktAuthMode = TraktAuthRepository.uiState.value.mode
val tmdbSettings = TmdbSettingsRepository.snapshot()
return buildString {
append("${settings.enabled}:${settings.apiKey.trim()}:$providers")
append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode")
append("|more_like=${trackingSettings.moreLikeThisSource}:$traktAuthMode")
append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}")
}
}

View file

@ -69,6 +69,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import coil3.compose.AsyncImage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.TrailerPlaybackMode
@ -78,10 +79,11 @@ import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.TrackingListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.rememberHeroStretchState
import dev.chrisbanes.haze.hazeSource
@ -104,6 +106,10 @@ import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.PendingTrackingMembershipRemoval
import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost
import com.nuvio.app.features.library.executeTrackingMembershipOperation
import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
@ -114,14 +120,18 @@ import com.nuvio.app.features.trakt.TraktCommentReview
import com.nuvio.app.features.trakt.TraktCommentsRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -137,6 +147,8 @@ import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
private val watchedMarkerDiagnosticLog = Logger.withTag("WatchedMarkerDiag")
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun MetaDetailsScreen(
@ -163,9 +175,9 @@ fun MetaDetailsScreen(
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
val trackingSettingsUiState by remember {
TrackingSettingsRepository.ensureLoaded()
TrackingSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val tmdbSettingsUiState by remember {
TmdbSettingsRepository.ensureLoaded()
@ -211,13 +223,71 @@ fun MetaDetailsScreen(
var selectedComment by remember(type, id) { mutableStateOf<TraktCommentReview?>(null) }
val detailsScope = rememberCoroutineScope()
var showLibraryListPicker by remember(type, id) { mutableStateOf(false) }
var pickerTabs by remember(type, id) { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerTabs by remember(type, id) { mutableStateOf<List<TrackingLibraryTab>>(emptyList()) }
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
var pendingTrackingRemoval by remember(type, id) {
mutableStateOf<PendingTrackingMembershipRemoval?>(null)
}
val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed)
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) }
LaunchedEffect(
displayedMeta?.id,
displayedMeta?.type,
displayedMeta?.name,
displayedMeta?.videos,
watchedUiState.items,
watchedUiState.isLoaded,
watchedUiState.hasLoadedRemoteItems,
fullyWatchedSeriesKeys,
watchProgressUiState.entries,
trackingSettingsUiState.watchProgressSource,
) {
val meta = displayedMeta ?: return@LaunchedEffect
val posterKey = watchedItemKey(meta.type, meta.id)
val expectedEpisodeKeys = meta.videos.map { episode ->
watchedItemKey(meta.type, meta.id, episode.season, episode.episode)
}
val matchedEpisodeKeys = expectedEpisodeKeys.filter(watchedUiState.watchedKeys::contains)
val completedProgressMatches = meta.videos.count { episode ->
val videoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
progressByVideoId[videoId]?.isEffectivelyCompleted == true
}
val directItemKeys = watchedUiState.items
.asSequence()
.filter { item -> item.id == meta.id }
.take(10)
.joinToString(separator = ",") { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}
val titleCandidateKeys = watchedUiState.items
.asSequence()
.filter { item -> item.name.equals(meta.name, ignoreCase = true) }
.take(10)
.joinToString(separator = ",") { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}
watchedMarkerDiagnosticLog.i {
"marker state requestedSource=${trackingSettingsUiState.watchProgressSource} " +
"content=${meta.type}:${meta.id} repositoryLoaded=${watchedUiState.isLoaded} " +
"remoteLoaded=${watchedUiState.hasLoadedRemoteItems} repositoryItems=${watchedUiState.items.size} " +
"posterKey=$posterKey posterInWatched=${posterKey in watchedUiState.watchedKeys} " +
"posterInFullyWatched=${posterKey in fullyWatchedSeriesKeys} videos=${meta.videos.size} " +
"episodeMarkerMatches=${matchedEpisodeKeys.size} completedProgressMatches=$completedProgressMatches " +
"directItemKeys=[$directItemKeys] titleCandidateKeys=[$titleCandidateKeys] " +
"expectedEpisodeKeys=[${expectedEpisodeKeys.take(10).joinToString(",")}] " +
"repositoryKeySample=[${watchedUiState.watchedKeys.take(10).joinToString(",")}]"
}
}
val shouldShowComments = commentsEnabled &&
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
displayedMeta != null &&
@ -290,7 +360,7 @@ fun MetaDetailsScreen(
id,
displayedMeta?.id,
uiState.isLoading,
traktSettingsUiState.moreLikeThisSource,
trackingSettingsUiState.moreLikeThisSource,
traktAuthUiState.mode,
tmdbSettingsUiState.enabled,
tmdbSettingsUiState.useMoreLikeThis,
@ -405,7 +475,7 @@ fun MetaDetailsScreen(
val openLibraryListPicker = remember(meta) {
{
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
pickerTabs = LibraryRepository.libraryListTabs()
pickerTabs = LibraryRepository.libraryListTabs(libraryItem)
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
@ -413,7 +483,7 @@ fun MetaDetailsScreen(
detailsScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
val tabs = LibraryRepository.libraryListTabs(libraryItem)
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
@ -426,9 +496,44 @@ fun MetaDetailsScreen(
Unit
}
}
val toggleSaved = remember(meta) {
val toggleSaved = remember(meta, trackingListsUpdateFailedMessage) {
{
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
val item = meta.toLibraryItem(savedAtEpochMs = 0L)
detailsScope.launch {
val toggleMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.toggleSaved(
item = item,
confirmedRemovalProviders = confirmedProviders,
)
}
executeTrackingMembershipOperation(
operation = { toggleMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = toggleMembership,
onApplied = ::showTrackingMembershipRewriteFeedback,
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
} else {
showTrackingMembershipRewriteFeedback(result)
}
},
onFailure = { error ->
NuvioToastController.show(
error.message ?: trackingListsUpdateFailedMessage,
)
},
)
}
Unit
}
}
val toggleWatched = remember(metaPreview) {
@ -1200,7 +1305,7 @@ fun MetaDetailsScreen(
)
}
TraktListPickerDialog(
TrackingListPickerDialog(
visible = showLibraryListPicker,
title = meta.name,
tabs = pickerTabs,
@ -1208,9 +1313,11 @@ fun MetaDetailsScreen(
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
pickerMembership = toggleTrackingLibraryMembership(
tabs = pickerTabs,
membership = pickerMembership,
key = listKey,
)
},
onDismiss = {
if (!pickerPending) {
@ -1221,21 +1328,52 @@ fun MetaDetailsScreen(
detailsScope.launch {
pickerPending = true
pickerError = null
runCatching {
val item = meta.toLibraryItem(savedAtEpochMs = 0L)
val desiredMembership = pickerMembership.toMap()
val applyMembership: suspend (Set<TrackingProviderId>) ->
TrackingMembershipApplyResult = { confirmedProviders ->
LibraryRepository.applyMembershipChanges(
item = meta.toLibraryItem(savedAtEpochMs = 0L),
desiredMembership = pickerMembership,
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedProviders,
)
}.onSuccess {
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed)
}
val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result ->
showTrackingMembershipRewriteFeedback(result)
showLibraryListPicker = false
}
executeTrackingMembershipOperation(
operation = { applyMembership(emptySet()) },
onSuccess = { result ->
if (result.requiresRemovalConfirmation) {
pendingTrackingRemoval = PendingTrackingMembershipRemoval(
itemTitle = item.name,
confirmations = result.requiredRemovalConfirmations,
retry = applyMembership,
onApplied = completeMembershipUpdate,
onFailure = { error ->
pickerError = error.message
?: trackingListsUpdateFailedMessage
},
)
} else {
completeMembershipUpdate(result)
}
},
onFailure = { error ->
pickerError = error.message ?: trackingListsUpdateFailedMessage
},
)
pickerPending = false
}
},
)
TrackingMembershipRemovalConfirmationHost(
pending = pendingTrackingRemoval,
onPendingChange = { pendingTrackingRemoval = it },
)
selectedComment?.let { comment ->
val commentIndex = comments.indexOfFirst { it.id == comment.id }.coerceAtLeast(0)
CommentDetailSheet(
@ -1863,8 +2001,8 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons(
playLabel = playButtonLabel,
secondaryActions = listOf(
DetailSecondaryAction(
secondaryActions = buildList {
add(DetailSecondaryAction(
label = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
@ -1877,8 +2015,8 @@ private fun ConfiguredMetaSections(
},
isActive = isWatched,
onClick = onWatchedClick,
),
DetailSecondaryAction(
))
add(DetailSecondaryAction(
label = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
@ -1892,8 +2030,8 @@ private fun ConfiguredMetaSections(
isActive = isSaved,
onClick = onSaveClick,
onLongClick = onSaveLongClick,
),
),
))
},
isTablet = isTablet,
onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,

View file

@ -49,10 +49,8 @@ import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding
import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
@ -76,7 +74,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
@ -97,7 +94,6 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.home.components.continueWatchingHeroViewportReserveHeight
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
@ -144,12 +140,12 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val effectiveWatchProgressSource by WatchProgressRepository.activeSourceState.collectAsStateWithLifecycle()
val effectiveWatchProgressSource = watchProgressUiState.source
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
val trackingSettingsUiState by remember {
TrackingSettingsRepository.ensureLoaded()
TrackingSettingsRepository.uiState
}.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
@ -180,60 +176,66 @@ fun HomeScreen(
}
}
val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT
val progressProviderOwnsCompletedHistory = remember(effectiveWatchProgressSource) {
WatchProgressRepository.activeProviderOwnsCompletedHistoryProjection()
}
val continueWatchingCutoffEpochMs = remember(
effectiveWatchProgressSource,
trackingSettingsUiState.continueWatchingDaysCap,
) {
WatchProgressRepository.activeProviderContinueWatchingCutoffEpochMs(
daysCap = trackingSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
if (isTraktProgressActive) emptyList() else watchedUiState.items
val nextUpWatchedItems = remember(watchedUiState.items, progressProviderOwnsCompletedHistory) {
if (progressProviderOwnsCompletedHistory) emptyList() else watchedUiState.items
}
val effectiveWatchProgressEntries = remember(
watchProgressUiState.entries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
watchProgressUiState.hiddenContentIds,
continueWatchingCutoffEpochMs,
) {
val filtered = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry ->
entry.parentMetaId in watchProgressUiState.hiddenContentIds ||
WatchProgressRepository.isDroppedShow(entry.parentMetaId)
}
filterEntriesForTraktContinueWatchingWindow(
entries = filtered,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
filterEntriesForContinueWatchingWindow(
entries = visibleProviderEntries,
cutoffEpochMs = continueWatchingCutoffEpochMs,
)
}
val allNextUpSeedCandidates = remember(
watchProgressUiState.entries,
watchProgressUiState.hiddenContentIds,
nextUpWatchedItems,
isTraktProgressActive,
progressProviderOwnsCompletedHistory,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
val filteredEntries = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
}
buildHomeNextUpSeedCandidates(
progressEntries = filteredEntries,
progressEntries = watchProgressUiState.entries,
watchedItems = nextUpWatchedItems,
isTraktProgressActive = isTraktProgressActive,
providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed,
isContentHidden = { contentId ->
contentId in watchProgressUiState.hiddenContentIds ||
WatchProgressRepository.isDroppedShow(contentId)
},
)
}
val recentNextUpSeedCandidates = remember(
allNextUpSeedCandidates,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
continueWatchingCutoffEpochMs,
) {
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
filterHomeNextUpCandidatesForContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
cutoffEpochMs = continueWatchingCutoffEpochMs,
)
}
@ -329,10 +331,10 @@ fun HomeScreen(
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.isLoaded,
watchedUiState.hasLoadedRemoteItems,
isTraktProgressActive,
progressProviderOwnsCompletedHistory,
) {
isHomeNextUpSeedSourceLoaded(
isTraktProgressActive = isTraktProgressActive,
providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory,
hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress,
hasLoadedWatchedItems = watchedUiState.isLoaded,
hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems,
@ -343,13 +345,14 @@ fun HomeScreen(
continueWatchingPreferences.dismissedNextUpKeys,
activeNextUpSeedContentIds,
currentNextUpSeedByContentId,
isTraktProgressActive,
progressProviderOwnsCompletedHistory,
watchProgressUiState.hasLoadedRemoteProgress,
shouldValidateMissingNextUpSeeds,
processedNextUpContentIds,
nextUpItemsBySeries,
continueWatchingPreferences.showUnairedNextUp,
watchedUiState.isLoaded,
watchProgressUiState.hiddenContentIds,
) {
cachedSnapshots.first.mapNotNull { cached ->
if (
@ -373,7 +376,7 @@ fun HomeScreen(
}
}
if (
isTraktProgressActive &&
progressProviderOwnsCompletedHistory &&
watchProgressUiState.hasLoadedRemoteProgress &&
cached.contentId in processedNextUpContentIds &&
cached.contentId !in nextUpItemsBySeries.keys
@ -386,7 +389,10 @@ fun HomeScreen(
if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) {
return@mapNotNull null
}
if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) {
if (
cached.contentId in watchProgressUiState.hiddenContentIds ||
WatchProgressRepository.isDroppedShow(cached.contentId)
) {
return@mapNotNull null
}
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
@ -398,9 +404,16 @@ fun HomeScreen(
cached.contentId to (sortTimestamp to item)
}.toMap()
}
val cachedInProgressItems = remember(cachedSnapshots.second, isTraktProgressActive) {
val cachedInProgressItems = remember(
cachedSnapshots.second,
effectiveWatchProgressSource,
watchProgressUiState.hiddenContentIds,
) {
cachedSnapshots.second.mapNotNull { cached ->
if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) {
if (
cached.contentId in watchProgressUiState.hiddenContentIds ||
WatchProgressRepository.isDroppedShow(cached.contentId)
) {
return@mapNotNull null
}
cached.resolvedProgressKey() to cached.toContinueWatchingItem()
@ -570,7 +583,7 @@ fun HomeScreen(
) {
if (
!isHomeNextUpSeedSourceLoaded(
isTraktProgressActive = isTraktProgressActive,
providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory,
hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress,
hasLoadedWatchedItems = watchedUiState.isLoaded,
hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems,
@ -662,7 +675,7 @@ fun HomeScreen(
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys,
isTraktProgressActive = isTraktProgressActive,
providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory,
)
}
} catch (error: Throwable) {
@ -925,6 +938,7 @@ fun HomeScreen(
preferences = continueWatchingPreferences,
continueWatchingItems = continueWatchingItems,
upcomingItems = upcomingItems,
dataSourceKey = effectiveWatchProgressSource,
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
continueWatchingListState = continueWatchingListState,
@ -946,6 +960,7 @@ fun HomeScreen(
preferences = continueWatchingPreferences,
continueWatchingItems = continueWatchingItems,
upcomingItems = upcomingItems,
dataSourceKey = effectiveWatchProgressSource,
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
continueWatchingListState = continueWatchingListState,
@ -989,6 +1004,7 @@ fun HomeScreen(
preferences = continueWatchingPreferences,
continueWatchingItems = continueWatchingItems,
upcomingItems = upcomingItems,
dataSourceKey = effectiveWatchProgressSource,
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
continueWatchingListState = continueWatchingListState,
@ -1045,6 +1061,7 @@ private fun LazyListScope.homeContinueWatchingSections(
preferences: ContinueWatchingPreferencesUiState,
continueWatchingItems: List<ContinueWatchingItem>,
upcomingItems: List<ContinueWatchingItem>,
dataSourceKey: WatchProgressSource,
sectionPadding: Dp,
layout: ContinueWatchingLayout,
continueWatchingListState: LazyListState,
@ -1058,6 +1075,7 @@ private fun LazyListScope.homeContinueWatchingSections(
item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) {
HomeContinueWatchingSection(
items = continueWatchingItems,
dataSourceKey = dataSourceKey,
style = preferences.style,
useEpisodeThumbnails = preferences.useEpisodeThumbnails,
blurNextUp = preferences.blurNextUp,
@ -1075,6 +1093,7 @@ private fun LazyListScope.homeContinueWatchingSections(
item(key = HOME_UPCOMING_SECTION_KEY) {
HomeContinueWatchingSection(
items = upcomingItems,
dataSourceKey = dataSourceKey,
style = preferences.style,
useEpisodeThumbnails = preferences.useEpisodeThumbnails,
blurNextUp = preferences.blurNextUp,
@ -1095,8 +1114,6 @@ private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching"
private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming"
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_RESOLUTION_CONCURRENCY = 4
private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3
private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L
@ -1164,59 +1181,45 @@ internal fun planHomeNextUpResolutionCandidates(
deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit),
)
internal fun filterEntriesForTraktContinueWatchingWindow(
internal fun filterEntriesForContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<WatchProgressEntry> {
if (!isTraktProgressActive) return entries
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
cutoffEpochMs: Long?,
): List<WatchProgressEntry> = cutoffEpochMs
?.let { cutoff -> entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoff } }
?: entries
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
internal fun filterHomeNextUpCandidatesForContinueWatchingWindow(
candidates: List<CompletedSeriesCandidate>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<CompletedSeriesCandidate> {
if (!isTraktProgressActive) return candidates
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs }
}
cutoffEpochMs: Long?,
): List<CompletedSeriesCandidate> = cutoffEpochMs
?.let { cutoff -> candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoff } }
?: candidates
internal fun buildHomeNextUpSeedCandidates(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
providerOwnsCompletedHistory: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
shouldUseProgressSeed: (WatchProgressEntry, Long) -> Boolean = { entry, _ ->
entry.shouldUseAsCompletedSeedForContinueWatching()
},
isContentHidden: (String) -> Boolean = { false },
): List<CompletedSeriesCandidate> {
val progressSeeds = progressEntries
.asSequence()
.filterNot { entry -> isContentHidden(entry.parentMetaId) }
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
.filter { entry ->
if (isTraktProgressActive) {
shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs)
} else {
entry.shouldUseAsCompletedSeedForContinueWatching()
}
}
.filter { entry -> shouldUseProgressSeed(entry, nowEpochMs) }
.toList()
val watchedSeeds = if (isTraktProgressActive) {
val watchedSeeds = if (providerOwnsCompletedHistory) {
emptyList()
} else {
watchedItems.filter { item ->
item.type.isSeriesTypeForContinueWatching() &&
!isContentHidden(item.id) &&
item.type.isSeriesTypeForContinueWatching() &&
item.season != null &&
item.episode != null &&
item.season != 0 &&
@ -1262,12 +1265,12 @@ internal fun filterNextUpItemsByCurrentSeeds(
}
internal fun isHomeNextUpSeedSourceLoaded(
isTraktProgressActive: Boolean,
providerOwnsCompletedHistory: Boolean,
hasLoadedRemoteProgress: Boolean,
hasLoadedWatchedItems: Boolean,
hasLoadedRemoteWatchedItems: Boolean,
): Boolean = hasLoadedRemoteProgress && (
isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems)
providerOwnsCompletedHistory || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems)
)
internal fun cachedNextUpHasAired(
@ -1356,7 +1359,7 @@ private suspend fun resolveHomeNextUpCandidate(
preferFurthestEpisode: Boolean,
showUnairedNextUp: Boolean,
dismissedNextUpKeys: Set<String>,
isTraktProgressActive: Boolean,
providerOwnsCompletedHistory: Boolean,
): HomeNextUpResolutionAttempt {
val contentId = completedEntry.content.id
val meta = try {
@ -1372,17 +1375,16 @@ private suspend fun resolveHomeNextUpCandidate(
return HomeNextUpResolutionAttempt.transientFailure()
}
val resolvedProgressEntries = if (isTraktProgressActive) {
remapTraktProgressEntries(watchProgressEntries, contentId)
} else {
watchProgressEntries
}
val resolvedProgressEntries = WatchProgressRepository.prepareNextUpProgressEntries(
entries = watchProgressEntries,
contentId = contentId,
)
val resolvedWatchedItems = watchedItems
val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}
if (!isTraktProgressActive) {
if (!providerOwnsCompletedHistory) {
WatchedRepository.reconcileFullyWatchedSeriesState(
meta = meta,
todayIsoDate = todayIsoDate,
@ -1458,17 +1460,6 @@ private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaV
}
}
private fun shouldUseAsTraktNextUpSeed(
entry: WatchProgressEntry,
nowEpochMs: Long,
): Boolean {
if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false
if (entry.source != WatchProgressSourceTraktPlayback) return true
val ageMs = nowEpochMs - entry.lastUpdatedEpochMs
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
}
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
progress: WatchProgressEntry,
latestCompletedAt: Long?,
@ -1740,15 +1731,6 @@ internal fun buildHomeInProgressCacheSnapshot(
}
}
internal fun effectiveContinueWatchingCacheSource(
isTraktProgressActive: Boolean,
): WatchProgressSource =
if (isTraktProgressActive) {
WatchProgressSource.TRAKT
} else {
WatchProgressSource.NUVIO_SYNC
}
private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) =
WatchProgressEntry(
contentType = content.type,
@ -1918,37 +1900,3 @@ private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean =
contentType.equals(CloudLibraryContentType, ignoreCase = true) ||
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
private suspend fun remapTraktProgressEntries(
entries: List<WatchProgressEntry>,
contentId: String,
): List<WatchProgressEntry> {
return entries.map { entry ->
if (entry.parentMetaId != contentId) {
entry
} else {
val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = entry.parentMetaId,
contentType = entry.contentType ?: "series",
season = entry.seasonNumber,
episode = entry.episodeNumber,
episodeTitle = entry.episodeTitle,
)
if (mapping != null) {
entry.copy(
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
videoId = com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
parentMetaId = entry.parentMetaId,
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
fallbackVideoId = entry.videoId,
),
episodeTitle = mapping.title ?: entry.episodeTitle,
)
} else {
entry
}
}
}
}

View file

@ -28,9 +28,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@ -55,12 +54,14 @@ import com.nuvio.app.core.ui.nuvioCardDepth
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
import com.nuvio.app.core.ui.ScopedDisintegrationTracker
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import com.nuvio.app.features.watchprogress.CurrentDateProvider
@ -223,6 +224,7 @@ private fun firstNonBlank(vararg values: String?): String? =
internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle,
dataSourceKey: WatchProgressSource,
useEpisodeThumbnails: Boolean = true,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier,
@ -238,6 +240,7 @@ internal fun HomeContinueWatchingSection(
if (sectionPadding != null && layout != null) {
HomeContinueWatchingSectionContent(
items = items,
dataSourceKey = dataSourceKey,
style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
@ -253,6 +256,7 @@ internal fun HomeContinueWatchingSection(
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
HomeContinueWatchingSectionContent(
items = items,
dataSourceKey = dataSourceKey,
style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
@ -271,6 +275,7 @@ internal fun HomeContinueWatchingSection(
@Composable
private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>,
dataSourceKey: WatchProgressSource,
style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
@ -282,103 +287,62 @@ private fun HomeContinueWatchingSectionContent(
onItemClick: ((ContinueWatchingItem) -> Unit)?,
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
) {
val disintegration = remember { ContinueWatchingDisintegrationHolder() }
val displayEntries = disintegration.sync(items)
key(dataSourceKey) {
val disintegration = remember {
ScopedDisintegrationTracker<WatchProgressSource, String, ContinueWatchingItem>(
itemKey = ContinueWatchingItem::videoId,
)
}
val displayEntries = disintegration.sync(dataSourceKey, items)
NuvioShelfSection(
title = title ?: stringResource(Res.string.compose_settings_page_continue_watching),
entries = displayEntries,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
key = { entry -> entry.videoId },
animatePlacement = true,
state = listState,
) { entry ->
val item = entry.item
val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } }
val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } }
DisintegratingContainer(
disintegrating = entry.exiting,
onDisintegrated = { disintegration.onExited(entry.videoId) },
) {
when (style) {
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
item = item,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
NuvioShelfSection(
title = title ?: stringResource(Res.string.compose_settings_page_continue_watching),
entries = displayEntries,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
key = { entry -> entry.key },
animatePlacement = true,
state = listState,
) { entry ->
val item = entry.item
val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } }
val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } }
DisintegratingContainer(
disintegrating = entry.exiting,
onDisintegrated = { disintegration.onDisintegrated(entry.key) },
) {
when (style) {
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
item = item,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onClick,
onLongClick = onLongClick,
)
}
}
}
}
}
private data class ContinueWatchingDisplayEntry(
val videoId: String,
val item: ContinueWatchingItem,
val exiting: Boolean,
)
private class ContinueWatchingDisintegrationHolder {
private val exiting = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
private var previous = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
private var invalidations by mutableStateOf(0)
fun onExited(videoId: String) {
if (exiting.remove(videoId) != null) invalidations++
}
fun sync(items: List<ContinueWatchingItem>): List<ContinueWatchingDisplayEntry> {
@Suppress("UNUSED_EXPRESSION")
invalidations
val current = LinkedHashMap<String, Pair<ContinueWatchingItem, Int>>()
items.forEachIndexed { index, item -> current[item.videoId] = item to index }
for ((videoId, info) in previous) {
if (videoId !in current && videoId !in exiting) {
exiting[videoId] = info
}
}
for (videoId in current.keys) {
exiting.remove(videoId)
}
previous = current
val entries = ArrayList<ContinueWatchingDisplayEntry>(items.size + exiting.size)
items.forEach { item ->
entries += ContinueWatchingDisplayEntry(item.videoId, item, exiting = false)
}
exiting.entries
.sortedBy { it.value.second }
.forEach { (videoId, info) ->
val insertAt = info.second.coerceIn(0, entries.size)
entries.add(insertAt, ContinueWatchingDisplayEntry(videoId, info.first, exiting = true))
}
return entries
}
}
@Composable
fun ContinueWatchingStylePreview(
style: ContinueWatchingSectionStyle,

View file

@ -85,7 +85,7 @@ internal data class LibraryVerticalProjection(
)
internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List<LibrarySortOption> =
if (sourceMode == LibrarySourceMode.TRAKT) {
if (sourceMode.isRemoteTrackingSource) {
LibrarySortOption.entries
} else {
LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT }
@ -149,8 +149,8 @@ internal fun buildLibraryVerticalProjection(
selectedType: String?,
sortOption: LibrarySortOption,
): LibraryVerticalProjection {
val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList()
val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) {
val availableSections = if (sourceMode.isRemoteTrackingSource) sections else emptyList()
val selectedSection = if (sourceMode.isRemoteTrackingSource) {
sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull()
} else {
null
@ -244,6 +244,9 @@ private fun libraryDisplayItemKey(item: LibraryItem): String =
private fun String.normalizedLibraryType(): String = trim().lowercase()
internal val LibrarySourceMode.isRemoteTrackingSource: Boolean
get() = this != LibrarySourceMode.LOCAL
@Serializable
private data class StoredLibraryDisplaySettings(
@SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.library
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.tracking.TrackingAttributedItem
import kotlinx.serialization.Serializable
@Serializable
@ -24,8 +25,14 @@ data class LibraryItem(
val imdbId: String? = null,
val tmdbId: Int? = null,
val traktId: Int? = null,
override val trackingProviderId: String? = null,
override val trackingProviderItemId: String? = null,
override val trackingSourceUrl: String? = null,
val savedAtEpochMs: Long,
)
) : TrackingAttributedItem {
override val trackingContentId: String
get() = id
}
data class LibrarySection(
val type: String,
@ -36,6 +43,7 @@ data class LibrarySection(
enum class LibrarySourceMode {
LOCAL,
TRAKT,
SIMKL,
}
data class LibraryUiState(

View file

@ -2,20 +2,24 @@ package com.nuvio.app.features.library
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktListType
import com.nuvio.app.features.trakt.TraktMembershipChanges
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
import com.nuvio.app.features.tracking.TrackingLibraryProvider
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.supportsContentType
import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.tracking.providerId
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.atomicfu.locks.SynchronizedObject
@ -47,7 +51,6 @@ import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.library_local_tab_title
import nuvio.composeapp.generated.resources.library_other
import nuvio.composeapp.generated.resources.trakt_lists_update_failed
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@ -92,80 +95,72 @@ object LibraryRepository {
private val lastPersistedContentRevisionByProfile = mutableMapOf<Int, Long>()
init {
ensureTrackingProvidersRegistered()
syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync()
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
}
TrackingProviderRegistry.connectedProviderIds.collectLatest {
TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare)
activeLibraryProvider()?.let { provider ->
refreshLibraryProvider(
provider = provider,
reason = "connection state change",
intent = provider.connectionRefreshIntent,
)
}
publish()
}
}
syncScope.launch {
TraktSettingsRepository.uiState
TrackingSettingsRepository.uiState
.map { it.librarySourceMode }
.distinctUntilChanged()
.collectLatest { source ->
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
TraktLibraryRepository.preloadListTabsAsync()
publish()
refreshTraktLibraryAsync()
} else {
publish()
.collectLatest {
publish()
activeLibraryProvider()?.let { provider ->
provider.prepare()
refreshLibraryProviderAsync(provider)
}
}
}
syncScope.launch {
TraktLibraryRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) {
publish()
TrackingProviderRegistry.libraryProviders().forEach { provider ->
syncScope.launch {
provider.changes.collectLatest {
if (TrackingProviderRegistry.isAuthenticated(provider.providerId)) {
publish()
}
}
}
}
}
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::ensureLoaded)
while (true) {
val activeProfileId = ProfileRepository.activeProfileId
val snapshot = localState.snapshot()
if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break
loadFromDisk(activeProfileId)
}
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync()
}
}
TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare)
activeLibraryProvider()?.let(::refreshLibraryProviderAsync)
}
fun onProfileChanged(profileId: Int) {
val current = localState.snapshot()
if (profileId == current.token.profileId && current.hasLoaded) return
TraktSettingsRepository.onProfileChanged()
if (!loadFromDisk(profileId)) return
TraktAuthRepository.onProfileChanged(profileId)
TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync()
}
}
TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::onProfileChanged)
TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare)
activeLibraryProvider()?.let(::refreshLibraryProviderAsync)
}
fun clearLocalState() {
val transition = synchronized(loadLock) { localState.reset() }
transition.detachedPushJob?.cancel()
TraktAuthRepository.clearLocalState()
TraktLibraryRepository.clearLocalState()
TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::clearLocalState)
_uiState.value = LibraryUiState()
}
@ -218,20 +213,21 @@ object LibraryRepository {
) != null
}
suspend fun pullFromServer(profileId: Int) {
suspend fun pullFromServer(
profileId: Int,
refreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.AUTOMATIC,
) {
val operationToken = activeOperationToken(profileId) ?: run {
log.d { "Skipping library pull for inactive profile $profileId" }
return
}
if (isTraktLibrarySourceActive()) {
try {
TraktLibraryRepository.refreshNow()
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to pull Trakt library" }
}
activeLibraryProvider()?.let { provider ->
refreshLibraryProvider(
provider = provider,
reason = "explicit pull",
intent = refreshIntent,
)
if (!isActiveOperation(operationToken)) return
publish()
return
@ -275,26 +271,37 @@ object LibraryRepository {
private fun isActiveOperation(token: LibraryProfileToken): Boolean =
localState.isCurrent(token) && ProfileRepository.activeProfileId == token.profileId
fun toggleSaved(item: LibraryItem) {
suspend fun toggleSaved(
item: LibraryItem,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult {
ensureLoaded()
if (isTraktLibrarySourceActive()) {
val profileId = localState.snapshot().token.profileId
log.i { "toggleSaved routed to Trakt library source item=${item.id} type=${item.type} profile=$profileId" }
syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e ->
log.e(e) { "Failed to toggle Trakt watchlist" }
NuvioToastController.show(
e.message?.takeIf { it.isNotBlank() }
?: getString(Res.string.trakt_lists_update_failed),
)
}
publish()
activeLibraryProvider()?.let { provider ->
val providerMembership = provider.membership(item)
val desiredMembership = provider.toggledDefaultMembership(providerMembership)
log.i {
"toggleSaved routed to ${provider.providerId.storageId} library source " +
"item=${item.id} type=${item.type} profile=${localState.snapshot().token.profileId}"
}
return
return applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = setOf(provider.providerId),
updateLocal = false,
)
}
return toggleLocalSavedInternal(item)
}
fun toggleLocalSaved(item: LibraryItem) {
ensureLoaded()
toggleLocalSavedInternal(item)
}
private fun toggleLocalSavedInternal(item: LibraryItem): TrackingMembershipApplyResult {
val result = localState.toggle(
item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()),
)
@ -312,6 +319,7 @@ object LibraryRepository {
persist(result.snapshot)
publish()
pushToServer(result.snapshot)
return TrackingMembershipApplyResult()
}
fun save(item: LibraryItem) {
@ -355,16 +363,7 @@ object LibraryRepository {
fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded()
if (isTraktLibrarySourceActive()) {
if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type)
}
val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
if (entry != null) {
return TraktLibraryRepository.isInAnyList(entry.id, entry.type)
}
return false
}
activeLibraryProvider()?.let { provider -> return provider.contains(id, type) }
return if (type != null) {
localState.contains(id, type)
@ -376,48 +375,74 @@ object LibraryRepository {
fun savedItem(id: String): LibraryItem? {
ensureLoaded()
if (isTraktLibrarySourceActive()) {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
}
activeLibraryProvider()?.let { provider -> return provider.find(id) }
return localState.findById(id)
}
fun libraryListTabs(): List<TraktListTab> {
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.currentListTabs()
} else {
emptyList()
}
return libraryTabsWithLocal(traktTabs)
}
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
fun libraryListTabs(item: LibraryItem? = null): List<TrackingLibraryTab> =
libraryTabsWithLocal(
TrackingProviderRegistry.connectedLibraryProviders()
.flatMap { provider -> provider.snapshot().tabs },
).filter { tab -> item == null || tab.supportsContentType(item.type) }
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded()
val inLocal = localState.contains(item.id, item.type)
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
return libraryMembershipWithLocal(
inLocal = inLocal,
traktMembership = traktMembership,
)
val memberships = linkedMapOf<String, Boolean>()
TrackingProviderRegistry.connectedLibraryProviders().forEach { provider ->
memberships += provider.membership(item)
}
return libraryMembershipWithLocal(inLocal = inLocal)
return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships)
}
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
suspend fun applyMembershipChanges(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult = applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = null,
updateLocal = true,
)
private suspend fun applyMembershipChanges(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
confirmedRemovalProviders: Set<TrackingProviderId>,
targetProviderIds: Set<TrackingProviderId>?,
updateLocal: Boolean,
): TrackingMembershipApplyResult {
ensureLoaded()
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
val currentlyInLocal = localState.contains(item.id, item.type)
val profileId = localState.snapshot().token.profileId
val providerChanges = TrackingProviderRegistry.connectedLibraryProviders()
.filter { provider -> targetProviderIds == null || provider.providerId in targetProviderIds }
.mapNotNull { provider ->
val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key)
val providerMembership = desiredMembership.filterKeys(providerListKeys::contains)
providerMembership.takeIf { membership -> membership.isNotEmpty() }?.let { membership ->
provider to membership
}
}
val requiredConfirmations = providerChanges.mapNotNull { (provider, providerMembership) ->
provider.membershipRemovalConfirmation(item, providerMembership)
?.takeUnless { confirmation -> confirmation.providerId in confirmedRemovalProviders }
}
log.i {
"Applying library membership item=${item.id} type=${item.type} profile=$profileId " +
"localDesired=$localDesired currentlyInLocal=$currentlyInLocal " +
"traktAuthenticated=${TraktAuthRepository.isAuthenticated.value}"
"connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}"
}
if (localDesired != currentlyInLocal) {
if (requiredConfirmations.isNotEmpty()) {
return TrackingMembershipApplyResult(
requiredRemovalConfirmations = requiredConfirmations,
)
}
if (updateLocal && localDesired != currentlyInLocal) {
if (localDesired) {
save(item)
} else {
@ -425,26 +450,52 @@ object LibraryRepository {
}
}
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
if (traktMembership.isNotEmpty()) {
TraktLibraryRepository.applyMembershipChanges(
var firstFailure: Throwable? = null
val resolutions = mutableListOf<TrackingMembershipResolution>()
providerChanges.forEach { (provider, providerMembership) ->
try {
provider.applyMembership(
profileId = profileId,
item = item,
changes = TraktMembershipChanges(desiredMembership = traktMembership),
)
desiredMembership = providerMembership,
destructiveRemovalConfirmed = provider.providerId in confirmedRemovalProviders,
)?.let(resolutions::add)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (firstFailure == null) firstFailure = error
log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" }
}
publish()
} else {
publish()
}
publish()
firstFailure?.let { throw it }
return TrackingMembershipApplyResult(resolutions = resolutions)
}
suspend fun removeFromList(item: LibraryItem, listKey: String) {
suspend fun removeFromList(
item: LibraryItem,
listKey: String,
confirmedRemovalProviders: Set<TrackingProviderId> = emptySet(),
): TrackingMembershipApplyResult {
ensureLoaded()
val targetProvider = TrackingProviderRegistry.connectedLibraryProviders()
.firstOrNull { provider -> provider.snapshot().tabs.any { tab -> tab.key == listKey } }
val currentMembership = if (listKey == LOCAL_LIBRARY_LIST_KEY) {
mapOf(LOCAL_LIBRARY_LIST_KEY to localState.contains(item.id, item.type))
} else {
targetProvider?.membership(item).orEmpty()
}
val desiredMembership = libraryMembershipWithRemovedList(
currentMembership = getMembershipSnapshot(item),
currentMembership = currentMembership,
listKey = listKey,
)
applyMembershipChanges(item, desiredMembership)
return applyMembershipChanges(
item = item,
desiredMembership = desiredMembership,
confirmedRemovalProviders = confirmedRemovalProviders,
targetProviderIds = targetProvider?.let { provider -> setOf(provider.providerId) }.orEmpty(),
updateLocal = listKey == LOCAL_LIBRARY_LIST_KEY,
)
}
private fun pushToServer(snapshot: LibraryLocalSnapshot) {
@ -528,28 +579,16 @@ object LibraryRepository {
private fun publish() {
val localSnapshot = localState.snapshot()
if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty()
if (listItems.isEmpty()) {
null
} else {
LibrarySection(
type = tab.key,
displayTitle = tab.title,
items = listItems,
)
}
}
val sourceMode = effectiveLibrarySourceMode()
activeLibraryProvider(sourceMode)?.let { provider ->
val providerSnapshot = provider.snapshot()
val newUiState = LibraryUiState(
sourceMode = LibrarySourceMode.TRAKT,
items = traktState.allItems,
sections = sections,
isLoaded = traktState.hasLoaded,
isLoading = traktState.isLoading,
errorMessage = traktState.errorMessage,
sourceMode = sourceMode,
items = providerSnapshot.items,
sections = providerSnapshot.sections,
isLoaded = providerSnapshot.hasLoaded,
isLoading = providerSnapshot.isLoading,
errorMessage = providerSnapshot.errorMessage,
)
localState.runIfTokenCurrent(localSnapshot.token) {
_uiState.value = newUiState
@ -600,52 +639,81 @@ object LibraryRepository {
}
}
private fun refreshTraktLibraryAsync() {
private fun refreshLibraryProviderAsync(provider: TrackingLibraryProvider) {
syncScope.launch {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to refresh Trakt library" } }
refreshLibraryProvider(
provider = provider,
reason = "background refresh",
intent = TrackingRefreshIntent.AUTOMATIC,
)
publish()
}
}
private suspend fun refreshLibraryProvider(
provider: TrackingLibraryProvider,
reason: String,
intent: TrackingRefreshIntent,
) {
log.i {
"Tracking library refresh request provider=${provider.providerId.storageId} " +
"reason=$reason intent=$intent"
}
try {
provider.refresh(intent)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) {
"Failed to refresh ${provider.providerId.storageId} library during $reason"
}
}
}
private fun selectedLibrarySourceMode(): LibrarySourceMode {
TraktSettingsRepository.ensureLoaded()
return TraktSettingsRepository.uiState.value.librarySourceMode
TrackingSettingsRepository.ensureLoaded()
return TrackingSettingsRepository.uiState.value.librarySourceMode
}
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
resolveEffectiveLibrarySourceMode(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = selectedLibrarySourceMode(),
requestedSource = selectedLibrarySourceMode(),
isProviderAuthenticated = { providerId ->
TrackingProviderRegistry.libraryProvider(providerId) != null &&
TrackingProviderRegistry.isAuthenticated(providerId)
},
)
private fun isTraktLibrarySourceActive(): Boolean =
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
private fun activeLibraryProvider(
sourceMode: LibrarySourceMode = effectiveLibrarySourceMode(),
): TrackingLibraryProvider? =
sourceMode.providerId?.let(TrackingProviderRegistry::libraryProvider)
}
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library"
private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other"
internal fun localLibraryListTab(): TraktListTab =
TraktListTab(
internal fun localLibraryListTab(): TrackingLibraryTab =
TrackingLibraryTab(
key = LOCAL_LIBRARY_LIST_KEY,
title = localizedStringOrDefault(
resource = Res.string.library_local_tab_title,
fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE,
),
type = TraktListType.WATCHLIST,
providerId = null,
kind = TrackingLibraryTabKind.WATCHLIST,
)
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
listOf(localLibraryListTab()) + traktTabs
internal fun libraryTabsWithLocal(providerTabs: List<TrackingLibraryTab>): List<TrackingLibraryTab> =
listOf(localLibraryListTab()) + providerTabs
internal fun libraryMembershipWithLocal(
inLocal: Boolean,
traktMembership: Map<String, Boolean> = emptyMap(),
providerMembership: Map<String, Boolean> = emptyMap(),
): Map<String, Boolean> =
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
putAll(traktMembership)
putAll(providerMembership)
}
internal fun libraryMembershipWithRemovedList(

View file

@ -27,7 +27,7 @@ import nuvio.composeapp.generated.resources.library_sort_added_asc
import nuvio.composeapp.generated.resources.library_sort_added_desc
import nuvio.composeapp.generated.resources.library_sort_title_asc
import nuvio.composeapp.generated.resources.library_sort_title_desc
import nuvio.composeapp.generated.resources.library_sort_trakt_order
import nuvio.composeapp.generated.resources.library_sort_provider_order
import org.jetbrains.compose.resources.stringResource
@Composable
@ -48,7 +48,7 @@ internal fun LibrarySavedControls(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) {
if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode.isRemoteTrackingSource) {
val selectedSection = verticalProjection.availableSections
.firstOrNull { section -> section.type == verticalProjection.selectedSectionKey }
NuvioDropdownChip(
@ -158,7 +158,7 @@ internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier =
@Composable
private fun librarySortOptionLabel(option: LibrarySortOption): String =
when (option) {
LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order)
LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_provider_order)
LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc)
LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc)
LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc)

View file

@ -74,6 +74,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.ScopedDisintegrationTracker
import com.nuvio.app.core.ui.nuvioConsumePointerEvents
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
@ -86,6 +87,7 @@ import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.home.components.posterGridColumnCountForWidth
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.Flow
@ -139,7 +141,7 @@ fun LibraryScreen(
var selectedLibraryType by rememberSaveable { mutableStateOf<String?>(null) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
val isRemoteSource = uiState.sourceMode != LibrarySourceMode.LOCAL
val effectiveSortOption = effectiveLibrarySortOption(
selected = displaySettings.sortOption,
sourceMode = uiState.sourceMode,
@ -169,11 +171,14 @@ fun LibraryScreen(
val retryLibraryLoad: () -> Unit = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
LibraryRepository.pullFromServer(
profileId = ProfileRepository.activeProfileId,
refreshIntent = TrackingRefreshIntent.USER_INITIATED,
)
}
}
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
LaunchedEffect(networkStatusUiState.condition, isRemoteSource) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
@ -184,7 +189,7 @@ fun LibraryScreen(
NetworkCondition.Online -> {
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
if (isTraktSource) {
if (isRemoteSource) {
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
@ -217,7 +222,11 @@ fun LibraryScreen(
uiState.isLoaded &&
sortedSections.isNotEmpty()
) {
disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT)
disintegration.sync(
sourceMode = uiState.sourceMode,
sections = sortedSections,
previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT,
)
} else {
disintegration.reset()
emptyList()
@ -245,10 +254,12 @@ fun LibraryScreen(
NuvioScreenHeader(
title = if (sourceMode == LibraryViewMode.Cloud) {
stringResource(Res.string.library_title)
} else if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
when (uiState.sourceMode) {
LibrarySourceMode.LOCAL -> stringResource(Res.string.library_title)
LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_title)
LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_title)
}
},
modifier = Modifier.padding(horizontal = 16.dp),
actions = {
@ -355,10 +366,10 @@ fun LibraryScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_load_failed)
} else {
stringResource(Res.string.library_load_failed)
title = when (uiState.sourceMode) {
LibrarySourceMode.LOCAL -> stringResource(Res.string.library_load_failed)
LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_load_failed)
LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_load_failed)
},
message = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(Res.string.action_retry),
@ -370,7 +381,7 @@ fun LibraryScreen(
uiState.sections.isEmpty() -> {
item {
if (networkStatusUiState.isOfflineLike && isTraktSource) {
if (networkStatusUiState.isOfflineLike && isRemoteSource) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
@ -379,15 +390,15 @@ fun LibraryScreen(
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_title)
} else {
stringResource(Res.string.library_empty_title)
title = when (uiState.sourceMode) {
LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_title)
LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_title)
LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_title)
},
message = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_message)
} else {
stringResource(Res.string.library_empty_message)
message = when (uiState.sourceMode) {
LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_message)
LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_message)
LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_message)
},
)
}
@ -1243,41 +1254,34 @@ private fun libraryGlobalKey(sectionType: String, item: LibraryItem): String =
"$sectionType|${item.type}|${item.id}"
private class LibraryDisintegrationHolder {
private val exiting = LinkedHashMap<String, LibraryExitingEntry>()
private var previous = LinkedHashMap<String, LibraryExitingEntry>()
private var invalidations by mutableStateOf(0)
private val tracker = ScopedDisintegrationTracker<LibrarySourceMode, String, LibraryExitingEntry> { entry ->
libraryGlobalKey(entry.sectionType, entry.item)
}
fun onExited(globalKey: String) {
if (exiting.remove(globalKey) != null) invalidations++
tracker.onDisintegrated(globalKey)
}
fun reset() {
exiting.clear()
previous = LinkedHashMap()
tracker.reset()
}
fun sync(sections: List<LibrarySection>, previewLimit: Int): List<LibraryDisplaySection> {
@Suppress("UNUSED_EXPRESSION")
invalidations
val current = LinkedHashMap<String, LibraryExitingEntry>()
fun sync(
sourceMode: LibrarySourceMode,
sections: List<LibrarySection>,
previewLimit: Int,
): List<LibraryDisplaySection> {
val current = ArrayList<LibraryExitingEntry>()
sections.forEach { section ->
section.items.take(previewLimit).forEachIndexed { index, item ->
val key = libraryGlobalKey(section.type, item)
current[key] = LibraryExitingEntry(item, section.type, section.displayTitle, index)
current += LibraryExitingEntry(item, section.type, section.displayTitle, index)
}
}
for ((key, info) in previous) {
if (key !in current && key !in exiting) {
exiting[key] = info
}
}
for (key in current.keys) {
exiting.remove(key)
}
previous = current
val exitingBySection = exiting.values.groupBy { it.sectionType }
val exitingBySection = tracker.sync(sourceMode, current)
.asSequence()
.filter { entry -> entry.exiting }
.map { entry -> entry.item }
.groupBy { entry -> entry.sectionType }
val seenTypes = HashSet<String>(sections.size)
val result = ArrayList<LibraryDisplaySection>(sections.size + 1)

View file

@ -0,0 +1,36 @@
package com.nuvio.app.features.library
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.tracking_list_status_rewritten
import org.jetbrains.compose.resources.getString
internal suspend fun showTrackingMembershipRewriteFeedback(result: TrackingMembershipApplyResult) {
val rewrite = result.rewrites.firstOrNull() ?: return
val providerName = TrackingProviderRegistry.authProvider(rewrite.providerId)
?.descriptor
?.displayName
?: rewrite.providerId.storageId.replaceFirstChar { char -> char.titlecase() }
val tabs = TrackingProviderRegistry.libraryProvider(rewrite.providerId)?.snapshot()?.tabs.orEmpty()
val requestedTitle = tabs.statusTitle(rewrite.requestedListKey, providerName)
val resolvedTitle = tabs.statusTitle(rewrite.resolvedListKey, providerName)
NuvioToastController.show(
getString(
Res.string.tracking_list_status_rewritten,
providerName,
resolvedTitle,
requestedTitle,
),
)
}
private fun List<TrackingLibraryTab>.statusTitle(
key: String,
providerName: String,
): String = firstOrNull { tab -> tab.key == key }
?.title
?.removePrefix("$providerName ")
?: key.substringAfterLast(':')

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.library
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.setValue
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.features.tracking.TrackingMembershipApplyResult
import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation
import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_remove_anyway
import nuvio.composeapp.generated.resources.tracking_remove_confirmation_message
import nuvio.composeapp.generated.resources.tracking_remove_confirmation_title
import nuvio.composeapp.generated.resources.tracking_removal_impact_history
import nuvio.composeapp.generated.resources.tracking_removal_impact_history_and_rating
import nuvio.composeapp.generated.resources.tracking_removal_impact_rating
import org.jetbrains.compose.resources.stringResource
class PendingTrackingMembershipRemoval(
val itemTitle: String,
val confirmations: List<TrackingMembershipRemovalConfirmation>,
val retry: suspend (Set<TrackingProviderId>) -> TrackingMembershipApplyResult,
val onApplied: suspend (TrackingMembershipApplyResult) -> Unit,
val onFailure: suspend (Throwable) -> Unit,
)
suspend fun executeTrackingMembershipOperation(
operation: suspend () -> TrackingMembershipApplyResult,
onSuccess: suspend (TrackingMembershipApplyResult) -> Unit,
onFailure: suspend (Throwable) -> Unit,
) {
try {
onSuccess(operation())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onFailure(error)
}
}
@Composable
fun TrackingMembershipRemovalConfirmationHost(
pending: PendingTrackingMembershipRemoval?,
onPendingChange: (PendingTrackingMembershipRemoval?) -> Unit,
) {
var isBusy by remember(pending) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val confirmations = pending?.confirmations.orEmpty()
val providerNames = confirmations
.map { confirmation ->
TrackingProviderRegistry.authProvider(confirmation.providerId)
?.descriptor
?.displayName
?: confirmation.providerId.storageId.replaceFirstChar(Char::uppercase)
}
.distinct()
.joinToString()
val impacts = confirmations.flatMapTo(linkedSetOf()) { confirmation -> confirmation.impacts }
val impactLabel = when (impacts) {
setOf(TrackingMembershipRemovalImpact.WATCHED_HISTORY) ->
stringResource(Res.string.tracking_removal_impact_history)
setOf(TrackingMembershipRemovalImpact.RATING) ->
stringResource(Res.string.tracking_removal_impact_rating)
else -> stringResource(Res.string.tracking_removal_impact_history_and_rating)
}
NuvioStatusModal(
title = stringResource(Res.string.tracking_remove_confirmation_title, providerNames),
message = stringResource(
Res.string.tracking_remove_confirmation_message,
pending?.itemTitle.orEmpty(),
providerNames,
impactLabel,
),
isVisible = pending != null,
isBusy = isBusy,
confirmText = stringResource(Res.string.action_remove_anyway),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
val request = pending ?: return@NuvioStatusModal
if (isBusy) return@NuvioStatusModal
isBusy = true
scope.launch {
try {
val confirmedProviders = request.confirmations.mapTo(linkedSetOf()) { confirmation ->
confirmation.providerId
}
val result = request.retry(confirmedProviders)
if (result.requiresRemovalConfirmation) {
onPendingChange(
PendingTrackingMembershipRemoval(
itemTitle = request.itemTitle,
confirmations = result.requiredRemovalConfirmations,
retry = request.retry,
onApplied = request.onApplied,
onFailure = request.onFailure,
),
)
} else {
onPendingChange(null)
request.onApplied(result)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onPendingChange(null)
request.onFailure(error)
} finally {
isBusy = false
}
}
},
onDismiss = {
if (!isBusy) onPendingChange(null)
},
)
}

View file

@ -8,7 +8,7 @@ import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibraryUiState
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.core.time.EpisodeReleaseDatePlatform
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -206,7 +206,7 @@ object EpisodeReleaseNotificationsRepository {
}
val request = EpisodeReleaseNotificationRequest(
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}",
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${EpisodeReleaseDatePlatform.nowEpochMs()}",
notificationTitle = target.name,
notificationBody = getString(Res.string.notifications_test_preview_body),
releaseDateIso = CurrentDateProvider.todayIsoDate(),

View file

@ -15,6 +15,7 @@ import com.nuvio.app.features.streams.BingeGroupCacheRepository
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
@ -68,7 +69,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
pendingSeekScrobbleRestart = false
seekProgressSyncJob?.cancel()
seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
@ -333,22 +334,26 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() {
playbackSnapshot.durationMs,
) {
if (playbackSnapshot.isEnded) {
flushWatchProgress()
flushWatchProgress(TrackingScrobbleAction.STOP)
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
pendingSeekScrobbleRestart = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
pendingScrobbleStartAfterSeek = false
flushWatchProgress()
pendingSeekScrobbleRestart = false
flushWatchProgress(TrackingScrobbleAction.PAUSE)
}
if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
if (playbackSnapshot.isPlaying && pendingSeekScrobbleRestart) {
pendingSeekScrobbleRestart = false
if (hasRequestedScrobbleStartForCurrentItem) {
emitTrackingSeekScrobbleStart()
} else {
emitTrackingScrobbleStart()
}
} else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
emitTrackingScrobbleStart()
}
if (!playbackSnapshot.isLoading) {

View file

@ -1,7 +1,11 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.buildTrackingMediaReference
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -55,7 +59,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
(activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f)
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
pendingSeekScrobbleRestart = false
autoFetchedAddonSubtitlesForKey = null
trackPreferenceRestoreApplied = false
preferredAudioSelectionApplied = false
@ -67,9 +71,9 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
lastResetVideoIdentity = videoIdentity
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration = 0L
pendingScrobbleStartAfterSeek = false
pendingSeekScrobbleRestart = false
hasSentCompletionScrobbleForCurrentItem = false
currentTraktScrobbleItem = null
currentTrackingMedia = null
}
}
@ -81,7 +85,7 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
.coerceIn(0f, 100f)
}
internal data class TraktScrobbleItemInputs(
internal data class TrackingScrobbleItemInputs(
val contentType: String,
val parentMetaId: String,
val videoId: String?,
@ -91,7 +95,7 @@ internal data class TraktScrobbleItemInputs(
val episodeTitle: String?,
)
internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs(
internal fun PlayerScreenRuntime.snapshotTrackingScrobbleItemInputs() = TrackingScrobbleItemInputs(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
@ -101,8 +105,8 @@ internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobb
episodeTitle = activeEpisodeTitle,
)
private suspend fun TraktScrobbleItemInputs.buildItem() =
TraktScrobbleRepository.buildItem(
private fun TrackingScrobbleItemInputs.buildMedia(): TrackingMediaReference =
buildTrackingMediaReference(
contentType = contentType,
parentMetaId = parentMetaId,
videoId = videoId,
@ -112,63 +116,115 @@ private suspend fun TraktScrobbleItemInputs.buildItem() =
episodeTitle = episodeTitle,
)
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
snapshotTraktScrobbleItemInputs().buildItem()
internal fun PlayerScreenRuntime.currentTrackingMedia(): TrackingMediaReference =
snapshotTrackingScrobbleItemInputs().buildMedia()
internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
internal fun PlayerScreenRuntime.emitTrackingScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
val requestGeneration = scrobbleStartRequestGeneration + 1L
scrobbleStartRequestGeneration = requestGeneration
scope.launch {
val item = currentTraktScrobbleItem()
if (item == null) {
val media = currentTrackingMedia()
if (!media.hasResolvableIdentity) {
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
return@launch
}
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
currentTrackingMedia = media
TrackingScrobbleCoordinator.scrobble(
profileId = profileId,
item = item,
progressPercent = currentPlaybackProgressPercent(),
action = TrackingScrobbleAction.START,
event = TrackingScrobbleEvent(
media = media,
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
}
}
internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) {
internal fun PlayerScreenRuntime.emitTrackingScrobblePause(progressPercent: Float? = null) {
emitTrackingScrobbleTerminal(
action = TrackingScrobbleAction.PAUSE,
progressPercent = progressPercent,
)
}
internal fun PlayerScreenRuntime.emitTrackingScrobbleStop(progressPercent: Float? = null) {
emitTrackingScrobbleTerminal(
action = TrackingScrobbleAction.STOP,
progressPercent = progressPercent,
)
}
private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal(
action: TrackingScrobbleAction,
progressPercent: Float?,
) {
val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
val itemSnapshot = currentTraktScrobbleItem
val inputsSnapshot = snapshotTraktScrobbleItemInputs()
val mediaSnapshot = currentTrackingMedia
val inputsSnapshot = snapshotTrackingScrobbleItemInputs()
scope.launch(NonCancellable) {
val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
val media = mediaSnapshot ?: inputsSnapshot.buildMedia()
if (!media.hasResolvableIdentity) return@launch
TrackingScrobbleCoordinator.scrobble(
profileId = profileId,
item = item,
progressPercent = percent,
action = action,
event = TrackingScrobbleEvent(media = media, progressPercent = percent.toDouble()),
)
}
currentTraktScrobbleItem = null
currentTrackingMedia = null
hasRequestedScrobbleStartForCurrentItem = false
pendingSeekScrobbleRestart = false
scrobbleStartRequestGeneration += 1L
}
internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() {
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
if (!shouldSendStopScrobble(hasRequestedScrobbleStartForCurrentItem, progressPercent)) {
return
}
if (progressPercent < 80f) {
emitTrackingScrobbleStop(progressPercent)
return
}
if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) {
hasSentCompletionScrobbleForCurrentItem = true
emitTraktScrobbleStop(progressPercent)
emitTrackingScrobbleStop(progressPercent)
}
}
internal fun shouldSendStopScrobble(
hasActiveScrobble: Boolean,
progressPercent: Float,
): Boolean = hasActiveScrobble || progressPercent >= 80f
internal fun shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble: Boolean,
progressPercent: Float,
): Boolean = hasActiveScrobble && progressPercent >= 1f && progressPercent < 80f
internal fun PlayerScreenRuntime.emitTrackingSeekScrobbleStart() {
val mediaSnapshot = currentTrackingMedia
val inputsSnapshot = snapshotTrackingScrobbleItemInputs()
scope.launch {
val media = mediaSnapshot ?: inputsSnapshot.buildMedia()
if (!media.hasResolvableIdentity) return@launch
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.START,
event = TrackingScrobbleEvent(
media = media,
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
}
}
@ -192,8 +248,14 @@ internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? {
)
}
internal fun PlayerScreenRuntime.flushWatchProgress() {
emitStopScrobbleForCurrentProgress()
internal fun PlayerScreenRuntime.flushWatchProgress(
scrobbleAction: TrackingScrobbleAction = TrackingScrobbleAction.STOP,
) {
when (scrobbleAction) {
TrackingScrobbleAction.PAUSE -> emitTrackingScrobblePause()
TrackingScrobbleAction.STOP -> emitStopScrobbleForCurrentProgress()
TrackingScrobbleAction.START -> Unit
}
WatchProgressRepository.flushPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
@ -211,14 +273,39 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
)
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (shouldRestartScrobbleNow) {
pendingScrobbleStartAfterSeek = true
if (
!shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble = hasRequestedScrobbleStartForCurrentItem,
progressPercent = progressPercent,
)
) {
return@launch
}
val media = currentTrackingMedia ?: currentTrackingMedia()
if (!media.hasResolvableIdentity) return@launch
val stopEvent = TrackingScrobbleEvent(
media = media,
progressPercent = progressPercent.toDouble(),
)
scope.launch {
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.STOP,
event = stopEvent,
)
if (!shouldRestartScrobbleAfterSeek || !shouldPlay || playbackSnapshot.isEnded) return@launch
if (playbackSnapshot.isPlaying) {
pendingSeekScrobbleRestart = false
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.START,
event = stopEvent.copy(
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
} else {
pendingSeekScrobbleRestart = true
}
}
}

View file

@ -16,7 +16,7 @@ import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.SkipInterval
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.trakt.TraktScrobbleItem
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.watched.WatchedUiState
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import kotlinx.coroutines.CoroutineScope
@ -149,9 +149,9 @@ internal class PlayerScreenRuntime(
var previousIsPlaying by mutableStateOf(false)
var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false)
var scrobbleStartRequestGeneration by mutableStateOf(0L)
var pendingScrobbleStartAfterSeek by mutableStateOf(false)
var pendingSeekScrobbleRestart by mutableStateOf(false)
var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false)
var currentTraktScrobbleItem by mutableStateOf<TraktScrobbleItem?>(null)
var currentTrackingMedia by mutableStateOf<TrackingMediaReference?>(null)
var showSourcesPanel by mutableStateOf(false)
var showEpisodesPanel by mutableStateOf(false)

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
@ -25,8 +26,8 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -154,8 +155,9 @@ object ProfileRepository {
)
persist()
WatchedRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged(profileIndex)
TrackingSettingsRepository.onProfileChanged()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex)
LibraryDisplaySettingsRepository.onProfileChanged()
WatchProgressRepository.onProfileChanged(profileIndex)

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.profiles
internal fun routeProfileSelection(
profile: NuvioProfile,
isEditMode: Boolean,
onEditProfile: (NuvioProfile) -> Unit,
onPinRequired: (NuvioProfile) -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
) {
when {
isEditMode -> onEditProfile(profile)
profile.pinEnabled -> onPinRequired(profile)
else -> onProfileSelected(profile)
}
}

View file

@ -80,6 +80,15 @@ fun ProfileSelectionScreen(
val titleAlpha = remember { Animatable(0f) }
val titleOffset = remember { Animatable(20f) }
val manageAlpha = remember { Animatable(0f) }
val onProfileClick: (NuvioProfile) -> Unit = { profile ->
routeProfileSelection(
profile = profile,
isEditMode = isEditMode,
onEditProfile = onEditProfile,
onPinRequired = { pinDialogProfile = it },
onProfileSelected = onProfileSelected,
)
}
LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars()
@ -168,14 +177,7 @@ fun ProfileSelectionScreen(
isEditMode = isEditMode,
animDelay = currentIndex * 80,
onClick = {
if (isEditMode) {
onEditProfile(profile)
} else if (profile.pinEnabled) {
pinDialogProfile = profile
} else {
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
}
onProfileClick(profile)
},
)
} else {
@ -209,14 +211,7 @@ fun ProfileSelectionScreen(
isEditMode = isEditMode,
animDelay = currentIndex * 80,
onClick = {
if (isEditMode) {
onEditProfile(profile)
} else if (profile.pinEnabled) {
pinDialogProfile = profile
} else {
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
}
onProfileClick(profile)
},
)
} else {
@ -279,7 +274,6 @@ fun ProfileSelectionScreen(
onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) },
onVerified = {
pinDialogProfile = null
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
},
onDismiss = { pinDialogProfile = null },

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.painter.Painter
internal enum class IntegrationLogo {
Tmdb,
Trakt,
Simkl,
MdbList,
IntroDb,
}

View file

@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
private const val TmdbUrl = "https://www.themoviedb.org"
private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/"
private const val TraktUrl = "https://trakt.tv"
private const val SimklUrl = "https://simkl.com"
private const val PremiumizeUrl = "https://www.premiumize.me"
private const val TorboxUrl = "https://torbox.app"
private const val MdbListUrl = "https://mdblist.com"
@ -330,6 +331,12 @@ private fun attributionItems(): List<AttributionItem> = listOf(
logo = IntegrationLogo.Trakt,
link = TraktUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_simkl_title,
bodyRes = Res.string.settings_licenses_attributions_simkl_body,
logo = IntegrationLogo.Simkl,
link = SimklUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_premiumize_title,
bodyRes = Res.string.settings_licenses_attributions_premiumize_body,

View file

@ -230,11 +230,12 @@ internal fun SettingsSection(
@Composable
internal fun SettingsNavigationRow(
title: String,
description: String,
description: String?,
icon: ImageVector? = null,
iconPainter: Painter? = null,
enabled: Boolean = true,
isTablet: Boolean,
trailingContent: (@Composable RowScope.() -> Unit)? = null,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -294,15 +295,18 @@ internal fun SettingsNavigationRow(
color = tokens.colors.textPrimary,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
modifier = Modifier.alpha(0.92f),
)
if (!description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
modifier = Modifier.alpha(0.92f),
)
}
}
}
trailingContent?.invoke(this)
}
}

View file

@ -31,6 +31,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_streams
import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.compose_settings_page_tracking
import nuvio.composeapp.generated.resources.settings_account
import org.jetbrains.compose.resources.StringResource
@ -150,7 +151,8 @@ internal enum class SettingsPage(
parentPage = Integrations,
),
TraktAuthentication(
titleRes = Res.string.compose_settings_page_trakt,
// Keep the enum name for saved navigation-state compatibility.
titleRes = Res.string.compose_settings_page_tracking,
category = SettingsCategory.Account,
parentPage = Root,
),

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CloudDownload
@ -45,13 +46,13 @@ import nuvio.composeapp.generated.resources.compose_settings_root_integrations_d
import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description
import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description
import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title
import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description
import nuvio.composeapp.generated.resources.compose_settings_root_tracking_description
import nuvio.composeapp.generated.resources.compose_settings_root_about_section
import nuvio.composeapp.generated.resources.compose_settings_root_account_section
import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description
import nuvio.composeapp.generated.resources.compose_settings_root_advanced_section
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.compose_settings_page_tracking
import nuvio.composeapp.generated.resources.settings_playback_subtitle
import nuvio.composeapp.generated.resources.updates_debug_test_description
import nuvio.composeapp.generated.resources.updates_debug_test_title
@ -67,7 +68,7 @@ internal fun LazyListScope.settingsRootContent(
onNotificationsClick: () -> Unit,
onContentDiscoveryClick: () -> Unit,
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onTrackingClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
@ -107,11 +108,11 @@ internal fun LazyListScope.settingsRootContent(
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_trakt),
description = stringResource(Res.string.compose_settings_root_trakt_description),
iconPainter = integrationLogoPainter(IntegrationLogo.Trakt),
title = stringResource(Res.string.compose_settings_page_tracking),
description = stringResource(Res.string.compose_settings_root_tracking_description),
icon = Icons.Default.Sync,
isTablet = isTablet,
onClick = onTraktClick,
onClick = onTrackingClick,
)
}
}

View file

@ -71,11 +71,13 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.AndroidLibmpvVideoOutput
import com.nuvio.app.features.player.AndroidPlaybackEngine
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklAuthUiState
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.TrackingSettingsUiState
import com.nuvio.app.features.tmdb.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -172,13 +174,17 @@ fun SettingsScreen(
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val simklAuthUiState by remember {
SimklAuthRepository.ensureLoaded()
SimklAuthRepository.uiState
}.collectAsStateWithLifecycle()
val traktCommentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
}.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
val trackingSettingsUiState by remember {
TrackingSettingsRepository.ensureLoaded()
TrackingSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val addonsUiState by remember {
AddonRepository.initialize()
@ -400,8 +406,9 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
simklAuthUiState = simklAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
trackingSettingsUiState = trackingSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
@ -460,8 +467,9 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
simklAuthUiState = simklAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
trackingSettingsUiState = trackingSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
@ -530,8 +538,9 @@ private fun MobileSettingsScreen(
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
simklAuthUiState: SimklAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
trackingSettingsUiState: TrackingSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenShowCatalogType: Boolean,
homescreenHideUnreleasedContent: Boolean,
@ -661,7 +670,7 @@ private fun MobileSettingsScreen(
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onTrackingClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
@ -789,10 +798,11 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
SettingsPage.TraktAuthentication -> trackingSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
traktUiState = traktAuthUiState,
simklUiState = simklAuthUiState,
settingsUiState = trackingSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
@ -886,8 +896,9 @@ private fun TabletSettingsScreen(
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
simklAuthUiState: SimklAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
trackingSettingsUiState: TrackingSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenShowCatalogType: Boolean,
homescreenHideUnreleasedContent: Boolean,
@ -1069,7 +1080,7 @@ private fun TabletSettingsScreen(
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onTrackingClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
@ -1201,10 +1212,11 @@ private fun TabletSettingsScreen(
isTablet = true,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
SettingsPage.TraktAuthentication -> trackingSettingsContent(
isTablet = true,
uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
traktUiState = traktAuthUiState,
simklUiState = simklAuthUiState,
settingsUiState = trackingSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.CloudDownload
@ -92,7 +93,7 @@ internal fun settingsSearchEntries(
val advancedCategory = stringResource(SettingsCategory.Advanced.labelRes)
val accountPage = stringResource(Res.string.compose_settings_page_account)
val traktPage = stringResource(Res.string.compose_settings_page_trakt)
val trackingPage = stringResource(Res.string.compose_settings_page_tracking)
val layoutPage = stringResource(Res.string.compose_settings_page_appearance)
val advancedPage = stringResource(Res.string.compose_settings_page_advanced)
val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery)
@ -200,11 +201,11 @@ internal fun settingsSearchEntries(
)
addPage(
page = SettingsPage.TraktAuthentication,
key = "trakt",
title = traktPage,
description = stringResource(Res.string.compose_settings_root_trakt_description),
key = "tracking",
title = trackingPage,
description = stringResource(Res.string.compose_settings_root_tracking_description),
category = accountCategory,
icon = Icons.Rounded.Link,
icon = Icons.Default.Sync,
)
addPage(
page = SettingsPage.Appearance,
@ -286,6 +287,7 @@ internal fun settingsSearchEntries(
PlaybackSearchRow("nuvio-license", stringResource(Res.string.settings_licenses_attributions_nuvio_title), stringResource(Res.string.settings_licenses_attributions_nuvio_license)),
PlaybackSearchRow("tmdb-attribution", stringResource(Res.string.settings_licenses_attributions_tmdb_title), stringResource(Res.string.settings_licenses_attributions_tmdb_body)),
PlaybackSearchRow("trakt-attribution", stringResource(Res.string.settings_licenses_attributions_trakt_title), stringResource(Res.string.settings_licenses_attributions_trakt_body)),
PlaybackSearchRow("simkl-attribution", stringResource(Res.string.settings_licenses_attributions_simkl_title), stringResource(Res.string.settings_licenses_attributions_simkl_body)),
PlaybackSearchRow("premiumize-attribution", stringResource(Res.string.settings_licenses_attributions_premiumize_title), stringResource(Res.string.settings_licenses_attributions_premiumize_body)),
PlaybackSearchRow("torbox-attribution", stringResource(Res.string.settings_licenses_attributions_torbox_title), stringResource(Res.string.settings_licenses_attributions_torbox_body)),
PlaybackSearchRow("mdblist-attribution", stringResource(Res.string.settings_licenses_attributions_mdblist_title), stringResource(Res.string.settings_licenses_attributions_mdblist_body)),
@ -873,10 +875,20 @@ internal fun settingsSearchEntries(
addRow(
page = SettingsPage.TraktAuthentication,
key = "trakt-authentication",
title = stringResource(Res.string.settings_trakt_authentication),
title = stringResource(Res.string.trakt_library_source_trakt),
description = stringResource(Res.string.settings_trakt_intro_description),
pageLabel = traktPage,
section = stringResource(Res.string.settings_trakt_authentication),
pageLabel = trackingPage,
section = stringResource(Res.string.settings_tracking_services),
category = accountCategory,
icon = Icons.Rounded.Link,
)
addRow(
page = SettingsPage.TraktAuthentication,
key = "simkl-authentication",
title = stringResource(Res.string.tracking_source_simkl),
description = stringResource(Res.string.settings_simkl_sign_in_description),
pageLabel = trackingPage,
section = stringResource(Res.string.settings_tracking_services),
category = accountCategory,
icon = Icons.Rounded.Link,
)
@ -892,8 +904,8 @@ internal fun settingsSearchEntries(
key = row.key,
title = row.title,
description = row.description,
pageLabel = traktPage,
section = stringResource(Res.string.settings_trakt_features),
pageLabel = trackingPage,
section = stringResource(Res.string.settings_tracking_features),
category = accountCategory,
icon = Icons.Rounded.Link,
)

View file

@ -0,0 +1,112 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.simkl.SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_close
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_activity
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_description
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_docs
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_library_statuses
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_manual
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_title
import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser
import org.jetbrains.compose.resources.stringResource
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun SimklSyncInfoDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
var browserError by rememberSaveable { mutableStateOf(false) }
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_simkl_sync_info_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(
Res.string.settings_simkl_sync_info_description,
SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(Res.string.settings_simkl_sync_info_activity),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(Res.string.settings_simkl_sync_info_manual),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(Res.string.settings_simkl_sync_info_library_statuses),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (browserError) {
Text(
text = stringResource(Res.string.settings_trakt_failed_open_browser),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
browserError = false
runCatching { uriHandler.openUri(SIMKL_SYNC_GUIDE_URL) }
.onFailure { browserError = true }
},
) {
Text(stringResource(Res.string.settings_simkl_sync_info_docs))
}
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_close))
}
}
}
}
}
}
private const val SIMKL_SYNC_GUIDE_URL = "https://api.simkl.org/guides/sync"

View file

@ -0,0 +1,255 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvio
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_close
import nuvio.composeapp.generated.resources.cd_selected
import org.jetbrains.compose.resources.stringResource
internal data class TrackingPickerOption<T>(
val value: T,
val title: String,
val description: String? = null,
val enabled: Boolean = true,
val unavailableReason: String? = null,
)
@Composable
internal fun <T> TrackingAdaptivePicker(
isTablet: Boolean,
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
if (isTablet) {
TrackingPickerDialog(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
onSelected = onSelected,
onDismiss = onDismiss,
)
} else {
TrackingPickerBottomSheet(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
onSelected = onSelected,
onDismiss = onDismiss,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> TrackingPickerBottomSheet(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
fun dismiss() {
scope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
}
NuvioModalBottomSheet(
onDismissRequest = ::dismiss,
sheetState = sheetState,
) {
TrackingPickerContent(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
isTablet = false,
onSelected = { value ->
onSelected(value)
dismiss()
},
onDismiss = ::dismiss,
modifier = Modifier.navigationBarsPadding(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> TrackingPickerDialog(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = tokens.components.dialogMaxWidth),
shape = tokens.shapes.dialog,
color = tokens.colors.surfaceDialog,
) {
TrackingPickerContent(
title = title,
subtitle = subtitle,
selectedValue = selectedValue,
options = options,
isTablet = true,
onSelected = { value ->
onSelected(value)
onDismiss()
},
onDismiss = onDismiss,
)
}
}
}
@Composable
private fun <T> TrackingPickerContent(
title: String,
subtitle: String,
selectedValue: T,
options: List<TrackingPickerOption<T>>,
isTablet: Boolean,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val horizontalPadding = if (isTablet) tokens.spacing.dialogPadding else tokens.spacing.screenHorizontal
Column(
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = if (isTablet) tokens.spacing.dialogPadding else 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 520.dp)
.verticalScroll(rememberScrollState()),
) {
options.forEachIndexed { index, option ->
if (index > 0) {
NuvioBottomSheetDivider()
}
TrackingPickerOptionRow(
option = option,
selected = option.value == selectedValue,
isTablet = isTablet,
onClick = { onSelected(option.value) },
)
}
}
if (isTablet) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_close))
}
}
}
}
}
@Composable
private fun <T> TrackingPickerOptionRow(
option: TrackingPickerOption<T>,
selected: Boolean,
isTablet: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val description = listOfNotNull(
option.description?.takeIf(String::isNotBlank),
option.unavailableReason?.takeIf(String::isNotBlank),
).joinToString("\n").takeIf(String::isNotBlank)
SettingsNavigationRow(
title = option.title,
description = description,
enabled = option.enabled,
isTablet = isTablet,
trailingContent = {
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = stringResource(Res.string.cd_selected),
tint = tokens.colors.accent,
modifier = Modifier.size(24.dp),
)
}
},
onClick = onClick,
)
}

View file

@ -0,0 +1,784 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.simkl.SimklAuthError
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklAuthUiState
import com.nuvio.app.features.simkl.SimklBrandAsset
import com.nuvio.app.features.simkl.SimklConnectionMode
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.simkl.simklBrandPainter
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.traktBrandPainter
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired
import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked
import nuvio.composeapp.generated.resources.settings_simkl_connect
import nuvio.composeapp.generated.resources.settings_simkl_connected_as
import nuvio.composeapp.generated.resources.settings_simkl_connected_description
import nuvio.composeapp.generated.resources.settings_simkl_default_user
import nuvio.composeapp.generated.resources.settings_simkl_disconnect
import nuvio.composeapp.generated.resources.settings_simkl_disconnect_description
import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in
import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback
import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials
import nuvio.composeapp.generated.resources.settings_simkl_open_login
import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description
import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed
import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action
import nuvio.composeapp.generated.resources.settings_simkl_sync_now
import nuvio.composeapp.generated.resources.settings_simkl_visit
import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect
import nuvio.composeapp.generated.resources.settings_tracking_disconnect_description
import nuvio.composeapp.generated.resources.settings_tracking_disconnect_title
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_connect
import nuvio.composeapp.generated.resources.settings_trakt_connected_as
import nuvio.composeapp.generated.resources.settings_trakt_default_user
import nuvio.composeapp.generated.resources.settings_trakt_disconnect
import nuvio.composeapp.generated.resources.settings_trakt_disconnect_description
import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser
import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in
import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
import nuvio.composeapp.generated.resources.settings_trakt_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
import org.jetbrains.compose.resources.stringResource
internal enum class TrackingBrand(val displayName: String) {
NUVIO("Nuvio"),
TRAKT("Trakt"),
SIMKL("Simkl"),
TMDB("TMDB"),
}
internal enum class TrackingConnectionCardMode {
DISCONNECTED,
AWAITING_APPROVAL,
CONNECTED,
}
internal fun isTrackingBrandAvailable(
brand: TrackingBrand,
traktConnected: Boolean,
simklConnected: Boolean,
): Boolean = when (brand) {
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> true
TrackingBrand.TRAKT -> traktConnected
TrackingBrand.SIMKL -> simklConnected
}
internal fun TraktConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) {
TraktConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED
TraktConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL
TraktConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED
}
internal fun SimklConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) {
SimklConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED
SimklConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL
SimklConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED
}
@Composable
internal fun TrackingProviderCards(
isTablet: Boolean,
traktUiState: TraktAuthUiState,
simklUiState: SimklAuthUiState,
) {
val syncState by remember {
SimklSyncRepository.ensureLoaded()
SimklSyncRepository.state
}.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showSyncInfo by rememberSaveable { mutableStateOf(false) }
val onSimklSyncRequested: () -> Unit = {
scope.launch {
WatchProgressSourceCoordinator.refreshProviderAndActiveSource(
profileId = ProfileRepository.activeProfileId,
providerId = TrackingProviderId.SIMKL,
refreshProvider = {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
},
)
}
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val useTwoColumns = maxWidth >= 600.dp
if (useTwoColumns) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
TraktProviderCard(
uiState = traktUiState,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
SimklProviderCard(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = onSimklSyncRequested,
onInfoRequested = { showSyncInfo = true },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
) {
TraktProviderCard(
uiState = traktUiState,
modifier = Modifier.fillMaxWidth(),
)
SimklProviderCard(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = onSimklSyncRequested,
onInfoRequested = { showSyncInfo = true },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
if (showSyncInfo) {
SimklSyncInfoDialog(onDismiss = { showSyncInfo = false })
}
}
@Composable
private fun TraktProviderCard(
uiState: TraktAuthUiState,
modifier: Modifier,
) {
TrackingProviderCard(
brand = TrackingBrand.TRAKT,
mode = uiState.mode.toTrackingConnectionCardMode(),
credentialsConfigured = uiState.credentialsConfigured,
isLoading = uiState.isLoading,
connectedLabel = stringResource(
Res.string.settings_trakt_connected_as,
uiState.username ?: stringResource(Res.string.settings_trakt_default_user),
),
connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description),
signInDescription = stringResource(Res.string.settings_trakt_sign_in_description),
finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in),
approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect),
connectLabel = stringResource(Res.string.settings_trakt_connect),
openLoginLabel = stringResource(Res.string.settings_trakt_open_login),
disconnectLabel = stringResource(Res.string.settings_trakt_disconnect),
missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials),
statusMessage = uiState.statusMessage.takeUnless {
uiState.mode == TraktConnectionMode.CONNECTED
},
errorMessage = uiState.errorMessage,
onConnectRequested = TraktAuthRepository::onConnectRequested,
onResumeAuthorization = {
TraktAuthRepository.pendingAuthorizationUrl()
?: TraktAuthRepository.onConnectRequested()
},
onCancelAuthorization = TraktAuthRepository::onCancelAuthorization,
onDisconnect = TraktAuthRepository::onDisconnectRequested,
modifier = modifier,
)
}
@Composable
private fun SimklProviderCard(
uiState: SimklAuthUiState,
isSyncing: Boolean,
syncErrorMessage: String?,
onSyncRequested: () -> Unit,
onInfoRequested: () -> Unit,
modifier: Modifier,
) {
TrackingProviderCard(
brand = TrackingBrand.SIMKL,
mode = uiState.mode.toTrackingConnectionCardMode(),
credentialsConfigured = uiState.credentialsConfigured,
isLoading = uiState.isLoading,
connectedLabel = stringResource(
Res.string.settings_simkl_connected_as,
uiState.username ?: stringResource(Res.string.settings_simkl_default_user),
),
connectedDescription = stringResource(Res.string.settings_simkl_connected_description),
signInDescription = stringResource(Res.string.settings_simkl_sign_in_description),
finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in),
approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect),
connectLabel = stringResource(Res.string.settings_simkl_connect),
openLoginLabel = stringResource(Res.string.settings_simkl_open_login),
disconnectLabel = stringResource(Res.string.settings_simkl_disconnect),
syncLabel = stringResource(Res.string.settings_simkl_sync_now),
infoLabel = stringResource(Res.string.settings_simkl_sync_info_action),
isSyncing = isSyncing,
missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials),
errorMessage = simklErrorMessage(uiState.error) ?: syncErrorMessage,
websiteLabel = stringResource(Res.string.settings_simkl_visit),
websiteUrl = SIMKL_WEBSITE_URL,
onConnectRequested = SimklAuthRepository::onConnectRequested,
onResumeAuthorization = {
SimklAuthRepository.pendingAuthorizationUrl()
?: SimklAuthRepository.onConnectRequested()
},
onCancelAuthorization = SimklAuthRepository::onCancelAuthorization,
onSyncRequested = onSyncRequested,
onInfoRequested = onInfoRequested,
onDisconnect = SimklAuthRepository::onDisconnectRequested,
modifier = modifier,
)
}
@Composable
private fun TrackingProviderCard(
brand: TrackingBrand,
mode: TrackingConnectionCardMode,
credentialsConfigured: Boolean,
isLoading: Boolean,
connectedLabel: String,
connectedDescription: String,
signInDescription: String,
finishSignInLabel: String,
approvalDescription: String,
connectLabel: String,
openLoginLabel: String,
disconnectLabel: String,
missingCredentialsMessage: String,
modifier: Modifier = Modifier,
syncLabel: String? = null,
infoLabel: String? = null,
isSyncing: Boolean = false,
statusMessage: String? = null,
errorMessage: String? = null,
websiteLabel: String? = null,
websiteUrl: String? = null,
onConnectRequested: () -> String?,
onResumeAuthorization: () -> String?,
onCancelAuthorization: () -> Unit,
onSyncRequested: (() -> Unit)? = null,
onInfoRequested: (() -> Unit)? = null,
onDisconnect: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val uriHandler = LocalUriHandler.current
val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser)
var browserError by rememberSaveable { mutableStateOf(false) }
var showDisconnectDialog by rememberSaveable { mutableStateOf(false) }
fun openUrl(url: String?) {
if (url.isNullOrBlank()) return
browserError = false
runCatching { uriHandler.openUri(url) }
.onFailure { browserError = true }
}
Box(
modifier = modifier
.clip(tokens.shapes.card)
.background(brand.cardBrush(), tokens.shapes.card)
.border(
width = tokens.borders.hairline,
color = Color.White.copy(alpha = 0.2f),
shape = tokens.shapes.card,
),
) {
TrackingBrandGlyph(
brand = brand,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp)
.size(150.dp)
.alpha(0.08f),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(if (mode == TrackingConnectionCardMode.CONNECTED) 20.dp else 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
TrackingBrandWordmark(
brand = brand,
contentDescription = brand.displayName,
)
when (mode) {
TrackingConnectionCardMode.CONNECTED -> {
TrackingConnectedIdentity(
label = connectedLabel,
description = connectedDescription,
)
if (syncLabel != null && onSyncRequested != null) {
TrackingBrandPrimaryButton(
label = syncLabel,
loading = isSyncing,
enabled = !isLoading && !isSyncing,
onClick = onSyncRequested,
showSyncIcon = true,
)
}
}
TrackingConnectionCardMode.AWAITING_APPROVAL -> {
Text(
text = finishSignInLabel,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold,
)
Text(
text = approvalDescription,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.78f),
)
TrackingBrandPrimaryButton(
label = openLoginLabel,
loading = isLoading,
enabled = !isLoading,
onClick = { openUrl(onResumeAuthorization()) },
)
OutlinedButton(
onClick = onCancelAuthorization,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.44f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White,
disabledContentColor = Color.White.copy(alpha = 0.45f),
),
) {
Text(stringResource(Res.string.action_cancel))
}
}
TrackingConnectionCardMode.DISCONNECTED -> {
Text(
text = signInDescription,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.82f),
)
TrackingBrandPrimaryButton(
label = connectLabel,
loading = isLoading,
enabled = credentialsConfigured && !isLoading,
onClick = { openUrl(onConnectRequested()) },
)
if (!credentialsConfigured) {
TrackingBrandMessage(
text = missingCredentialsMessage,
isError = true,
)
}
}
}
statusMessage?.takeIf(String::isNotBlank)?.let { message ->
TrackingBrandMessage(text = message, isError = false)
}
errorMessage?.takeIf(String::isNotBlank)?.let { message ->
TrackingBrandMessage(text = message, isError = true)
}
if (browserError) {
TrackingBrandMessage(text = failedOpenBrowserMessage, isError = true)
}
val hasWebsiteAction = !websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank()
val hasDisconnectAction = mode == TrackingConnectionCardMode.CONNECTED
val hasInfoAction = mode == TrackingConnectionCardMode.CONNECTED &&
infoLabel != null && onInfoRequested != null
val footerActionCount = listOf(
hasWebsiteAction,
hasDisconnectAction,
hasInfoAction,
).count { it }
if (footerActionCount > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasWebsiteAction) {
TextButton(
onClick = { openUrl(websiteUrl) },
modifier = if (footerActionCount > 1) Modifier.weight(0.95f) else Modifier,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color.White),
) {
Text(
text = websiteLabel.orEmpty(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (hasDisconnectAction) {
TextButton(
onClick = { showDisconnectDialog = true },
modifier = if (footerActionCount > 1) Modifier.weight(1.05f) else Modifier,
enabled = !isLoading && !isSyncing,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Color.White.copy(alpha = 0.84f),
disabledContentColor = Color.White.copy(alpha = 0.38f),
),
) {
Text(
text = disconnectLabel,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (hasInfoAction) {
TextButton(
onClick = { onInfoRequested?.invoke() },
modifier = if (footerActionCount > 1) Modifier.weight(1.55f) else Modifier,
contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color.White),
) {
Text(
text = infoLabel.orEmpty(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
if (showDisconnectDialog) {
TrackingDisconnectDialog(
brand = brand,
onConfirm = {
showDisconnectDialog = false
onDisconnect()
},
onDismiss = { showDisconnectDialog = false },
)
}
}
@Composable
private fun TrackingConnectedIdentity(
label: String,
description: String,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = Color.White,
fontWeight = FontWeight.SemiBold,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.76f),
)
}
}
@Composable
private fun TrackingBrandPrimaryButton(
label: String,
loading: Boolean,
enabled: Boolean,
onClick: () -> Unit,
showSyncIcon: Boolean = false,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF171717),
disabledContainerColor = Color.White.copy(alpha = 0.34f),
disabledContentColor = Color.White.copy(alpha = 0.7f),
),
) {
when {
loading -> NuvioLoadingIndicator(
color = Color(0xFF171717),
modifier = Modifier.size(18.dp),
)
showSyncIcon -> Icon(
imageVector = Icons.Rounded.Sync,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
if (loading || showSyncIcon) {
Spacer(modifier = Modifier.width(8.dp))
}
Text(label)
}
}
@Composable
private fun TrackingBrandMessage(
text: String,
isError: Boolean,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (isError) TrackingErrorColor.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(NuvioTokens.Radius.md),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = MaterialTheme.typography.bodySmall,
color = if (isError) TrackingErrorColor else Color.White.copy(alpha = 0.82f),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TrackingDisconnectDialog(
brand: TrackingBrand,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = tokens.components.dialogMaxWidth),
shape = tokens.shapes.dialog,
color = tokens.colors.surfaceDialog,
) {
Column(
modifier = Modifier.padding(tokens.spacing.dialogPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.settings_tracking_disconnect_title, brand.displayName),
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when (brand) {
TrackingBrand.TRAKT ->
stringResource(Res.string.settings_trakt_disconnect_description)
TrackingBrand.SIMKL ->
stringResource(Res.string.settings_simkl_disconnect_description)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> stringResource(
Res.string.settings_tracking_disconnect_description,
brand.displayName,
)
},
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_cancel))
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(Res.string.settings_trakt_disconnect))
}
}
}
}
}
}
@Composable
internal fun TrackingBrandGlyph(
brand: TrackingBrand,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
when (brand) {
TrackingBrand.TRAKT -> Image(
painter = traktBrandPainter(TraktBrandAsset.Glyph),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.SIMKL -> Image(
painter = simklBrandPainter(SimklBrandAsset.Glyph),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.TMDB -> Image(
painter = integrationLogoPainter(IntegrationLogo.Tmdb),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
TrackingBrand.NUVIO -> Icon(
imageVector = Icons.Rounded.Sync,
contentDescription = contentDescription,
modifier = modifier,
tint = MaterialTheme.nuvio.colors.accent,
)
}
}
@Composable
private fun TrackingBrandWordmark(
brand: TrackingBrand,
contentDescription: String,
) {
val painter: Painter = when (brand) {
TrackingBrand.TRAKT -> traktBrandPainter(TraktBrandAsset.Wordmark)
TrackingBrand.SIMKL -> simklBrandPainter(SimklBrandAsset.Wordmark)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> return
}
Image(
painter = painter,
contentDescription = contentDescription,
modifier = when (brand) {
TrackingBrand.TRAKT -> Modifier
.width(86.dp)
.height(38.dp)
TrackingBrand.SIMKL -> Modifier
.width(124.dp)
.height(30.dp)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> Modifier
},
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
)
}
private fun TrackingBrand.cardBrush(): Brush = when (this) {
TrackingBrand.TRAKT -> Brush.linearGradient(
colors = listOf(Color(0xFF7D279B), Color(0xFFD61F56), Color(0xFFF22125)),
)
TrackingBrand.SIMKL -> Brush.linearGradient(
colors = listOf(Color(0xFF050505), Color(0xFF292929), Color(0xFF111111)),
)
TrackingBrand.NUVIO,
TrackingBrand.TMDB,
-> Brush.linearGradient(colors = listOf(Color(0xFF242424), Color(0xFF111111)))
}
@Composable
private fun simklErrorMessage(error: SimklAuthError?): String? = when (error) {
null, SimklAuthError.MISSING_CLIENT_ID -> null
SimklAuthError.INVALID_CALLBACK,
SimklAuthError.INVALID_CALLBACK_STATE,
-> stringResource(Res.string.settings_simkl_invalid_callback)
SimklAuthError.AUTHORIZATION_EXPIRED ->
stringResource(Res.string.settings_simkl_authorization_expired)
SimklAuthError.TOKEN_EXCHANGE_FAILED,
SimklAuthError.INVALID_TOKEN_RESPONSE,
-> stringResource(Res.string.settings_simkl_sign_in_failed)
SimklAuthError.AUTHORIZATION_REVOKED ->
stringResource(Res.string.settings_simkl_authorization_revoked)
}
private val TrackingErrorColor = Color(0xFFFFDAD6)
private const val SIMKL_WEBSITE_URL = "https://simkl.com"

View file

@ -0,0 +1,550 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.simkl.SimklAuthUiState
import com.nuvio.app.features.simkl.SimklConnectionMode
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.TrackingSettingsUiState
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.tracking.effectiveLibrarySourceMode
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_retry
import nuvio.composeapp.generated.resources.settings_tracking_connect_first
import nuvio.composeapp.generated.resources.settings_tracking_data_sources
import nuvio.composeapp.generated.resources.settings_tracking_nuvio_library_description
import nuvio.composeapp.generated.resources.settings_tracking_nuvio_progress_description
import nuvio.composeapp.generated.resources.settings_tracking_progress_refresh_failed
import nuvio.composeapp.generated.resources.settings_tracking_services
import nuvio.composeapp.generated.resources.settings_tracking_simkl_library_description
import nuvio.composeapp.generated.resources.settings_tracking_simkl_progress_description
import nuvio.composeapp.generated.resources.settings_tracking_source_fallback
import nuvio.composeapp.generated.resources.settings_tracking_tmdb_recommendations_description
import nuvio.composeapp.generated.resources.settings_tracking_trakt_library_description
import nuvio.composeapp.generated.resources.settings_tracking_trakt_progress_description
import nuvio.composeapp.generated.resources.settings_tracking_trakt_recommendations_description
import nuvio.composeapp.generated.resources.settings_tracking_viewing_discovery
import nuvio.composeapp.generated.resources.settings_trakt_comments
import nuvio.composeapp.generated.resources.settings_trakt_comments_description
import nuvio.composeapp.generated.resources.tracking_source_simkl
import nuvio.composeapp.generated.resources.tracking_watch_progress_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_all_history
import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
import nuvio.composeapp.generated.resources.trakt_continue_watching_window
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
import nuvio.composeapp.generated.resources.trakt_cw_window_title
import nuvio.composeapp.generated.resources.trakt_days_format
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
import nuvio.composeapp.generated.resources.trakt_library_source_title
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_title
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.trackingSettingsContent(
isTablet: Boolean,
traktUiState: TraktAuthUiState,
simklUiState: SimklAuthUiState,
settingsUiState: TrackingSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
item {
SettingsSection(
title = stringResource(Res.string.settings_tracking_services),
isTablet = isTablet,
) {
TrackingProviderCards(
isTablet = isTablet,
traktUiState = traktUiState,
simklUiState = simklUiState,
)
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_tracking_data_sources),
isTablet = isTablet,
) {
TrackingDataSources(
isTablet = isTablet,
settingsUiState = settingsUiState,
traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED,
simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED,
)
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_tracking_viewing_discovery),
isTablet = isTablet,
) {
TrackingViewingAndDiscovery(
isTablet = isTablet,
settingsUiState = settingsUiState,
traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED,
commentsEnabled = commentsEnabled,
onCommentsEnabledChange = onCommentsEnabledChange,
)
}
}
}
private enum class TrackingDataPicker {
LIBRARY,
WATCH_PROGRESS,
}
@Composable
private fun TrackingDataSources(
isTablet: Boolean,
settingsUiState: TrackingSettingsUiState,
traktConnected: Boolean,
simklConnected: Boolean,
) {
var activePickerName by rememberSaveable { mutableStateOf<String?>(null) }
val activePicker = activePickerName?.let(TrackingDataPicker::valueOf)
val scope = rememberCoroutineScope()
val transitionState by remember {
WatchProgressSourceCoordinator.ensureStarted()
WatchProgressSourceCoordinator.uiState
}.collectAsStateWithLifecycle()
val connectedProviders = buildSet {
if (traktConnected) add(TrackingProviderId.TRAKT)
if (simklConnected) add(TrackingProviderId.SIMKL)
}
val effectiveLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { provider ->
provider in connectedProviders
}
val effectiveProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { provider ->
provider in connectedProviders
}
val libraryFallback = if (effectiveLibrarySource != settingsUiState.librarySourceMode) {
stringResource(
Res.string.settings_tracking_source_fallback,
librarySourceModeLabel(settingsUiState.librarySourceMode),
librarySourceModeLabel(effectiveLibrarySource),
)
} else {
null
}
val progressFallback = if (effectiveProgressSource != settingsUiState.watchProgressSource) {
stringResource(
Res.string.settings_tracking_source_fallback,
watchProgressSourceLabel(settingsUiState.watchProgressSource),
watchProgressSourceLabel(effectiveProgressSource),
)
} else {
null
}
SettingsGroup(isTablet = isTablet) {
TrackingPreferenceActionRow(
title = stringResource(Res.string.trakt_library_source_title),
description = stringResource(Res.string.trakt_library_source_subtitle),
value = librarySourceModeLabel(effectiveLibrarySource),
supportingMessage = libraryFallback,
isTablet = isTablet,
onClick = { activePickerName = TrackingDataPicker.LIBRARY.name },
)
SettingsGroupDivider(isTablet = isTablet)
TrackingPreferenceActionRow(
title = stringResource(Res.string.trakt_watch_progress_title),
description = stringResource(Res.string.trakt_watch_progress_subtitle),
value = watchProgressSourceLabel(effectiveProgressSource),
supportingMessage = progressFallback,
isLoading = transitionState.isRefreshing,
isTablet = isTablet,
onClick = { activePickerName = TrackingDataPicker.WATCH_PROGRESS.name },
)
if (transitionState.lastRefreshSucceeded == false && !transitionState.isRefreshing) {
SettingsGroupDivider(isTablet = isTablet)
TrackingInlineErrorRow(
isTablet = isTablet,
message = stringResource(Res.string.settings_tracking_progress_refresh_failed),
onRetry = {
scope.launch {
WatchProgressSourceCoordinator.refreshActiveSource(ProfileRepository.activeProfileId)
}
},
)
}
}
when (activePicker) {
TrackingDataPicker.LIBRARY -> TrackingAdaptivePicker(
isTablet = isTablet,
title = stringResource(Res.string.trakt_library_source_dialog_title),
subtitle = stringResource(Res.string.trakt_library_source_dialog_subtitle),
selectedValue = effectiveLibrarySource,
options = librarySourceOptions(traktConnected, simklConnected),
onSelected = TrackingSettingsRepository::setLibrarySourceMode,
onDismiss = { activePickerName = null },
)
TrackingDataPicker.WATCH_PROGRESS -> TrackingAdaptivePicker(
isTablet = isTablet,
title = stringResource(Res.string.trakt_watch_progress_dialog_title),
subtitle = stringResource(Res.string.tracking_watch_progress_dialog_subtitle),
selectedValue = effectiveProgressSource,
options = watchProgressSourceOptions(traktConnected, simklConnected),
onSelected = { source ->
scope.launch {
WatchProgressSourceCoordinator.selectSource(
profileId = ProfileRepository.activeProfileId,
source = source,
)
}
},
onDismiss = { activePickerName = null },
)
null -> Unit
}
}
private enum class TrackingViewingPicker {
CONTINUE_WATCHING,
MORE_LIKE_THIS,
}
@Composable
private fun TrackingViewingAndDiscovery(
isTablet: Boolean,
settingsUiState: TrackingSettingsUiState,
traktConnected: Boolean,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
var activePickerName by rememberSaveable { mutableStateOf<String?>(null) }
val activePicker = activePickerName?.let(TrackingViewingPicker::valueOf)
val effectiveRecommendationsSource = effectiveTrackingRecommendationsSource(
source = settingsUiState.moreLikeThisSource,
traktConnected = traktConnected,
)
val recommendationsFallback = if (effectiveRecommendationsSource != settingsUiState.moreLikeThisSource) {
stringResource(
Res.string.settings_tracking_source_fallback,
moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource),
moreLikeThisSourceLabel(effectiveRecommendationsSource),
)
} else {
null
}
val connectTraktFirst = stringResource(
Res.string.settings_tracking_connect_first,
TrackingBrand.TRAKT.displayName,
)
SettingsGroup(isTablet = isTablet) {
TrackingPreferenceActionRow(
title = stringResource(Res.string.trakt_continue_watching_window),
description = stringResource(Res.string.trakt_continue_watching_subtitle),
value = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap),
isTablet = isTablet,
onClick = { activePickerName = TrackingViewingPicker.CONTINUE_WATCHING.name },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_trakt_comments),
description = listOfNotNull(
stringResource(Res.string.settings_trakt_comments_description),
connectTraktFirst.takeUnless { traktConnected },
).joinToString("\n"),
checked = commentsEnabled,
enabled = traktConnected,
isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange,
)
SettingsGroupDivider(isTablet = isTablet)
TrackingPreferenceActionRow(
title = stringResource(Res.string.trakt_more_like_this_source_title),
description = stringResource(Res.string.trakt_more_like_this_source_subtitle),
value = moreLikeThisSourceLabel(effectiveRecommendationsSource),
supportingMessage = recommendationsFallback,
isTablet = isTablet,
onClick = { activePickerName = TrackingViewingPicker.MORE_LIKE_THIS.name },
)
}
when (activePicker) {
TrackingViewingPicker.CONTINUE_WATCHING -> TrackingAdaptivePicker(
isTablet = isTablet,
title = stringResource(Res.string.trakt_cw_window_title),
subtitle = stringResource(Res.string.trakt_cw_window_subtitle),
selectedValue = normalizeTraktContinueWatchingDaysCap(settingsUiState.continueWatchingDaysCap),
options = continueWatchingOptions(),
onSelected = TrackingSettingsRepository::setContinueWatchingDaysCap,
onDismiss = { activePickerName = null },
)
TrackingViewingPicker.MORE_LIKE_THIS -> TrackingAdaptivePicker(
isTablet = isTablet,
title = stringResource(Res.string.trakt_more_like_this_source_dialog_title),
subtitle = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle),
selectedValue = effectiveRecommendationsSource,
options = recommendationsSourceOptions(traktConnected),
onSelected = TrackingSettingsRepository::setMoreLikeThisSource,
onDismiss = { activePickerName = null },
)
null -> Unit
}
}
@Composable
private fun TrackingPreferenceActionRow(
title: String,
description: String,
value: String,
isTablet: Boolean,
onClick: () -> Unit,
supportingMessage: String? = null,
isLoading: Boolean = false,
) {
val tokens = MaterialTheme.nuvio
SettingsNavigationRow(
title = title,
description = listOfNotNull(
description,
supportingMessage?.takeIf(String::isNotBlank),
).joinToString("\n"),
isTablet = isTablet,
trailingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isLoading) {
NuvioLoadingIndicator(
color = tokens.colors.accent,
modifier = Modifier.size(16.dp),
)
}
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.accent,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
},
onClick = onClick,
)
}
@Composable
private fun TrackingInlineErrorRow(
isTablet: Boolean,
message: String,
onRetry: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = if (isTablet) 20.dp else 16.dp,
vertical = if (isTablet) 14.dp else 12.dp,
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = tokens.colors.danger,
)
TextButton(onClick = onRetry) {
Text(stringResource(Res.string.action_retry))
}
}
}
@Composable
private fun librarySourceOptions(
traktConnected: Boolean,
simklConnected: Boolean,
): List<TrackingPickerOption<LibrarySourceMode>> {
val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected)
val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected)
return listOf(
TrackingPickerOption(
value = LibrarySourceMode.LOCAL,
title = stringResource(Res.string.trakt_library_source_nuvio),
description = stringResource(Res.string.settings_tracking_nuvio_library_description),
),
TrackingPickerOption(
value = LibrarySourceMode.TRAKT,
title = stringResource(Res.string.trakt_library_source_trakt),
description = stringResource(Res.string.settings_tracking_trakt_library_description),
enabled = traktAvailable,
unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable),
),
TrackingPickerOption(
value = LibrarySourceMode.SIMKL,
title = stringResource(Res.string.tracking_source_simkl),
description = stringResource(Res.string.settings_tracking_simkl_library_description),
enabled = simklAvailable,
unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable),
),
)
}
@Composable
private fun watchProgressSourceOptions(
traktConnected: Boolean,
simklConnected: Boolean,
): List<TrackingPickerOption<WatchProgressSource>> {
val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected)
val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected)
return listOf(
TrackingPickerOption(
value = WatchProgressSource.NUVIO_SYNC,
title = stringResource(Res.string.trakt_watch_progress_source_nuvio),
description = stringResource(Res.string.settings_tracking_nuvio_progress_description),
),
TrackingPickerOption(
value = WatchProgressSource.TRAKT,
title = stringResource(Res.string.trakt_watch_progress_source_trakt),
description = stringResource(Res.string.settings_tracking_trakt_progress_description),
enabled = traktAvailable,
unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable),
),
TrackingPickerOption(
value = WatchProgressSource.SIMKL,
title = stringResource(Res.string.tracking_source_simkl),
description = stringResource(Res.string.settings_tracking_simkl_progress_description),
enabled = simklAvailable,
unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable),
),
)
}
@Composable
private fun continueWatchingOptions(): List<TrackingPickerOption<Int>> =
TraktContinueWatchingDaysOptions.map { days ->
val normalizedDays = normalizeTraktContinueWatchingDaysCap(days)
TrackingPickerOption(
value = normalizedDays,
title = continueWatchingDaysCapLabel(normalizedDays),
)
}
@Composable
private fun recommendationsSourceOptions(
traktConnected: Boolean,
): List<TrackingPickerOption<MoreLikeThisSourcePreference>> {
val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected = false)
return listOf(
TrackingPickerOption(
value = MoreLikeThisSourcePreference.TMDB,
title = stringResource(Res.string.trakt_more_like_this_source_tmdb),
description = stringResource(Res.string.settings_tracking_tmdb_recommendations_description),
),
TrackingPickerOption(
value = MoreLikeThisSourcePreference.TRAKT,
title = stringResource(Res.string.trakt_more_like_this_source_trakt),
description = stringResource(Res.string.settings_tracking_trakt_recommendations_description),
enabled = traktAvailable,
unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable),
),
)
}
@Composable
private fun trackingUnavailableReason(
brand: TrackingBrand,
connected: Boolean,
): String? = if (connected) {
null
} else {
stringResource(Res.string.settings_tracking_connect_first, brand.displayName)
}
@Composable
private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) {
LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt)
LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio)
LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl)
}
@Composable
private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) {
WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt)
WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl)
}
@Composable
private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = when (source) {
MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt)
MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb)
}
@Composable
private fun continueWatchingDaysCapLabel(daysCap: Int): String {
val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
stringResource(Res.string.trakt_all_history)
} else {
stringResource(Res.string.trakt_days_format, normalized)
}
}
internal fun effectiveTrackingRecommendationsSource(
source: MoreLikeThisSourcePreference,
traktConnected: Boolean,
): MoreLikeThisSourcePreference =
if (source == MoreLikeThisSourcePreference.TRAKT && !traktConnected) {
MoreLikeThisSourcePreference.TMDB
} else {
source
}

View file

@ -1,823 +0,0 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.traktBrandPainter
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_playback_dialog_close
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_authentication
import nuvio.composeapp.generated.resources.settings_trakt_comments
import nuvio.composeapp.generated.resources.settings_trakt_comments_description
import nuvio.composeapp.generated.resources.settings_trakt_connect
import nuvio.composeapp.generated.resources.settings_trakt_connected_as
import nuvio.composeapp.generated.resources.settings_trakt_default_user
import nuvio.composeapp.generated.resources.settings_trakt_disconnect
import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser
import nuvio.composeapp.generated.resources.settings_trakt_features
import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in
import nuvio.composeapp.generated.resources.settings_trakt_intro_description
import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
import nuvio.composeapp.generated.resources.settings_trakt_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
import nuvio.composeapp.generated.resources.trakt_all_history
import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
import nuvio.composeapp.generated.resources.trakt_continue_watching_window
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
import nuvio.composeapp.generated.resources.trakt_cw_window_title
import nuvio.composeapp.generated.resources.trakt_days_format
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected
import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
import nuvio.composeapp.generated.resources.trakt_library_source_title
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb
import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.traktSettingsContent(
isTablet: Boolean,
uiState: TraktAuthUiState,
settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
item {
SettingsGroup(isTablet = isTablet) {
TraktBrandIntro(isTablet = isTablet)
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_trakt_authentication),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
TraktConnectionCard(
isTablet = isTablet,
uiState = uiState,
)
}
}
}
if (uiState.mode == TraktConnectionMode.CONNECTED) {
item {
SettingsSection(
title = stringResource(Res.string.settings_trakt_features),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
TraktFeatureRows(
isTablet = isTablet,
settingsUiState = settingsUiState,
commentsEnabled = commentsEnabled,
onCommentsEnabledChange = onCommentsEnabledChange,
)
}
}
}
}
}
@Composable
private fun TraktFeatureRows(
isTablet: Boolean,
settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) }
var statusMessage by rememberSaveable { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource)
val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected)
val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_library_source_title),
description = stringResource(Res.string.trakt_library_source_subtitle),
value = librarySourceValue,
isTablet = isTablet,
onClick = { showLibrarySourceDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_watch_progress_title),
description = stringResource(Res.string.trakt_watch_progress_subtitle),
value = watchProgressValue,
isTablet = isTablet,
onClick = { showWatchProgressDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_continue_watching_window),
description = stringResource(Res.string.trakt_continue_watching_subtitle),
value = continueWatchingWindowValue,
isTablet = isTablet,
onClick = { showContinueWatchingWindowDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_trakt_comments),
description = stringResource(Res.string.settings_trakt_comments_description),
checked = commentsEnabled,
isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange,
)
SettingsGroupDivider(isTablet = isTablet)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_more_like_this_source_title),
description = stringResource(Res.string.trakt_more_like_this_source_subtitle),
value = moreLikeThisSourceValue,
isTablet = isTablet,
onClick = { showMoreLikeThisSourceDialog = true },
)
statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
SettingsGroupDivider(isTablet = isTablet)
TraktInfoRow(
isTablet = isTablet,
text = message,
)
}
if (showLibrarySourceDialog) {
LibrarySourceModeDialog(
selectedSource = settingsUiState.librarySourceMode,
onSourceSelected = { source ->
TraktSettingsRepository.setLibrarySourceMode(source)
statusMessage = if (source == LibrarySourceMode.TRAKT) {
traktLibrarySelectedMessage
} else {
nuvioLibrarySelectedMessage
}
showLibrarySourceDialog = false
},
onDismiss = { showLibrarySourceDialog = false },
)
}
if (showWatchProgressDialog) {
WatchProgressSourceDialog(
selectedSource = settingsUiState.watchProgressSource,
onSourceSelected = { source ->
scope.launch {
val result = WatchProgressSourceCoordinator.selectSource(
profileId = ProfileRepository.activeProfileId,
source = source,
)
statusMessage = if (result.succeeded) {
if (result.requestedSource == WatchProgressSource.TRAKT) {
traktProgressSelectedMessage
} else {
nuvioProgressSelectedMessage
}
} else {
null
}
}
showWatchProgressDialog = false
},
onDismiss = { showWatchProgressDialog = false },
)
}
if (showContinueWatchingWindowDialog) {
ContinueWatchingWindowDialog(
selectedDaysCap = settingsUiState.continueWatchingDaysCap,
onDaysCapSelected = { days ->
TraktSettingsRepository.setContinueWatchingDaysCap(days)
showContinueWatchingWindowDialog = false
},
onDismiss = { showContinueWatchingWindowDialog = false },
)
}
if (showMoreLikeThisSourceDialog) {
MoreLikeThisSourceDialog(
selectedSource = settingsUiState.moreLikeThisSource,
onSourceSelected = { source ->
TraktSettingsRepository.setMoreLikeThisSource(source)
showMoreLikeThisSourceDialog = false
},
onDismiss = { showMoreLikeThisSourceDialog = false },
)
}
}
@Composable
private fun TraktSettingsActionRow(
title: String,
description: String,
value: String,
isTablet: Boolean,
onClick: () -> Unit,
) {
val verticalPadding = if (isTablet) 16.dp else 14.dp
val horizontalPadding = if (isTablet) 20.dp else 16.dp
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp)
.widthIn(max = if (isTablet) 560.dp else Dp.Unspecified),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun TraktInfoRow(
isTablet: Boolean,
text: String,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 14.dp else 12.dp
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun librarySourceModeLabel(source: LibrarySourceMode): String =
when (source) {
LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt)
LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio)
}
@Composable
private fun watchProgressSourceLabel(source: WatchProgressSource): String =
when (source) {
WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt)
WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
}
@Composable
private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String =
when (source) {
MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt)
MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb)
}
@Composable
private fun continueWatchingDaysCapLabel(daysCap: Int): String {
val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
stringResource(Res.string.trakt_all_history)
} else {
stringResource(Res.string.trakt_days_format, normalized)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun LibrarySourceModeDialog(
selectedSource: LibrarySourceMode,
onSourceSelected: (LibrarySourceMode) -> 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 = stringResource(Res.string.trakt_library_source_dialog_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_library_source_dialog_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source ->
TraktDialogOption(
label = librarySourceModeLabel(source),
selected = source == selectedSource,
onClick = { onSourceSelected(source) },
)
}
}
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 WatchProgressSourceDialog(
selectedSource: WatchProgressSource,
onSourceSelected: (WatchProgressSource) -> 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 = stringResource(Res.string.trakt_watch_progress_dialog_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source ->
TraktDialogOption(
label = watchProgressSourceLabel(source),
selected = source == selectedSource,
onClick = { onSourceSelected(source) },
)
}
}
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 ContinueWatchingWindowDialog(
selectedDaysCap: Int,
onDaysCapSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap)
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.trakt_cw_window_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_cw_window_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TraktContinueWatchingDaysOptions.forEach { days ->
val normalizedDays = normalizeTraktContinueWatchingDaysCap(days)
TraktDialogOption(
label = continueWatchingDaysCapLabel(days),
selected = normalizedDays == normalizedSelected,
onClick = { onDaysCapSelected(days) },
)
}
}
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 MoreLikeThisSourceDialog(
selectedSource: MoreLikeThisSourcePreference,
onSourceSelected: (MoreLikeThisSourcePreference) -> 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 = stringResource(Res.string.trakt_more_like_this_source_dialog_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source ->
TraktDialogOption(
label = moreLikeThisSourceLabel(source),
selected = source == selectedSource,
onClick = { onSourceSelected(source) },
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun TraktDialogOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
val containerColor = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun TraktBrandIntro(
isTablet: Boolean,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.foundation.Image(
painter = traktBrandPainter(TraktBrandAsset.Glyph),
contentDescription = null,
modifier = Modifier.size(if (isTablet) 84.dp else 72.dp),
contentScale = ContentScale.Fit,
)
Text(
text = stringResource(Res.string.settings_trakt_intro_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun TraktConnectionCard(
isTablet: Boolean,
uiState: TraktAuthUiState,
) {
val uriHandler = LocalUriHandler.current
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
when (uiState.mode) {
TraktConnectionMode.CONNECTED -> {
Text(
text = stringResource(
Res.string.settings_trakt_connected_as,
uiState.username ?: stringResource(Res.string.settings_trakt_default_user),
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(Res.string.settings_trakt_save_actions_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = TraktAuthRepository::onDisconnectRequested,
enabled = !uiState.isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
if (uiState.isLoading) {
NuvioLoadingIndicator(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(18.dp),
)
} else {
Text(stringResource(Res.string.settings_trakt_disconnect))
}
}
}
TraktConnectionMode.AWAITING_APPROVAL -> {
Text(
text = stringResource(Res.string.settings_trakt_finish_sign_in),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(Res.string.settings_trakt_approval_redirect),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
val authUrl = TraktAuthRepository.pendingAuthorizationUrl()
?: TraktAuthRepository.onConnectRequested()
if (authUrl == null) return@Button
runCatching { uriHandler.openUri(authUrl) }
.onFailure {
TraktAuthRepository.onAuthLaunchFailed(
it.message ?: failedOpenBrowserMessage,
)
}
},
enabled = !uiState.isLoading,
) {
Text(stringResource(Res.string.settings_trakt_open_login))
}
Button(
onClick = TraktAuthRepository::onCancelAuthorization,
enabled = !uiState.isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text(stringResource(Res.string.action_cancel))
}
}
TraktConnectionMode.DISCONNECTED -> {
Text(
text = stringResource(Res.string.settings_trakt_sign_in_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
val authUrl = TraktAuthRepository.onConnectRequested() ?: return@Button
runCatching { uriHandler.openUri(authUrl) }
.onFailure {
TraktAuthRepository.onAuthLaunchFailed(
it.message ?: failedOpenBrowserMessage,
)
}
},
enabled = uiState.credentialsConfigured && !uiState.isLoading,
) {
if (uiState.isLoading) {
NuvioLoadingIndicator(
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp),
)
} else {
Text(stringResource(Res.string.settings_trakt_connect))
}
}
if (!uiState.credentialsConfigured) {
Text(
text = stringResource(Res.string.settings_trakt_missing_credentials),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
uiState.statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
uiState.errorMessage?.takeIf { it.isNotBlank() }?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.nuvio.app.features.simkl
/**
* Provides a fallback watched check for anime franchise-parent content IDs.
*
* When meta.id is a franchise parent (e.g. "mal:49233") that doesn't exist in
* Simkl's snapshot, episodes can't be resolved through watchedKeys alone.
* This fallback parses the video ID (e.g. "mal:32615:1") and checks the
* Simkl snapshot directly.
*
* Optimistic removals are tracked after unmark, the videoId is blacklisted
* until the snapshot refreshes and confirms the removal.
*/
object SimklAnimeWatchedFallback {
private val optimisticallyRemoved = mutableSetOf<String>()
fun isWatched(videoId: String, episode: Int): Boolean {
if (videoId in optimisticallyRemoved) return false
return isWatchedByAnimeVideoId(
snapshot = SimklSyncRepository.state.value.snapshot,
videoId = videoId,
episode = episode,
)
}
fun markOptimisticallyRemoved(videoId: String) {
optimisticallyRemoved += videoId
}
/**
* Called when snapshot refreshes clear optimistic removals since
* the snapshot now reflects the true state.
*/
fun clearOptimisticRemovals() {
optimisticallyRemoved.clear()
}
}

View file

@ -0,0 +1,324 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlin.math.max
import kotlin.random.Random
private const val SIMKL_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024
private val simklSyncLog = Logger.withTag("SimklSync")
internal enum class SimklHttpMethod {
GET,
POST,
DELETE,
}
internal enum class SimklRetryPolicy {
TRANSIENT_FAILURES,
SYNC_WRITE,
NEVER,
}
internal data class SimklApiRequest(
val method: SimklHttpMethod,
val path: String,
val query: Map<String, String> = emptyMap(),
val body: String = "",
val requiresAuthentication: Boolean = true,
val retryPolicy: SimklRetryPolicy = SimklRetryPolicy.TRANSIENT_FAILURES,
val scrobbleStopConflictIsSuccess: Boolean = false,
)
internal data class SimklApiResponse(
val status: Int,
val body: String,
val headers: Map<String, String>,
val isSoftSuccess: Boolean = false,
)
internal class SimklApiException(
val status: Int?,
val errorCode: String?,
override val message: String,
cause: Throwable? = null,
) : Exception(message, cause)
internal fun interface SimklHttpEngine {
suspend fun execute(
method: String,
url: String,
headers: Map<String, String>,
body: String,
): RawHttpResponse
}
internal class SimklApiClient(
private val engine: SimklHttpEngine,
private val accessToken: () -> String?,
private val onUnauthorized: () -> Unit,
private val nowEpochMs: () -> Long = SimklPlatformClock::nowEpochMs,
private val sleep: suspend (Long) -> Unit = { delayMs -> delay(delayMs) },
private val retryJitterMs: () -> Long = { Random.nextLong(RETRY_JITTER_BOUND_MS + 1L) },
) {
private val requestMutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
private var nextGetAtEpochMs = 0L
private var nextPostAtEpochMs = 0L
suspend fun execute(request: SimklApiRequest): SimklApiResponse = requestMutex.withLock {
val token = if (request.requiresAuthentication) {
accessToken()?.takeIf(String::isNotBlank)
?: throw SimklApiException(
status = 401,
errorCode = "authentication_required",
message = "Simkl authentication is required",
)
} else {
null
}
val maxAttempts = when (request.retryPolicy) {
SimklRetryPolicy.TRANSIENT_FAILURES,
SimklRetryPolicy.SYNC_WRITE,
-> MAX_ATTEMPTS
SimklRetryPolicy.NEVER -> 1
}
var syncWriteLockRetried = false
for (attempt in 0 until maxAttempts) {
val response = try {
executeRateLimited(request.method) {
engine.execute(
method = request.method.name,
url = buildSimklApiUrl(request.path, request.query),
headers = simklRequestHeaders(
accessToken = token,
contentTypeJson = request.method == SimklHttpMethod.POST,
),
body = request.body,
)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (attempt == maxAttempts - 1) {
throw SimklApiException(
status = null,
errorCode = "transport_failure",
message = "Simkl request failed",
cause = error,
)
}
sleep(retryDelayMs(attempt, retryAfterHeader = null, retryJitterMs()))
continue
}
logSyncReadResponse(
request = request,
response = response,
attempt = attempt + 1,
)
if (
request.retryPolicy == SimklRetryPolicy.SYNC_WRITE &&
response.status == 400 &&
!syncWriteLockRetried &&
response.errorCode(json) == "rate_limit" &&
attempt < maxAttempts - 1
) {
syncWriteLockRetried = true
sleep(SYNC_WRITE_LOCK_RETRY_DELAY_MS)
continue
}
when (classifySimklResponse(response.status, request.scrobbleStopConflictIsSuccess)) {
SimklResponseAction.SUCCESS -> return@withLock response.toApiResponse()
SimklResponseAction.SOFT_SUCCESS -> {
return@withLock response.toApiResponse(isSoftSuccess = true)
}
SimklResponseAction.REAUTHENTICATE -> {
if (request.requiresAuthentication) onUnauthorized()
throw response.toApiException(json)
}
SimklResponseAction.FAIL -> throw response.toApiException(json)
SimklResponseAction.RETRY -> {
if (attempt == maxAttempts - 1) throw response.toApiException(json)
sleep(
retryDelayMs(
attempt = attempt,
retryAfterHeader = response.headers.headerValue("retry-after"),
jitterMs = retryJitterMs(),
),
)
}
}
}
error("Simkl request loop completed without a response")
}
private suspend fun <T> executeRateLimited(
method: SimklHttpMethod,
block: suspend () -> T,
): T {
awaitRateLimit(method)
return try {
block()
} finally {
recordRateLimitCompletion(method)
}
}
private suspend fun awaitRateLimit(method: SimklHttpMethod) {
val now = nowEpochMs()
val scheduledAt = when (method) {
SimklHttpMethod.GET -> nextGetAtEpochMs
SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs
}
if (scheduledAt > now) sleep(scheduledAt - now)
}
private fun recordRateLimitCompletion(method: SimklHttpMethod) {
val completedAt = nowEpochMs()
when (method) {
SimklHttpMethod.GET -> {
nextGetAtEpochMs = max(nextGetAtEpochMs, completedAt + GET_INTERVAL_MS)
}
SimklHttpMethod.POST, SimklHttpMethod.DELETE -> {
nextPostAtEpochMs = max(nextPostAtEpochMs, completedAt + POST_INTERVAL_MS)
}
}
}
private companion object {
const val GET_INTERVAL_MS = 100L
const val POST_INTERVAL_MS = 1_000L
const val MAX_ATTEMPTS = 5
const val SYNC_WRITE_LOCK_RETRY_DELAY_MS = 3_000L
const val RETRY_JITTER_BOUND_MS = 1_000L
}
}
private fun logSyncReadResponse(
request: SimklApiRequest,
response: RawHttpResponse,
attempt: Int,
) {
if (request.method != SimklHttpMethod.GET || !request.path.startsWith("/sync/")) return
simklSyncLog.d { simklSyncResponseLogMessage(request, response, attempt) }
}
internal fun simklSyncResponseLogMessage(
request: SimklApiRequest,
response: RawHttpResponse,
attempt: Int,
): String {
val queryKeys = request.query.keys.sorted().joinToString(prefix = "[", postfix = "]")
val contentType = response.headers.headerValue("content-type") ?: "<missing>"
return "Simkl HTTP response: method=${request.method} path=${request.path} " +
"queryKeys=$queryKeys status=${response.status} attempt=$attempt " +
"contentType=$contentType bodyChars=${response.body.length}"
}
internal object SimklApi {
val client: SimklApiClient by lazy {
SimklApiClient(
engine = SimklHttpEngine { method, url, headers, body ->
httpRequestRaw(
method = method,
url = url,
headers = headers,
body = body,
maxResponseBodyBytes = SIMKL_MAX_RESPONSE_BODY_BYTES,
)
},
accessToken = SimklAuthRepository::authorizedAccessToken,
onUnauthorized = SimklAuthRepository::onUnauthorizedResponse,
)
}
}
internal enum class SimklResponseAction {
SUCCESS,
SOFT_SUCCESS,
REAUTHENTICATE,
RETRY,
FAIL,
}
internal fun classifySimklResponse(
status: Int,
scrobbleStopConflictIsSuccess: Boolean = false,
): SimklResponseAction = when {
status in 200..299 -> SimklResponseAction.SUCCESS
status == 409 && scrobbleStopConflictIsSuccess -> SimklResponseAction.SOFT_SUCCESS
status == 401 -> SimklResponseAction.REAUTHENTICATE
status == 429 || status == 500 || status == 502 || status == 503 -> SimklResponseAction.RETRY
else -> SimklResponseAction.FAIL
}
internal fun retryDelayMs(
attempt: Int,
retryAfterHeader: String?,
jitterMs: Long,
): Long {
require(attempt in 0..4) { "Retry attempt must be between 0 and 4" }
val exponentialDelayMs = 1_000L shl attempt
val retryAfterMs = retryAfterHeader
?.substringBefore(',')
?.trim()
?.toLongOrNull()
?.coerceAtLeast(0L)
?.times(1_000L)
?: 0L
return (max(exponentialDelayMs, retryAfterMs) + jitterMs.coerceIn(0L, 1_000L))
.coerceAtMost(60_000L)
}
private fun RawHttpResponse.toApiResponse(isSoftSuccess: Boolean = false): SimklApiResponse =
SimklApiResponse(
status = status,
body = body,
headers = headers,
isSoftSuccess = isSoftSuccess,
)
private fun RawHttpResponse.toApiException(json: Json): SimklApiException {
val envelope = errorEnvelope(json)
return SimklApiException(
status = status,
errorCode = envelope?.error,
message = envelope?.message?.takeIf(String::isNotBlank)
?: envelope?.errorDescription?.takeIf(String::isNotBlank)
?: envelope?.error?.takeIf(String::isNotBlank)
?: "Simkl request failed with HTTP $status",
)
}
private fun RawHttpResponse.errorCode(json: Json): String? = errorEnvelope(json)?.error
private fun RawHttpResponse.errorEnvelope(json: Json): SimklErrorEnvelope? =
body.takeIf(String::isNotBlank)?.let { payload ->
runCatching { json.decodeFromString<SimklErrorEnvelope>(payload) }.getOrNull()
}
private fun Map<String, String>.headerValue(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
@Serializable
private data class SimklErrorEnvelope(
val error: String? = null,
val code: Int? = null,
val message: String? = null,
@SerialName("error_description") val errorDescription: String? = null,
)

View file

@ -0,0 +1,47 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.core.build.AppVersionConfig
import io.ktor.http.encodeURLParameter
internal const val SIMKL_API_BASE_URL = "https://api.simkl.com"
internal const val SIMKL_AUTHORIZE_URL = "https://simkl.com/oauth/authorize"
internal val simklAppVersion: String
get() = AppVersionConfig.VERSION_NAME.ifBlank { "dev" }
internal fun buildSimklApiUrl(
path: String,
query: Map<String, String> = emptyMap(),
): String {
val normalizedPath = path.trim().let { value ->
if (value.startsWith('/')) value else "/$value"
}
val parameters = linkedMapOf<String, String>().apply {
putAll(query)
put("client_id", SimklConfig.CLIENT_ID)
put("app-name", SimklConfig.APP_NAME)
put("app-version", simklAppVersion)
}
return buildString {
append(SIMKL_API_BASE_URL)
append(normalizedPath)
append('?')
append(
parameters.entries.joinToString("&") { (key, value) ->
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
},
)
}
}
internal fun simklRequestHeaders(
accessToken: String? = null,
contentTypeJson: Boolean = false,
): Map<String, String> = buildMap {
put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion")
put("Accept", "application/json")
accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token ->
put("Authorization", "Bearer $token")
}
if (contentTypeJson) put("Content-Type", "application/json")
}

View file

@ -0,0 +1,245 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProgressSnapshot
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingWatchedProvider
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
object SimklWatchedSyncAdapter : TrackingWatchedProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override suspend fun pull(profileId: Int, pageSize: Int): List<WatchedItem> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
SimklSyncRepository.refresh(
intent = TrackingRefreshIntent.AUTOMATIC,
origin = SimklRefreshOrigin.WATCHED_ITEMS,
)
val snapshot = SimklSyncRepository.state.value.snapshot
val projection = snapshot.toSimklWatchedProjection()
SimklWatchDiagnostics.logProjection(
stage = "items-pull",
snapshot = snapshot,
projection = projection,
)
return projection.items
}
override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set<String>? {
if (profileId != ProfileRepository.activeProfileId) return null
SimklSyncRepository.refresh(
intent = TrackingRefreshIntent.AUTOMATIC,
origin = SimklRefreshOrigin.WATCHED_SERIES,
)
val snapshot = SimklSyncRepository.state.value.snapshot
val projection = snapshot.toSimklWatchedProjection()
SimklWatchDiagnostics.logProjection(
stage = "fully-watched-pull",
snapshot = snapshot,
projection = projection,
)
return projection.fullyWatchedSeriesKeys
}
override suspend fun pullExtraWatchedKeys(profileId: Int): Set<String> {
if (profileId != ProfileRepository.activeProfileId) return emptySet()
SimklSyncRepository.refresh(
intent = TrackingRefreshIntent.AUTOMATIC,
origin = SimklRefreshOrigin.WATCHED_ITEMS,
)
return SimklSyncRepository.state.value.snapshot.animeAlternateWatchedKeys()
}
override fun observeExtraWatchedKeys(profileId: Int): kotlinx.coroutines.flow.Flow<Set<String>> =
SimklSyncRepository.state
.map { state ->
SimklAnimeWatchedFallback.clearOptimisticRemovals()
state.snapshot.animeAlternateWatchedKeys()
}
.distinctUntilChanged()
override suspend fun push(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val historyItems = items.map { item ->
TrackingHistoryItem(
media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
season = item.season,
episode = item.episode,
videoId = item.videoId,
),
watchedAtEpochMs = item.markedAtEpochMs,
)
}
val result = SimklMutationRepository.addToHistory(profileId = profileId, items = historyItems)
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items"
}
}
override suspend fun delete(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return
// Optimistically mark video IDs as removed so fallback won't show them as watched
items.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) }
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val media = items.map { item ->
snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
season = item.season,
episode = item.episode,
videoId = item.videoId,
).let { ref ->
val enriched = snapshot.enrichMediaReference(ref)
enriched.resolveAnimeEpisodeForSimkl()
}
}
val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media)
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items"
}
}
}
data class SimklProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(),
val isLoading: Boolean = false,
val hasLoadedRemoteProgress: Boolean = false,
val errorMessage: String? = null,
)
object SimklProgressRepository {
private val log = Logger.withTag("SimklProgress")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklProgressUiState())
val uiState: StateFlow<SimklProgressUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refresh(intent: TrackingRefreshIntent) {
SimklSyncRepository.refresh(
intent = intent,
origin = SimklRefreshOrigin.PROGRESS,
)
publish(SimklSyncRepository.state.value)
}
suspend fun removeProgress(entries: Collection<WatchProgressEntry>) {
val sessionIds = entries.mapNotNullTo(linkedSetOf()) { entry ->
entry.progressKey
?.removePrefix(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX)
?.takeIf { entry.progressKey.startsWith(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) }
?.toLongOrNull()
?.takeIf { it > 0L }
}
if (sessionIds.isEmpty()) return
val removed = linkedSetOf<Long>()
for (sessionId in sessionIds) {
try {
SimklApi.client.execute(
SimklApiRequest(
method = SimklHttpMethod.DELETE,
path = "/sync/playback/$sessionId",
),
)
removed += sessionId
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
val apiError = error as? SimklApiException
log.w {
"Failed to remove Simkl playback: status=${apiError?.status} " +
"code=${apiError?.errorCode ?: "transport_failure"}"
}
}
}
SimklSyncRepository.commitPlaybackRemoval(removed)
if (removed.isNotEmpty()) {
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.PLAYBACK_REMOVAL,
)
}
}
private fun publish(syncState: SimklSyncUiState) {
_uiState.value = SimklProgressUiState(
entries = syncState.snapshot.toSimklProgressEntries(),
isLoading = syncState.isLoading,
hasLoadedRemoteProgress = syncState.hasLoaded && syncState.errorMessage == null,
errorMessage = syncState.errorMessage,
)
}
}
object SimklTrackingProgressProvider : TrackingProgressProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override val changes: Flow<Unit> = SimklProgressRepository.uiState.map { Unit }
override fun ensureLoaded() = SimklProgressRepository.ensureLoaded()
override fun onProfileChanged() = SimklProgressRepository.ensureLoaded()
override suspend fun refresh(force: Boolean, sourceChanged: Boolean) =
SimklProgressRepository.refresh(simklProgressRefreshIntent)
override fun snapshot(): TrackingProgressSnapshot {
val state = SimklProgressRepository.uiState.value
return TrackingProgressSnapshot(
entries = state.entries,
hiddenContentIds = SimklSyncRepository.state.value.snapshot
.hiddenFromContinueWatchingContentIds(),
hasLoadedRemoteProgress = state.hasLoadedRemoteProgress,
errorMessage = state.errorMessage,
)
}
override suspend fun removeProgress(entries: Collection<WatchProgressEntry>) =
SimklProgressRepository.removeProgress(entries)
override fun isHiddenFromProgress(contentId: String): Boolean =
SimklSyncRepository.state.value.snapshot.isHiddenFromContinueWatching(contentId)
override fun normalizeParentContentId(parentContentId: String, videoId: String?): String {
val snapshot = SimklSyncRepository.state.value.snapshot
val resolvedId = snapshot.resolveCanonicalContentId(parentContentId)
return resolvedId ?: parentContentId
}
}
private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:"

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.Serializable
enum class SimklConnectionMode {
DISCONNECTED,
AWAITING_APPROVAL,
CONNECTED,
}
enum class SimklAuthError {
MISSING_CLIENT_ID,
INVALID_CALLBACK,
INVALID_CALLBACK_STATE,
AUTHORIZATION_EXPIRED,
TOKEN_EXCHANGE_FAILED,
INVALID_TOKEN_RESPONSE,
AUTHORIZATION_REVOKED,
}
data class SimklAuthUiState(
val mode: SimklConnectionMode = SimklConnectionMode.DISCONNECTED,
val credentialsConfigured: Boolean = false,
val isLoading: Boolean = false,
val username: String? = null,
val accountId: Long? = null,
val tokenExpiresAtEpochMs: Long? = null,
val pendingAuthorizationStartedAtEpochMs: Long? = null,
val error: SimklAuthError? = null,
)
@Serializable
internal data class SimklStoredAuthState(
val username: String? = null,
val accountId: Long? = null,
val hasFetchedUserSettings: Boolean = false,
val settingsActivityWatermark: String? = null,
val tokenExpiresAtEpochMs: Long? = null,
val pendingAuthorizationState: String? = null,
val pendingAuthorizationStartedAtEpochMs: Long? = null,
) {
val hasPendingAuthorization: Boolean
get() = !pendingAuthorizationState.isNullOrBlank()
}
internal enum class SimklSettingsRefreshAction {
NONE,
RECORD_WATERMARK,
FETCH,
}
internal fun simklSettingsRefreshAction(
state: SimklStoredAuthState,
activityWatermark: String?,
): SimklSettingsRefreshAction = when {
activityWatermark.isNullOrBlank() -> SimklSettingsRefreshAction.NONE
activityWatermark == state.settingsActivityWatermark -> SimklSettingsRefreshAction.NONE
state.settingsActivityWatermark == null && state.hasFetchedUserSettings -> {
SimklSettingsRefreshAction.RECORD_WATERMARK
}
else -> SimklSettingsRefreshAction.FETCH
}
internal sealed interface SimklAuthCallback {
data class AuthorizationCode(
val code: String,
val state: String,
) : SimklAuthCallback
data object Invalid : SimklAuthCallback
data object NotSimkl : SimklAuthCallback
}

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.simkl
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
internal const val SIMKL_AUTHORIZATION_TIMEOUT_MS = 5L * 60L * 1_000L
internal fun parseSimklAuthCallback(
callbackUrl: String,
redirectUri: String,
): SimklAuthCallback {
if (callbackUrl != redirectUri && !callbackUrl.startsWith("$redirectUri?")) {
return SimklAuthCallback.NotSimkl
}
val parsed = runCatching { Url(callbackUrl) }.getOrNull() ?: return SimklAuthCallback.Invalid
val code = parsed.parameters["code"].orEmpty().trim()
val state = parsed.parameters["state"].orEmpty().trim()
if (code.isBlank() || state.isBlank()) return SimklAuthCallback.Invalid
return SimklAuthCallback.AuthorizationCode(code = code, state = state)
}
internal fun isSimklAuthorizationExpired(
startedAtEpochMs: Long?,
nowEpochMs: Long,
): Boolean = startedAtEpochMs == null ||
nowEpochMs < startedAtEpochMs ||
nowEpochMs - startedAtEpochMs > SIMKL_AUTHORIZATION_TIMEOUT_MS
internal fun constantTimeEquals(left: String, right: String): Boolean {
val leftBytes = left.encodeToByteArray()
val rightBytes = right.encodeToByteArray()
val length = maxOf(leftBytes.size, rightBytes.size)
var difference = leftBytes.size xor rightBytes.size
for (index in 0 until length) {
val leftByte = leftBytes.getOrElse(index) { 0 }.toInt()
val rightByte = rightBytes.getOrElse(index) { 0 }.toInt()
difference = difference or (leftByte xor rightByte)
}
return difference == 0
}
internal fun buildSimklAuthorizationUrl(
clientId: String,
redirectUri: String,
appName: String,
appVersion: String,
material: SimklPkceMaterial,
): String = buildString {
append(SIMKL_AUTHORIZE_URL)
append("?response_type=code")
append("&client_id=")
append(clientId.encodeURLParameter())
append("&redirect_uri=")
append(redirectUri.encodeURLParameter())
append("&code_challenge=")
append(material.challenge.encodeURLParameter())
append("&code_challenge_method=S256")
append("&state=")
append(material.state.encodeURLParameter())
append("&app-name=")
append(appName.encodeURLParameter())
append("&app-version=")
append(appVersion.encodeURLParameter())
}

View file

@ -0,0 +1,427 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.tracking.TrackingAuthProvider
import com.nuvio.app.features.tracking.TrackingCapability
import com.nuvio.app.features.tracking.TrackingProviderDescriptor
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SimklAuthRepository : TrackingAuthProvider {
private val log = Logger.withTag("SimklAuth")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val authorizationMutex = Mutex()
private val _uiState = MutableStateFlow(SimklAuthUiState())
val uiState: StateFlow<SimklAuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(false)
override val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
override val descriptor = TrackingProviderDescriptor(
id = TrackingProviderId.SIMKL,
displayName = "Simkl",
capabilities = setOf(
TrackingCapability.AUTHENTICATION,
TrackingCapability.LIBRARY_READ,
TrackingCapability.LIBRARY_WRITE,
TrackingCapability.WATCHED_READ,
TrackingCapability.WATCHED_WRITE,
TrackingCapability.PROGRESS_READ,
TrackingCapability.PROGRESS_WRITE,
TrackingCapability.SCROBBLE,
),
)
private var hasLoaded = false
private var storedState = SimklStoredAuthState()
private var accessToken: String? = null
init {
TrackingProviderRegistry.register(this)
}
override fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
override fun onProfileChanged() {
loadFromDisk()
}
override fun clearLocalState() {
hasLoaded = false
storedState = SimklStoredAuthState()
accessToken = null
publish()
}
override fun removeStoredProfile(profileId: Int) {
SimklAuthStorage.removeProfile(profileId)
}
fun snapshot(): SimklAuthUiState {
ensureLoaded()
return uiState.value
}
fun hasRequiredCredentials(): Boolean = SimklConfig.CLIENT_ID.isNotBlank()
fun onConnectRequested(): String? {
ensureLoaded()
if (!hasRequiredCredentials()) {
publish(error = SimklAuthError.MISSING_CLIENT_ID)
return null
}
val material = generateSimklPkceMaterial()
SimklAuthStorage.saveCodeVerifier(material.verifier)
storedState = storedState.copy(
pendingAuthorizationState = material.state,
pendingAuthorizationStartedAtEpochMs = SimklPlatformClock.nowEpochMs(),
)
persistMetadata()
publish(error = null)
return authorizationUrl(material)
}
fun pendingAuthorizationUrl(): String? {
ensureLoaded()
val state = storedState.pendingAuthorizationState?.takeIf(String::isNotBlank) ?: return null
val verifier = SimklAuthStorage.loadCodeVerifier()?.takeIf(String::isNotBlank) ?: run {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
if (isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
) {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
return authorizationUrl(
SimklPkceMaterial(
verifier = verifier,
challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(),
state = state,
),
)
}
fun onCancelAuthorization() {
ensureLoaded()
clearPendingAuthorization()
persistMetadata()
publish(error = null)
}
override fun handleAuthCallback(url: String): Boolean {
ensureLoaded()
return when (val callback = parseSimklAuthCallback(url, SimklConfig.REDIRECT_URI)) {
SimklAuthCallback.NotSimkl -> false
SimklAuthCallback.Invalid -> {
clearPendingAuthorization()
persistMetadata()
publish(error = SimklAuthError.INVALID_CALLBACK)
true
}
is SimklAuthCallback.AuthorizationCode -> {
scope.launch { completeAuthorization(callback) }
true
}
}
}
fun onDisconnectRequested() {
ensureLoaded()
accessToken = null
SimklAuthStorage.saveAccessToken(null)
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
SimklSyncRepository.clearLocalState()
publish(error = null)
}
internal fun authorizedAccessToken(): String? {
ensureLoaded()
val token = accessToken?.takeIf(String::isNotBlank) ?: return null
val expiresAt = storedState.tokenExpiresAtEpochMs
if (expiresAt != null && SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS) {
invalidateCredentials(SimklAuthError.AUTHORIZATION_EXPIRED)
return null
}
return token
}
internal fun onUnauthorizedResponse() {
invalidateCredentials(SimklAuthError.AUTHORIZATION_REVOKED)
}
suspend fun refreshUserSettings(): String? {
authorizedAccessToken() ?: return null
return if (fetchAndStoreUserSettings()) storedState.username else null
}
internal suspend fun synchronizeUserSettings(activityWatermark: String?) {
authorizedAccessToken() ?: return
when (simklSettingsRefreshAction(storedState, activityWatermark)) {
SimklSettingsRefreshAction.NONE -> Unit
SimklSettingsRefreshAction.RECORD_WATERMARK -> {
storedState = storedState.copy(settingsActivityWatermark = activityWatermark)
persistMetadata()
}
SimklSettingsRefreshAction.FETCH -> {
fetchAndStoreUserSettings(activityWatermark)
}
}
}
private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) =
authorizationMutex.withLock {
publish(isLoading = true, error = null)
val expectedState = storedState.pendingAuthorizationState
val verifier = SimklAuthStorage.loadCodeVerifier()
val isExpired = isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
if (expectedState.isNullOrBlank() || verifier.isNullOrBlank() || isExpired) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.AUTHORIZATION_EXPIRED)
return@withLock
}
if (!constantTimeEquals(callback.state, expectedState)) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.INVALID_CALLBACK_STATE)
return@withLock
}
val request = SimklTokenRequest(
code = callback.code,
clientId = SimklConfig.CLIENT_ID,
codeVerifier = verifier,
redirectUri = SimklConfig.REDIRECT_URI,
)
val response = try {
SimklApi.client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/oauth/token",
body = json.encodeToString(request),
requiresAuthentication = false,
retryPolicy = SimklRetryPolicy.NEVER,
),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Simkl token exchange failed: ${error.message}" }
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED)
return@withLock
}
val token = runCatching { json.decodeFromString<SimklTokenResponse>(response.body) }
.getOrNull()
?.takeIf { it.accessToken.isNotBlank() }
if (token == null) {
clearPendingAuthorization()
persistMetadata()
publish(isLoading = false, error = SimklAuthError.INVALID_TOKEN_RESPONSE)
return@withLock
}
accessToken = token.accessToken
SimklAuthStorage.saveAccessToken(token.accessToken)
clearPendingAuthorization()
storedState = storedState.copy(
tokenExpiresAtEpochMs = token.expiresIn
?.takeIf { seconds -> seconds > 0L }
?.let { seconds -> SimklPlatformClock.nowEpochMs() + seconds * 1_000L },
)
persistMetadata()
publish(isLoading = false, error = null)
fetchAndStoreUserSettings()
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.AUTHORIZATION,
)
}
private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean {
val response = try {
SimklApi.client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/users/settings",
),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Failed to fetch Simkl user settings: ${error.message}" }
return false
}
val settings = runCatching { json.decodeFromString<SimklUserSettingsResponse>(response.body) }
.getOrNull() ?: return false
storedState = storedState.copy(
username = settings.user?.name,
accountId = settings.account?.id,
hasFetchedUserSettings = true,
settingsActivityWatermark = activityWatermark ?: storedState.settingsActivityWatermark,
)
persistMetadata()
publish(error = null)
return true
}
private fun loadFromDisk() {
hasLoaded = true
storedState = SimklAuthStorage.loadMetadataPayload()
?.trim()
?.takeIf(String::isNotEmpty)
?.let { payload ->
runCatching { json.decodeFromString<SimklStoredAuthState>(payload) }
.onFailure { error -> log.w { "Failed to parse Simkl auth metadata: ${error.message}" } }
.getOrNull()
}
?: SimklStoredAuthState()
accessToken = SimklAuthStorage.loadAccessToken()?.takeIf(String::isNotBlank)
if (accessToken != null && storedState.tokenExpiresAtEpochMs?.let { expiresAt ->
SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS
} == true
) {
accessToken = null
SimklAuthStorage.saveAccessToken(null)
storedState = SimklStoredAuthState()
persistMetadata()
}
if (storedState.hasPendingAuthorization && isSimklAuthorizationExpired(
startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
nowEpochMs = SimklPlatformClock.nowEpochMs(),
)
) {
clearPendingAuthorization()
persistMetadata()
}
publish(error = null)
}
private fun invalidateCredentials(error: SimklAuthError) {
accessToken = null
SimklAuthStorage.saveAccessToken(null)
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
SimklSyncRepository.clearLocalState()
publish(isLoading = false, error = error)
}
private fun clearPendingAuthorization() {
SimklAuthStorage.saveCodeVerifier(null)
storedState = storedState.copy(
pendingAuthorizationState = null,
pendingAuthorizationStartedAtEpochMs = null,
)
}
private fun persistMetadata() {
SimklAuthStorage.saveMetadataPayload(json.encodeToString(storedState))
}
private fun publish(
isLoading: Boolean = _uiState.value.isLoading,
error: SimklAuthError? = _uiState.value.error,
) {
val authenticated = !accessToken.isNullOrBlank()
_isAuthenticated.value = authenticated
_uiState.value = SimklAuthUiState(
mode = when {
authenticated -> SimklConnectionMode.CONNECTED
storedState.hasPendingAuthorization -> SimklConnectionMode.AWAITING_APPROVAL
else -> SimklConnectionMode.DISCONNECTED
},
credentialsConfigured = hasRequiredCredentials(),
isLoading = isLoading,
username = storedState.username,
accountId = storedState.accountId,
tokenExpiresAtEpochMs = storedState.tokenExpiresAtEpochMs,
pendingAuthorizationStartedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs,
error = error,
)
}
private fun authorizationUrl(material: SimklPkceMaterial): String =
buildSimklAuthorizationUrl(
clientId = SimklConfig.CLIENT_ID,
redirectUri = SimklConfig.REDIRECT_URI,
appName = SimklConfig.APP_NAME,
appVersion = simklAppVersion,
material = material,
)
private const val TOKEN_EXPIRY_SKEW_MS = 60_000L
}
@Serializable
private data class SimklTokenRequest(
val code: String,
@SerialName("client_id") val clientId: String,
@SerialName("code_verifier") val codeVerifier: String,
@SerialName("redirect_uri") val redirectUri: String,
@SerialName("grant_type") val grantType: String = "authorization_code",
)
@Serializable
private data class SimklTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String? = null,
val scope: String? = null,
@SerialName("expires_in") val expiresIn: Long? = null,
)
@Serializable
private data class SimklUserSettingsResponse(
val user: SimklUser? = null,
val account: SimklAccount? = null,
)
@Serializable
private data class SimklUser(
val name: String? = null,
)
@Serializable
private data class SimklAccount(
val id: Long? = null,
)

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.simkl
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
enum class SimklBrandAsset {
Glyph,
Wordmark,
}
@Composable
expect fun simklBrandPainter(asset: SimklBrandAsset): Painter

View file

@ -0,0 +1,266 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingLibraryProvider
import com.nuvio.app.features.tracking.TrackingLibrarySnapshot
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation
import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact
import com.nuvio.app.features.tracking.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
data class SimklLibraryUiState(
val items: List<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
object SimklLibraryRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklLibraryUiState())
val uiState: StateFlow<SimklLibraryUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refresh(intent: TrackingRefreshIntent) {
SimklSyncRepository.refresh(
intent = intent,
origin = SimklRefreshOrigin.LIBRARY,
)
publish(SimklSyncRepository.state.value)
}
fun isTracked(contentId: String, contentType: String? = null): Boolean {
ensureLoaded()
return findItem(contentId, contentType) != null
}
fun statusMembership(contentId: String, contentType: String? = null): Map<String, Boolean> {
ensureLoaded()
val listKeys = findItem(contentId, contentType)?.listKeys.orEmpty()
return simklLibraryStatusDefinitions.associate { definition ->
definition.key to (definition.key in listKeys)
}
}
suspend fun applyStatusMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean = false,
): TrackingMembershipResolution? {
if (profileId != ProfileRepository.activeProfileId) return null
ensureLoaded()
val desiredStatuses = simklLibraryStatusDefinitions.filter { definition ->
desiredMembership[definition.key] == true
}
require(desiredStatuses.size <= 1) { "A Simkl item can have only one list status" }
val desiredStatus = desiredStatuses.singleOrNull()
require(desiredStatus == null || desiredStatus.supportedContentTypes.any { supported ->
supported.equals(item.type, ignoreCase = true)
}) { "${desiredStatus?.title} does not support ${item.type}" }
val currentStatus = findItem(item.id, item.type)?.listKeys.orEmpty()
.firstNotNullOfOrNull(::simklLibraryStatusDefinition)
if (desiredStatus == currentStatus) return null
val snapshot = SimklSyncRepository.state.value.snapshot
val media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
)
val result = when {
desiredStatus != null -> SimklMutationRepository.moveToList(
profileId = profileId,
items = listOf(media),
destination = desiredStatus.trackingStatus,
)
currentStatus != null -> {
require(
snapshot.membershipRemovalConfirmation(item.id) == null ||
destructiveRemovalConfirmed,
) {
"Removing this item from Simkl would also clear watched history or a rating"
}
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
}
else -> return null
}
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items"
}
val resolution = desiredStatus?.let { requested ->
result.resolvedListStatuses
.singleOrNull()
?.let(::simklLibraryStatusDefinition)
?.let { resolved ->
TrackingMembershipResolution(
providerId = TrackingProviderId.SIMKL,
requestedListKey = requested.key,
resolvedListKey = resolved.key,
)
}
}
refresh(TrackingRefreshIntent.INVALIDATED)
return resolution
}
fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? {
ensureLoaded()
val removesStatus = simklLibraryStatusDefinitions.none { definition ->
desiredMembership[definition.key] == true
} && findItem(item.id, item.type)?.listKeys.orEmpty().any { key ->
simklLibraryStatusDefinition(key) != null
}
if (!removesStatus) return null
return SimklSyncRepository.state.value.snapshot.membershipRemovalConfirmation(item.id)
}
private fun publish(syncState: SimklSyncUiState) {
val projection = syncState.snapshot.toSimklLibraryProjection()
_uiState.value = SimklLibraryUiState(
items = projection.items,
sections = projection.sections,
isLoading = syncState.isLoading,
hasLoaded = syncState.hasLoaded,
errorMessage = syncState.errorMessage,
)
}
private fun findItem(contentId: String, contentType: String?): LibraryItem? =
uiState.value.items.firstOrNull { item ->
item.id.equals(contentId, ignoreCase = true) &&
(contentType == null || item.type.equals(contentType, ignoreCase = true))
}
}
object SimklTrackingLibraryProvider : TrackingLibraryProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override val changes: Flow<Unit> = SimklLibraryRepository.uiState.map { Unit }
override val connectionRefreshIntent: TrackingRefreshIntent = simklConnectionRefreshIntent
override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded()
override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded()
override suspend fun refresh(intent: TrackingRefreshIntent) =
SimklLibraryRepository.refresh(intent)
override fun snapshot(): TrackingLibrarySnapshot {
val state = SimklLibraryRepository.uiState.value
return TrackingLibrarySnapshot(
items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs),
sections = state.sections,
tabs = simklLibraryStatusDefinitions.map { definition ->
TrackingLibraryTab(
key = definition.key,
title = definition.title,
providerId = TrackingProviderId.SIMKL,
kind = if (definition.status == SimklListStatus.PLAN_TO_WATCH) {
TrackingLibraryTabKind.WATCHLIST
} else {
TrackingLibraryTabKind.STATUS
},
selectionGroup = SIMKL_STATUS_SELECTION_GROUP,
supportedContentTypes = definition.supportedContentTypes,
isMembershipDestination = definition.isMembershipDestination,
)
},
hasLoaded = state.hasLoaded,
isLoading = state.isLoading,
errorMessage = state.errorMessage,
)
}
override fun contains(contentId: String, contentType: String?): Boolean =
SimklLibraryRepository.isTracked(contentId, contentType)
override fun find(contentId: String): LibraryItem? =
SimklLibraryRepository.uiState.value.items.firstOrNull { item ->
item.id.equals(contentId, ignoreCase = true)
}
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
SimklLibraryRepository.statusMembership(item.id, item.type)
override fun toggledDefaultMembership(
currentMembership: Map<String, Boolean>,
): Map<String, Boolean> = currentMembership.mapValues { false }.toMutableMap().apply {
if (currentMembership.values.none { isSelected -> isSelected }) {
this[simklLibraryStatusDefinitions.single { definition ->
definition.status == SimklListStatus.PLAN_TO_WATCH
}.key] = true
}
}
override fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? =
SimklLibraryRepository.membershipRemovalConfirmation(item, desiredMembership)
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean,
): TrackingMembershipResolution? =
SimklLibraryRepository.applyStatusMembership(
profileId = profileId,
item = item,
desiredMembership = desiredMembership,
destructiveRemovalConfirmed = destructiveRemovalConfirmed,
)
}
internal fun SimklSyncSnapshot.membershipRemovalConfirmation(
contentId: String,
): TrackingMembershipRemovalConfirmation? =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.takeUnless { entry ->
entry.status == SimklListStatus.PLAN_TO_WATCH &&
entry.lastWatchedAt == null &&
entry.userRating == null &&
entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } }
}
?.let {
TrackingMembershipRemovalConfirmation(
providerId = TrackingProviderId.SIMKL,
impacts = setOf(
TrackingMembershipRemovalImpact.WATCHED_HISTORY,
TrackingMembershipRemovalImpact.RATING,
),
)
}

View file

@ -0,0 +1,126 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingProviderId
internal const val SIMKL_STATUS_SELECTION_GROUP = "simkl:status"
internal data class SimklLibraryStatusDefinition(
val status: SimklListStatus,
val key: String,
val title: String,
val trackingStatus: TrackingListStatus,
val supportedContentTypes: Set<String>,
val isMembershipDestination: Boolean = true,
)
internal data class SimklLibraryProjection(
val items: List<LibraryItem>,
val sections: List<LibrarySection>,
)
internal val simklLibraryStatusDefinitions = listOf(
SimklLibraryStatusDefinition(
status = SimklListStatus.WATCHING,
key = "simkl:status:watching",
title = "Watching",
trackingStatus = TrackingListStatus.WATCHING,
supportedContentTypes = setOf("series", "anime"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.PLAN_TO_WATCH,
key = "simkl:status:plantowatch",
title = "Plan to Watch",
trackingStatus = TrackingListStatus.PLAN_TO_WATCH,
supportedContentTypes = setOf("movie", "series", "anime"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.ON_HOLD,
key = "simkl:status:hold",
title = "On Hold",
trackingStatus = TrackingListStatus.ON_HOLD,
supportedContentTypes = setOf("series", "anime"),
),
SimklLibraryStatusDefinition(
status = SimklListStatus.COMPLETED,
key = "simkl:status:completed",
title = "Completed",
trackingStatus = TrackingListStatus.COMPLETED,
supportedContentTypes = setOf("movie", "series", "anime"),
isMembershipDestination = false,
),
SimklLibraryStatusDefinition(
status = SimklListStatus.DROPPED,
key = "simkl:status:dropped",
title = "Dropped",
trackingStatus = TrackingListStatus.DROPPED,
supportedContentTypes = setOf("movie", "series", "anime"),
),
)
internal fun SimklSyncSnapshot.toSimklLibraryProjection(): SimklLibraryProjection {
val sections = simklLibraryStatusDefinitions.mapNotNull { definition ->
val items = entries
.asSequence()
.filter { entry -> entry.status == definition.status }
.mapNotNull { entry -> entry.toLibraryItem(definition.key, lastSyncedAtEpochMs) }
.distinctBy { item -> "${item.type}:${item.id}" }
.sortedByDescending(LibraryItem::savedAtEpochMs)
.toList()
items.takeIf { projectedItems -> projectedItems.isNotEmpty() }?.let {
LibrarySection(
type = definition.key,
displayTitle = definition.title,
items = items,
)
}
}
return SimklLibraryProjection(
items = sections
.flatMap(LibrarySection::items)
.distinctBy { item -> "${item.type}:${item.id}" }
.sortedByDescending(LibraryItem::savedAtEpochMs),
sections = sections,
)
}
internal fun simklLibraryStatusDefinition(key: String): SimklLibraryStatusDefinition? =
simklLibraryStatusDefinitions.firstOrNull { definition -> definition.key == key }
internal fun simklLibraryStatusDefinition(status: TrackingListStatus): SimklLibraryStatusDefinition? =
simklLibraryStatusDefinitions.firstOrNull { definition -> definition.trackingStatus == status }
private fun SimklLibraryEntry.toLibraryItem(
listKey: String,
lastSyncedAtEpochMs: Long?,
): LibraryItem? {
val media = media ?: return null
val contentId = media.canonicalContentId() ?: return null
val simklId = media.ids.simklIdValue()?.toLongOrNull()
val entryType = when (mediaType) {
SimklMediaType.MOVIES -> "movie"
SimklMediaType.ANIME -> "anime"
SimklMediaType.SHOWS -> "series"
}
return LibraryItem(
id = contentId,
type = entryType,
name = media.title?.takeIf(String::isNotBlank) ?: contentId,
poster = simklPosterUrl(media.poster),
releaseInfo = media.year?.toString(),
posterShape = PosterShape.Poster,
listKeys = setOf(listKey),
imdbId = media.ids.idValue("imdb"),
tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(),
trackingProviderId = TrackingProviderId.SIMKL.storageId,
trackingProviderItemId = simklId?.let { "simkl:$it" },
trackingSourceUrl = buildSimklSourceUrl(mediaType, media),
savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt)
?: parseSimklUtcEpochMs(lastWatchedAt)
?: lastSyncedAtEpochMs
?: 0L,
)
}

View file

@ -0,0 +1,560 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingEpisode
import com.nuvio.app.features.tracking.TrackingExternalIds
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingHistoryWriter
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingListWriter
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingMutationResult
import com.nuvio.app.features.tracking.TrackingMutationResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.TrackingScrobbler
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.math.round
internal class SimklMutationService(
private val client: SimklApiClient,
private val onMutationCommitted: () -> Unit = {},
) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}
suspend fun moveToList(
items: Collection<TrackingMediaReference>,
destination: TrackingListStatus,
): TrackingMutationResult {
val candidates = items.validated()
if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0)
val response = client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/sync/add-to-list",
body = buildSimklListMutationBody(candidates, destination, json),
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
}
suspend fun removeFromList(items: Collection<TrackingMediaReference>): TrackingMutationResult =
removeFromHistory(items)
suspend fun addToHistory(items: Collection<TrackingHistoryItem>): TrackingMutationResult {
val candidates = items.toList().also { historyItems ->
require(historyItems.all { item -> item.media.hasResolvableIdentity }) {
"Simkl mutation requires a media ID or title for every item"
}
}
if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0)
val response = client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/sync/history",
body = buildSimklHistoryMutationBody(candidates, json),
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
}
suspend fun removeFromHistory(items: Collection<TrackingMediaReference>): TrackingMutationResult {
val candidates = items.validated()
if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0)
val response = client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/sync/history/remove",
body = buildSimklHistoryRemovalBody(candidates, json),
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
}
suspend fun scrobble(action: TrackingScrobbleAction, event: TrackingScrobbleEvent) {
require(event.media.hasResolvableIdentity) { "Simkl scrobble requires a media ID or title" }
require(event.media.kind == TrackingMediaKind.MOVIE || event.media.episode != null) {
"Simkl series scrobble requires an episode"
}
client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/scrobble/${action.wireValue}",
body = buildSimklScrobbleBody(event, json),
retryPolicy = SimklRetryPolicy.NEVER,
scrobbleStopConflictIsSuccess = action == TrackingScrobbleAction.STOP,
),
)
if (action != TrackingScrobbleAction.START) onMutationCommitted()
}
private fun Collection<TrackingMediaReference>.validated(): List<TrackingMediaReference> =
toList().also { candidates ->
require(candidates.all(TrackingMediaReference::hasResolvableIdentity)) {
"Simkl mutation requires a media ID or title for every item"
}
}
}
object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, TrackingScrobbler {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
private val service by lazy {
SimklMutationService(
client = SimklApi.client,
onMutationCommitted = {
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.MUTATION,
)
},
)
}
init {
TrackingProviderRegistry.registerListWriter(this)
TrackingProviderRegistry.registerHistoryWriter(this)
TrackingProviderRegistry.registerScrobbler(this)
}
fun ensureRegistered() = Unit
override suspend fun moveToList(
profileId: Int,
items: Collection<TrackingMediaReference>,
destination: TrackingListStatus,
): TrackingMutationResult {
if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0)
return service.moveToList(items, destination)
}
override suspend fun removeFromList(
profileId: Int,
items: Collection<TrackingMediaReference>,
): TrackingMutationResult {
if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0)
return service.removeFromList(items)
}
override suspend fun addToHistory(
profileId: Int,
items: Collection<TrackingHistoryItem>,
): TrackingMutationResult {
if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0)
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val resolved = items.map { item ->
val enriched = snapshot.enrichMediaReference(item.media)
item.copy(media = enriched.resolveAnimeEpisodeForSimkl())
}
return service.addToHistory(resolved)
}
override suspend fun removeFromHistory(
profileId: Int,
items: Collection<TrackingMediaReference>,
): TrackingMutationResult {
if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0)
return service.removeFromHistory(items)
}
override suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
) {
if (!isActiveProfile(profileId)) return
SimklSyncRepository.ensureLoaded()
val enriched = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media)
service.scrobble(
action = action,
event = event.copy(
media = enriched.resolveAnimeEpisodeForSimkl(),
),
)
}
private fun isActiveProfile(profileId: Int): Boolean = ProfileRepository.activeProfileId == profileId
}
internal fun buildSimklListMutationBody(
items: Collection<TrackingMediaReference>,
destination: TrackingListStatus,
json: Json = SimklMutationJson,
): String {
val requestItems = items.map { item ->
item to SimklListItemDto(
to = destination.wireValue,
title = item.title.nonBlankOrNull(),
year = item.year,
ids = item.ids.toSimklJsonObjectOrNull(),
)
}
return json.encodeToString(
SimklListMutationRequestDto(
movies = requestItems.filter { (item, _) -> item.kind == TrackingMediaKind.MOVIE }.map { it.second },
shows = requestItems.filter { (item, _) -> item.kind != TrackingMediaKind.MOVIE }.map { it.second },
),
)
}
internal fun buildSimklHistoryMutationBody(
items: Collection<TrackingHistoryItem>,
json: Json = SimklMutationJson,
): String = json.encodeToString(buildHistoryRequest(items, includeWatchedAt = true))
internal fun buildSimklHistoryRemovalBody(
items: Collection<TrackingMediaReference>,
json: Json = SimklMutationJson,
): String = json.encodeToString(
buildHistoryRequest(
items = items.map { media -> TrackingHistoryItem(media = media) },
includeWatchedAt = false,
),
)
internal fun buildSimklScrobbleBody(
event: TrackingScrobbleEvent,
json: Json = SimklMutationJson,
): String {
val media = event.media.toScrobbleMediaDto()
val usesTvStyleAnimeCoordinates = event.media.kind == TrackingMediaKind.ANIME &&
event.media.episode?.season != null
val request = SimklScrobbleRequestDto(
progress = event.progressPercent.clampAndRoundProgress(),
movie = media.takeIf { event.media.kind == TrackingMediaKind.MOVIE },
show = media.takeIf {
event.media.kind == TrackingMediaKind.SHOW || usesTvStyleAnimeCoordinates
},
anime = media.takeIf {
event.media.kind == TrackingMediaKind.ANIME && !usesTvStyleAnimeCoordinates
},
episode = event.media.episode?.toEpisodeDto(
includeSeason = true,
includeWatchedAt = false,
watchedAtEpochMs = null,
),
)
return json.encodeToString(request)
}
private fun buildHistoryRequest(
items: Collection<TrackingHistoryItem>,
includeWatchedAt: Boolean,
): SimklHistoryMutationRequestDto {
val movies = items
.filter { item -> item.media.kind == TrackingMediaKind.MOVIE }
.map { item ->
item.media.toHistoryItemDto(
watchedAtEpochMs = item.watchedAtEpochMs.takeIf { includeWatchedAt },
includeWatchedAt = includeWatchedAt,
)
}
val shows = items
.filter { item -> item.media.kind != TrackingMediaKind.MOVIE }
.groupBy { item -> item.media.stableKey }
.values
.map { matchingItems -> buildShowHistoryItem(matchingItems, includeWatchedAt) }
return SimklHistoryMutationRequestDto(movies = movies, shows = shows)
}
private fun buildShowHistoryItem(
items: List<TrackingHistoryItem>,
includeWatchedAt: Boolean,
): SimklHistoryItemDto {
val first = items.first()
val parentMutation = items.lastOrNull { item -> item.media.episode == null }
if (parentMutation != null) {
return parentMutation.media.toHistoryItemDto(
watchedAtEpochMs = parentMutation.watchedAtEpochMs.takeIf { includeWatchedAt },
includeWatchedAt = includeWatchedAt,
status = if (includeWatchedAt) TrackingListStatus.COMPLETED.wireValue else null,
)
}
val episodeMutations = items.mapNotNull { item ->
item.media.episode?.let { episode -> item to episode }
}
val flatEpisodes = episodeMutations
.filter { (_, episode) -> episode.season == null }
.map { (item, episode) ->
episode.toEpisodeDto(
includeSeason = false,
includeWatchedAt = includeWatchedAt,
watchedAtEpochMs = item.watchedAtEpochMs,
)
}
.distinctBy(SimklEpisodeMutationDto::number)
val seasons = episodeMutations
.filter { (_, episode) -> episode.season != null }
.groupBy { (_, episode) -> requireNotNull(episode.season) }
.map { (season, seasonItems) ->
SimklSeasonMutationDto(
number = season,
episodes = seasonItems
.map { (item, episode) ->
episode.toEpisodeDto(
includeSeason = false,
includeWatchedAt = includeWatchedAt,
watchedAtEpochMs = item.watchedAtEpochMs,
)
}
.distinctBy(SimklEpisodeMutationDto::number),
)
}
.sortedBy(SimklSeasonMutationDto::number)
return first.media.toHistoryItemDto(
watchedAtEpochMs = null,
includeWatchedAt = includeWatchedAt,
episodes = flatEpisodes,
seasons = seasons,
useTvdbAnimeSeasons = first.media.kind == TrackingMediaKind.ANIME && seasons.isNotEmpty(),
)
}
private fun TrackingMediaReference.toHistoryItemDto(
watchedAtEpochMs: Long?,
includeWatchedAt: Boolean,
status: String? = null,
episodes: List<SimklEpisodeMutationDto> = emptyList(),
seasons: List<SimklSeasonMutationDto> = emptyList(),
useTvdbAnimeSeasons: Boolean = false,
): SimklHistoryItemDto = SimklHistoryItemDto(
title = title.nonBlankOrNull(),
year = year,
ids = ids.toSimklJsonObjectOrNull(),
watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(),
status = status,
episodes = episodes,
seasons = seasons,
useTvdbAnimeSeasons = useTvdbAnimeSeasons,
)
private fun TrackingMediaReference.toScrobbleMediaDto(): SimklScrobbleMediaDto =
SimklScrobbleMediaDto(
title = title.nonBlankOrNull(),
year = year,
ids = ids.toSimklJsonObjectOrNull(),
)
private fun TrackingEpisode.toEpisodeDto(
includeSeason: Boolean,
includeWatchedAt: Boolean,
watchedAtEpochMs: Long?,
): SimklEpisodeMutationDto = SimklEpisodeMutationDto(
season = season.takeIf { includeSeason },
number = number,
watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(),
)
private fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? {
val value = buildJsonObject {
simkl?.let { put("simkl", it) }
imdb.nonBlankOrNull()?.let { put("imdb", it) }
tmdb?.let { put("tmdb", it) }
tvdb.nonBlankOrNull()?.let { tvdbValue ->
tvdbValue.toLongOrNull()?.let { put("tvdb", it) } ?: put("tvdb", tvdbValue)
}
mal?.let { put("mal", it) }
anidb?.let { put("anidb", it) }
anilist?.let { put("anilist", it) }
kitsu?.let { put("kitsu", it) }
}
return value.takeIf { it.isNotEmpty() }
}
private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult {
val payload = body
.takeIf(String::isNotBlank)
?.let { value -> runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() }
val notFound = payload
?.get("not_found")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
val notFoundCount = notFound
?.values
?.sumOf { value -> (value as? JsonArray)?.size ?: 0 }
?: 0
val added = payload
?.get("added")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
val resolutions = added?.toMutationResolutions().orEmpty()
return TrackingMutationResult(
attemptedCount = attemptedCount,
notFoundCount = notFoundCount,
resolutions = resolutions,
)
}
private fun JsonObject.toMutationResolutions(): List<TrackingMutationResolution> {
val historyResolutions = (get("statuses") as? JsonArray)
.orEmpty()
.mapNotNull { element ->
val response = runCatching { element.jsonObject["response"]?.jsonObject }.getOrNull()
?: return@mapNotNull null
response.toMutationResolution(statusKey = "status")
}
if (historyResolutions.isNotEmpty()) return historyResolutions
return entries.flatMap { (bucket, value) ->
(value as? JsonArray).orEmpty().mapNotNull { element ->
runCatching { element.jsonObject }.getOrNull()
?.toMutationResolution(statusKey = "to", fallbackKind = bucket.toTrackingMediaKind())
}
}
}
private fun JsonObject.toMutationResolution(
statusKey: String,
fallbackKind: TrackingMediaKind? = null,
): TrackingMutationResolution? {
val status = TrackingListStatus.fromWireValue(stringValue(statusKey))
val mediaKind = stringValue("simkl_type")?.toTrackingMediaKind()
?: stringValue("type")?.toTrackingMediaKind()
?: fallbackKind
val providerSubtype = stringValue("anime_type")
if (status == null && mediaKind == null && providerSubtype == null) return null
return TrackingMutationResolution(
listStatus = status,
mediaKind = mediaKind,
providerSubtype = providerSubtype,
)
}
private fun JsonObject.stringValue(key: String): String? =
runCatching { get(key)?.jsonPrimitive?.content }
.getOrNull()
?.trim()
?.takeIf(String::isNotEmpty)
private fun String.toTrackingMediaKind(): TrackingMediaKind? = when (lowercase()) {
"movie", "movies" -> TrackingMediaKind.MOVIE
"anime" -> TrackingMediaKind.ANIME
"tv", "show", "shows" -> TrackingMediaKind.SHOW
else -> null
}
private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0
private fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf(String::isNotEmpty)
private fun Long.epochMsToUtcIso(): String? {
if (this < 10_000_000_000L) return null
val totalSeconds = this / 1_000L
val second = (totalSeconds % 60L).toInt()
val minute = ((totalSeconds / 60L) % 60L).toInt()
val hour = ((totalSeconds / 3_600L) % 24L).toInt()
var days = totalSeconds / 86_400L
var year = 1970
while (true) {
val daysInYear = if (year.isLeapYear()) 366 else 365
if (days < daysInYear) break
days -= daysInYear
year += 1
}
val monthDays = if (year.isLeapYear()) {
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.toInt() + 1
return "${year.pad(4)}-${month.pad(2)}-${day.pad(2)}T${hour.pad(2)}:${minute.pad(2)}:${second.pad(2)}Z"
}
private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0
private fun Int.pad(length: Int): String = toString().padStart(length, '0')
private val SimklMutationJson = Json {
encodeDefaults = false
explicitNulls = false
}
@Serializable
private data class SimklListMutationRequestDto(
val movies: List<SimklListItemDto> = emptyList(),
val shows: List<SimklListItemDto> = emptyList(),
)
@Serializable
private data class SimklListItemDto(
val to: String,
val title: String? = null,
val year: Int? = null,
val ids: JsonObject? = null,
)
@Serializable
private data class SimklHistoryMutationRequestDto(
val movies: List<SimklHistoryItemDto> = emptyList(),
val shows: List<SimklHistoryItemDto> = emptyList(),
)
@Serializable
private data class SimklHistoryItemDto(
val title: String? = null,
val year: Int? = null,
val ids: JsonObject? = null,
@SerialName("watched_at") val watchedAt: String? = null,
val status: String? = null,
val episodes: List<SimklEpisodeMutationDto> = emptyList(),
val seasons: List<SimklSeasonMutationDto> = emptyList(),
@SerialName("use_tvdb_anime_seasons") val useTvdbAnimeSeasons: Boolean = false,
)
@Serializable
private data class SimklSeasonMutationDto(
val number: Int,
val episodes: List<SimklEpisodeMutationDto> = emptyList(),
)
@Serializable
private data class SimklEpisodeMutationDto(
val season: Int? = null,
val number: Int,
@SerialName("watched_at") val watchedAt: String? = null,
)
@Serializable
private data class SimklScrobbleRequestDto(
val progress: Double,
val movie: SimklScrobbleMediaDto? = null,
val show: SimklScrobbleMediaDto? = null,
val anime: SimklScrobbleMediaDto? = null,
val episode: SimklEpisodeMutationDto? = null,
)
@Serializable
private data class SimklScrobbleMediaDto(
val title: String? = null,
val year: Int? = null,
val ids: JsonObject? = null,
)

View file

@ -0,0 +1,51 @@
package com.nuvio.app.features.simkl
internal data class SimklPkceMaterial(
val verifier: String,
val challenge: String,
val state: String,
)
internal fun generateSimklPkceMaterial(): SimklPkceMaterial =
createSimklPkceMaterial(
verifierEntropy = SimklPkceCrypto.secureRandomBytes(32),
stateEntropy = SimklPkceCrypto.secureRandomBytes(32),
)
internal fun createSimklPkceMaterial(
verifierEntropy: ByteArray,
stateEntropy: ByteArray,
): SimklPkceMaterial {
require(verifierEntropy.size >= 32) { "PKCE verifier entropy must be at least 32 bytes" }
require(stateEntropy.size >= 16) { "OAuth state entropy must be at least 16 bytes" }
val verifier = verifierEntropy.base64UrlWithoutPadding()
require(verifier.length in 43..128) { "PKCE verifier must be 43-128 characters" }
return SimklPkceMaterial(
verifier = verifier,
challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(),
state = stateEntropy.base64UrlWithoutPadding(),
)
}
internal fun ByteArray.base64UrlWithoutPadding(): String {
if (isEmpty()) return ""
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
return buildString((size * 4 + 2) / 3) {
var index = 0
while (index < size) {
val first = this@base64UrlWithoutPadding[index].toInt() and 0xff
val second = this@base64UrlWithoutPadding.getOrNull(index + 1)?.toInt()?.and(0xff)
val third = this@base64UrlWithoutPadding.getOrNull(index + 2)?.toInt()?.and(0xff)
append(alphabet[first ushr 2])
append(alphabet[((first and 0x03) shl 4) or ((second ?: 0) ushr 4)])
if (second != null) {
append(alphabet[((second and 0x0f) shl 2) or ((third ?: 0) ushr 6)])
}
if (third != null) {
append(alphabet[third and 0x3f])
}
index += 3
}
}
}

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.simkl
internal expect object SimklPlatformClock {
fun nowEpochMs(): Long
}
internal expect object SimklPkceCrypto {
fun secureRandomBytes(size: Int): ByteArray
fun sha256(value: ByteArray): ByteArray
}
internal expect object SimklAuthStorage {
fun loadMetadataPayload(): String?
fun saveMetadataPayload(payload: String)
fun loadAccessToken(): String?
fun saveAccessToken(value: String?)
fun loadCodeVerifier(): String?
fun saveCodeVerifier(value: String?)
fun removeProfile(profileId: Int)
}

View file

@ -0,0 +1,70 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot {
if (playback.isEmpty()) return this
val watchedItems = toSimklWatchedProjection().items
val retainedPlayback = playback.filterNot { session ->
session.toWatchProgressEntry()?.let { progress ->
entries.any { entry -> entry.hidesPlayback(progress) } ||
watchedItems.any { watched -> watched.supersedes(progress) }
} == true
}
return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback)
}
internal fun SimklSyncSnapshot.isHiddenFromContinueWatching(contentId: String): Boolean =
entries.any { entry ->
entry.status.hidesContinueWatching() &&
entry.matchesContent(contentId = contentId, trackingProviderItemId = null)
}
internal fun SimklSyncSnapshot.hiddenFromContinueWatchingContentIds(): Set<String> =
entries.asSequence()
.filter { entry -> entry.status.hidesContinueWatching() }
.mapNotNull { entry -> entry.media?.canonicalContentId() }
.toSet()
internal fun SimklListStatus?.hidesContinueWatching(): Boolean =
this == SimklListStatus.ON_HOLD || this == SimklListStatus.DROPPED
private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean {
if (!type.equals(progress.contentType, ignoreCase = true)) return false
if (season != progress.seasonNumber || episode != progress.episodeNumber) return false
val sameProviderItem = trackingProviderItemId
?.takeIf(String::isNotBlank)
?.equals(progress.trackingProviderItemId, ignoreCase = true) == true
val sameContent = id.equals(progress.parentMetaId, ignoreCase = true)
return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs
}
private fun SimklLibraryEntry.hidesPlayback(progress: WatchProgressEntry): Boolean {
if (
!matchesContent(
contentId = progress.parentMetaId,
trackingProviderItemId = progress.trackingProviderItemId,
)
) {
return false
}
return when {
status.hidesContinueWatching() -> true
status == SimklListStatus.COMPLETED ->
parseSimklUtcEpochMs(lastWatchedAt)?.let { completedAt ->
completedAt >= progress.lastUpdatedEpochMs
} == true
else -> false
}
}
private fun SimklLibraryEntry.matchesContent(
contentId: String,
trackingProviderItemId: String?,
): Boolean {
val providerItemId = media?.simklTrackingProviderItemId()
val sameProviderItem = providerItemId != null &&
providerItemId.equals(trackingProviderItemId, ignoreCase = true)
return sameProviderItem || matchesContentId(contentId)
}

View file

@ -0,0 +1,481 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingCatalogReference
import com.nuvio.app.features.tracking.TrackingEpisode
import com.nuvio.app.features.tracking.TrackingExternalIds
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.extractTrackingYear
import com.nuvio.app.features.tracking.parseTrackingExternalIds
import com.nuvio.app.features.tracking.trackingMediaKind
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
internal data class SimklWatchedProjection(
val items: List<WatchedItem>,
val fullyWatchedSeriesKeys: Set<String>,
)
internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection {
val watchedItems = mutableListOf<WatchedItem>()
val fullyWatched = linkedSetOf<String>()
entries.forEach { entry ->
val media = entry.media ?: return@forEach
val contentId = media.canonicalContentId() ?: return@forEach
val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series"
val title = media.title?.takeIf(String::isNotBlank) ?: contentId
val poster = simklPosterUrl(media.poster)
val trackingProviderItemId = media.simklTrackingProviderItemId()
val trackingSourceUrl = buildSimklSourceUrl(entry.mediaType, media)
val lastWatchedAt = parseSimklUtcEpochMs(entry.lastWatchedAt)
?: parseSimklUtcEpochMs(entry.addedToWatchlistAt)
?: 0L
if (entry.mediaType == SimklMediaType.MOVIES) {
if (entry.lastWatchedAt != null || entry.status == SimklListStatus.COMPLETED) {
watchedItems += WatchedItem(
id = contentId,
type = contentType,
name = title,
poster = poster,
releaseInfo = media.year?.toString(),
trackingProviderId = TrackingProviderId.SIMKL.storageId,
trackingProviderItemId = trackingProviderItemId,
trackingSourceUrl = trackingSourceUrl,
markedAtEpochMs = lastWatchedAt,
)
}
return@forEach
}
var hasEpisodeHistory = false
entry.seasons.forEach seasonLoop@{ season ->
season.episodes.forEach episodeLoop@{ episode ->
val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop
val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop
val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop
hasEpisodeHistory = true
watchedItems += WatchedItem(
id = contentId,
type = contentType,
name = title,
poster = poster,
releaseInfo = media.year?.toString(),
season = seasonNumber,
episode = episodeNumber,
trackingProviderId = TrackingProviderId.SIMKL.storageId,
trackingProviderItemId = trackingProviderItemId,
trackingSourceUrl = trackingSourceUrl,
markedAtEpochMs = watchedAt,
)
}
}
if (entry.status == SimklListStatus.COMPLETED) {
fullyWatched += watchedItemKey(contentType, contentId)
if (!hasEpisodeHistory) {
watchedItems += WatchedItem(
id = contentId,
type = contentType,
name = title,
poster = poster,
releaseInfo = media.year?.toString(),
trackingProviderId = TrackingProviderId.SIMKL.storageId,
trackingProviderItemId = trackingProviderItemId,
trackingSourceUrl = trackingSourceUrl,
markedAtEpochMs = lastWatchedAt,
)
}
}
}
val newestByKey = watchedItems
.groupBy { item -> watchedItemKey(item.type, item.id, item.season, item.episode) }
.mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchedItem::markedAtEpochMs) }
.sortedByDescending(WatchedItem::markedAtEpochMs)
return SimklWatchedProjection(
items = newestByKey,
fullyWatchedSeriesKeys = fullyWatched,
)
}
/**
* Builds a set of extra watched keys under all alternate content IDs for anime entries.
* These are NOT added as items (to avoid CW duplicates), but are used to augment
* the watchedKeys set so the UI can resolve watched status regardless of which
* content ID the addon uses.
*/
internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set<String> {
val extraKeys = linkedSetOf<String>()
entries.forEach { entry ->
if (entry.mediaType != SimklMediaType.ANIME) return@forEach
val media = entry.media ?: return@forEach
val contentId = media.canonicalContentId() ?: return@forEach
val contentType = "series"
val alternateIds = media.alternateContentIds() - contentId
if (alternateIds.isEmpty()) return@forEach
entry.seasons.forEach { season ->
season.episodes.forEach episodeLoop@{ episode ->
val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop
val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop
if (episode.watchedAt == null) return@episodeLoop
alternateIds.forEach { altId ->
extraKeys += watchedItemKey(contentType, altId, seasonNumber, episodeNumber)
}
}
}
if (entry.status == SimklListStatus.COMPLETED) {
alternateIds.forEach { altId ->
extraKeys += watchedItemKey(contentType, altId)
}
}
}
return extraKeys
}
internal fun SimklSyncSnapshot.toSimklProgressEntries(): List<WatchProgressEntry> =
playback
.mapNotNull(SimklPlaybackSession::toWatchProgressEntry)
.groupBy(WatchProgressEntry::progressKey)
.mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) }
.sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs)
internal fun SimklSyncSnapshot.mediaReference(
contentId: String,
contentType: String,
title: String? = null,
releaseInfo: String? = null,
season: Int? = null,
episode: Int? = null,
episodeTitle: String? = null,
videoId: String? = null,
): TrackingMediaReference {
val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) }
val media = entry?.media
val parsedIds = parseTrackingExternalIds(contentId)
val ids = media?.toTrackingExternalIds()?.mergeMissing(parsedIds) ?: parsedIds
val kind = when (entry?.mediaType) {
SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE
SimklMediaType.ANIME -> TrackingMediaKind.ANIME
SimklMediaType.SHOWS -> TrackingMediaKind.SHOW
null -> trackingMediaKind(contentType, ids)
}
return TrackingMediaReference(
kind = kind,
title = media?.title?.takeIf(String::isNotBlank) ?: title?.takeIf(String::isNotBlank),
year = media?.year ?: extractTrackingYear(releaseInfo),
ids = ids,
episode = episode?.let { number ->
TrackingEpisode(season = season, number = number, title = episodeTitle)
},
catalog = TrackingCatalogReference(
contentId = contentId,
contentType = contentType,
videoId = videoId?.trim()?.takeIf(String::isNotBlank),
),
)
}
internal fun SimklSyncSnapshot.enrichMediaReference(reference: TrackingMediaReference): TrackingMediaReference {
val entry = entries.firstOrNull { candidate ->
candidate.media?.toTrackingExternalIds()?.sharesIdentityWith(reference.ids) == true
} ?: return reference
val media = entry.media ?: return reference
val kind = when (entry.mediaType) {
SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE
SimklMediaType.SHOWS -> TrackingMediaKind.SHOW
SimklMediaType.ANIME -> TrackingMediaKind.ANIME
}
return reference.copy(
kind = kind,
title = media.title?.takeIf(String::isNotBlank) ?: reference.title,
year = media.year ?: reference.year,
ids = media.toTrackingExternalIds().mergeMissing(reference.ids),
)
}
internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds(
simkl = ids.simklIdValue()?.toLongOrNull(),
imdb = ids.idValue("imdb"),
tmdb = ids.idValue("tmdb")?.toLongOrNull(),
tvdb = ids.idValue("tvdb"),
mal = ids.idValue("mal")?.toLongOrNull(),
anidb = ids.idValue("anidb")?.toLongOrNull(),
anilist = ids.idValue("anilist")?.toLongOrNull(),
kitsu = ids.idValue("kitsu")?.toLongOrNull(),
)
internal fun SimklMedia.canonicalContentId(): String? = when {
!ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb")
!ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}"
!ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}"
!ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}"
!ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}"
!ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}"
!ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}"
!ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}"
else -> null
}
/**
* Returns all content IDs (IMDB, MAL, AniDB, AniList, Kitsu, etc.) that this media entry
* is known under. Used to emit watched items under all possible IDs for anime entries so
* that the UI can find them regardless of which content ID the addon uses.
*/
private fun SimklMedia.alternateContentIds(): Set<String> = buildSet {
ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add)
ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") }
ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") }
ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") }
ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") }
ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") }
ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") }
ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") }
}
internal fun simklPosterUrl(path: String?): String? =
path
?.trim()
?.trim('/')
?.takeIf(String::isNotBlank)
?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_ca.webp&q=90" }
internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? {
val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null
val category = when (mediaType) {
SimklMediaType.MOVIES -> "movies"
SimklMediaType.SHOWS -> "tv"
SimklMediaType.ANIME -> "anime"
}
val slug = media.ids.idValue("slug")
?.trim()
?.trim('/')
?.takeIf { value -> value.isNotBlank() && value.all { it.isLetterOrDigit() || it in "-_." } }
return if (slug == null) {
"https://simkl.com/$category/$id"
} else {
"https://simkl.com/$category/$id/$slug"
}
}
internal fun parseSimklUtcEpochMs(value: String?): Long? {
val match = value?.trim()?.let(SIMKL_UTC_PATTERN::matchEntire) ?: return null
val year = match.groupValues[1].toIntOrNull() ?: return null
val month = match.groupValues[2].toIntOrNull() ?: return null
val day = match.groupValues[3].toIntOrNull() ?: return null
val hour = match.groupValues[4].toIntOrNull() ?: return null
val minute = match.groupValues[5].toIntOrNull() ?: return null
val second = match.groupValues[6].toIntOrNull() ?: return null
val millis = match.groupValues[7].padEnd(3, '0').take(3).toIntOrNull() ?: 0
if (year < 1970 || month !in 1..12 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null
val monthDays = daysInMonths(year)
if (day !in 1..monthDays[month - 1]) return null
var days = 0L
for (currentYear in 1970 until year) days += if (currentYear.isLeapYear()) 366L else 365L
for (monthIndex in 0 until month - 1) days += monthDays[monthIndex]
days += day - 1L
return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis
}
internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? {
val media = media ?: return null
val parentId = media.canonicalContentId() ?: return null
val isMovie = mediaType == SimklMediaType.MOVIES
val season = episode?.tvdbSeason ?: episode?.season
val episodeNumber = episode?.tvdbNumber ?: episode?.number
if (!isMovie && episodeNumber == null) return null
val videoId = if (isMovie) {
parentId
} else {
buildPlaybackVideoId(
parentMetaId = parentId,
seasonNumber = season,
episodeNumber = episodeNumber,
)
}
val normalizedProgress = progress.coerceIn(0.0, 100.0)
val durationMs = media.runtime?.takeIf { it > 0 }?.toLong()?.times(60_000L) ?: 0L
val positionMs = if (durationMs > 0L) (durationMs * normalizedProgress / 100.0).toLong() else 0L
val updatedAt = parseSimklUtcEpochMs(pausedAt)
?: parseSimklUtcEpochMs(watchedAt)
?: 0L
return WatchProgressEntry(
contentType = if (isMovie) "movie" else "series",
parentMetaId = parentId,
parentMetaType = if (isMovie) "movie" else "series",
videoId = videoId,
title = media.title?.takeIf(String::isNotBlank) ?: parentId,
poster = simklPosterUrl(media.poster),
seasonNumber = season,
episodeNumber = episodeNumber,
episodeTitle = episode?.title,
lastPositionMs = positionMs,
durationMs = durationMs,
lastUpdatedEpochMs = updatedAt,
isCompleted = normalizedProgress >= SIMKL_WATCHED_THRESHOLD_PERCENT,
progressPercent = normalizedProgress.toFloat(),
source = WatchProgressSourceSimklPlayback,
trackingProviderId = TrackingProviderId.SIMKL.storageId,
trackingProviderItemId = media.simklTrackingProviderItemId(),
trackingSourceUrl = buildSimklSourceUrl(mediaType, media),
progressKey = id?.let { "simkl-playback:$it" }
?: "simkl-playback:$parentId:${season ?: -1}:${episodeNumber ?: -1}",
)
}
internal fun SimklMedia.simklTrackingProviderItemId(): String? =
ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L }?.let { id -> "simkl:$id" }
internal fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean {
val media = media ?: return false
if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true
val parsed = parseTrackingExternalIds(contentId)
val candidateIds = media.toTrackingExternalIds()
return (parsed.simkl != null && parsed.simkl == candidateIds.simkl) ||
(!parsed.imdb.isNullOrBlank() && parsed.imdb.equals(candidateIds.imdb, ignoreCase = true)) ||
(parsed.tmdb != null && parsed.tmdb == candidateIds.tmdb) ||
(!parsed.tvdb.isNullOrBlank() && parsed.tvdb.equals(candidateIds.tvdb, ignoreCase = true)) ||
(parsed.mal != null && parsed.mal == candidateIds.mal) ||
(parsed.anidb != null && parsed.anidb == candidateIds.anidb) ||
(parsed.anilist != null && parsed.anilist == candidateIds.anilist) ||
(parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu)
}
private fun TrackingExternalIds.sharesIdentityWith(other: TrackingExternalIds): Boolean =
(simkl != null && simkl == other.simkl) ||
(!imdb.isNullOrBlank() && imdb.equals(other.imdb, ignoreCase = true)) ||
(tmdb != null && tmdb == other.tmdb) ||
(!tvdb.isNullOrBlank() && tvdb.equals(other.tvdb, ignoreCase = true)) ||
(mal != null && mal == other.mal) ||
(anidb != null && anidb == other.anidb) ||
(anilist != null && anilist == other.anilist) ||
(kitsu != null && kitsu == other.kitsu)
private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0
private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) {
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)
}
/**
* For anime with per-season MAL/Kitsu/AniDB/AniList video IDs, resolves both the
* anime-specific IDs and the episode number to match Simkl's anime-native format
* (Path B: flat episode numbering per MAL/Kitsu entry, no season).
*
* Example: video ID "mal:42203:7" means episode 7 of mal:42203. Even if the UI shows
* this as "Season 2 Episode 20" of the franchise, Simkl needs:
* - ids with mal=42203 (not the franchise parent mal)
* - episode number=7 (not 20)
* - no season (flat/absolute numbering)
*
* Returns the reference unchanged if:
* - Not anime
* - No catalog videoId
* - VideoId doesn't have an anime-tracker prefix with episode number
*/
internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMediaReference {
if (kind != TrackingMediaKind.ANIME) return this
val videoId = catalog?.videoId ?: return this
val parsed = parseSimklAnimeVideoId(videoId) ?: return this
val videoEpisodeNumber = parsed.episodeNumber ?: return this
// Override IDs: use ONLY the anime-specific ID from videoId.
// Clear all other IDs to prevent Simkl from matching a wrong entry
// (e.g. shared anidb/anilist IDs from the enriched parent entry).
val videoIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}")
val overriddenIds = TrackingExternalIds(
imdb = null,
tmdb = null,
tvdb = null,
trakt = null,
simkl = null,
mal = videoIds.mal,
anidb = videoIds.anidb,
anilist = videoIds.anilist,
kitsu = videoIds.kitsu,
)
// Override episode: use absolute number from videoId, drop season
return copy(
ids = overriddenIds,
episode = episode?.copy(season = null, number = videoEpisodeNumber)
?: TrackingEpisode(season = null, number = videoEpisodeNumber),
)
}
/**
* Resolve a contentId to its canonical form by finding a matching Simkl entry.
* e.g. "mal:123" finds entry with mal=123 returns "tt2560140" (its IMDB/canonical ID)
*/
internal fun SimklSyncSnapshot.resolveCanonicalContentId(contentId: String): String? {
val entry = entries.firstOrNull { it.matchesContentId(contentId) }
return entry?.media?.canonicalContentId()
}
/**
* Check if an episode is watched by resolving through the video ID.
* Parses the anime-specific ID from videoId (e.g. "mal:123:5" mal=123, ep=5),
* finds the Simkl entry, and checks if that episode number has watched_at.
*/
internal fun isWatchedByAnimeVideoId(
snapshot: SimklSyncSnapshot,
videoId: String,
episode: Int,
): Boolean {
val parsed = parseSimklAnimeVideoId(videoId) ?: return false
val parsedIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}")
val entry = snapshot.entries.firstOrNull { entry ->
entry.mediaType == SimklMediaType.ANIME &&
entry.media?.toTrackingExternalIds()?.sharesAnimeIdentityWith(parsedIds) == true
} ?: return false
val targetEpisodeNumber = parsed.episodeNumber ?: episode
return entry.seasons.any { season ->
season.episodes.any { ep ->
ep.number == targetEpisodeNumber && ep.watchedAt != null
}
}
}
private fun TrackingExternalIds.sharesAnimeIdentityWith(other: TrackingExternalIds): Boolean =
(mal != null && mal == other.mal) ||
(anidb != null && anidb == other.anidb) ||
(anilist != null && anilist == other.anilist) ||
(kitsu != null && kitsu == other.kitsu)
private val SIMKL_ANIME_VIDEO_ID_PREFIXES = setOf("mal", "anidb", "anilist", "kitsu")
private data class SimklAnimeVideoIdParts(
val prefix: String,
val id: Long,
val episodeNumber: Int?,
)
private fun parseSimklAnimeVideoId(videoId: String): SimklAnimeVideoIdParts? {
val trimmed = videoId.trim()
if (trimmed.isBlank()) return null
val prefix = trimmed.substringBefore(':').lowercase()
if (prefix !in SIMKL_ANIME_VIDEO_ID_PREFIXES) return null
val afterPrefix = trimmed.substringAfter(':', "")
val idValue = afterPrefix.substringBefore(':').toLongOrNull() ?: return null
val episodePart = afterPrefix.substringAfter(':', "").substringBefore(':')
return SimklAnimeVideoIdParts(prefix, idValue, episodePart.toIntOrNull())
}
private val SIMKL_UTC_PATTERN = Regex(
"^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$",
RegexOption.IGNORE_CASE,
)
private const val SIMKL_WATCHED_THRESHOLD_PERCENT = 80.0

View file

@ -0,0 +1,69 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES = 15
internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS =
SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES * 60L * 1_000L
internal val simklConnectionRefreshIntent = TrackingRefreshIntent.AUTOMATIC
internal val simklProgressRefreshIntent = TrackingRefreshIntent.AUTOMATIC
internal fun shouldRunSimklRefresh(
intent: TrackingRefreshIntent,
lastCheckedAtEpochMs: Long?,
nowEpochMs: Long,
hasError: Boolean,
automaticIntervalMs: Long = SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS,
): Boolean {
if (intent != TrackingRefreshIntent.AUTOMATIC) return true
if (hasError || lastCheckedAtEpochMs == null) return true
val elapsedMs = nowEpochMs - lastCheckedAtEpochMs
return elapsedMs < 0L || elapsedMs >= automaticIntervalMs
}
internal enum class SimklRefreshGateOutcome {
COALESCED,
EXECUTED,
FRESHNESS_SKIPPED,
}
/**
* Serializes provider refreshes and coalesces callers that overlap for the same profile generation.
*
* Library, watched, and progress are projections of one Simkl snapshot. They may request the same
* refresh concurrently, but only the first request should reach the network.
*/
internal class SimklRefreshGate {
private val mutex = Mutex()
private val completionSequence = atomic(0L)
private var lastCompletedProfileGeneration: Long? = null
suspend fun runIfNeeded(
profileGeneration: Long,
shouldRun: () -> Boolean,
block: suspend () -> Unit,
): SimklRefreshGateOutcome {
val observedSequence = completionSequence.value
return mutex.withLock {
if (
completionSequence.value != observedSequence &&
lastCompletedProfileGeneration == profileGeneration
) {
return@withLock SimklRefreshGateOutcome.COALESCED
}
if (!shouldRun()) return@withLock SimklRefreshGateOutcome.FRESHNESS_SKIPPED
try {
block()
SimklRefreshGateOutcome.EXECUTED
} finally {
lastCompletedProfileGeneration = profileGeneration
completionSequence.incrementAndGet()
}
}
}
}

View file

@ -0,0 +1,137 @@
package com.nuvio.app.features.simkl
internal class SimklSyncEngine(
private val remote: SimklSyncRemote,
private val nowEpochMs: () -> Long,
) {
suspend fun synchronize(current: SimklSyncSnapshot): SimklSyncSnapshot {
if (!current.isInitialized) return initialSync()
val activities = remote.fetchActivities()
if (activities.all == current.watermark) {
return current.copy(
activities = activities,
lastCheckedAtEpochMs = nowEpochMs(),
)
}
if (current.watermark == null) return initialSync()
var entries = current.entries
if (hasAllItemsActivityChanged(current.activities, activities)) {
val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark))
entries = mergeDelta(entries, delta)
}
if (hasRemovalActivityChanged(current.activities, activities)) {
val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds)
entries = reconcileRemovedEntries(entries, authoritativeIds)
}
val playback = if (hasPlaybackActivityChanged(current.activities, activities)) {
remote.fetchPlayback()
} else {
current.playback
}
val now = nowEpochMs()
return current.copy(
watermark = activities.all,
activities = activities,
entries = entries,
playback = playback,
lastSyncedAtEpochMs = now,
lastCheckedAtEpochMs = now,
).reconcileWatchedPlayback()
}
private suspend fun initialSync(): SimklSyncSnapshot {
val entries = buildList {
SimklMediaType.entries.forEach { type ->
addAll(
remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(type))
.entriesFor(type),
)
}
}
val playback = remote.fetchPlayback()
val activities = remote.fetchActivities()
val now = nowEpochMs()
return SimklSyncSnapshot(
isInitialized = true,
watermark = activities.all,
activities = activities,
entries = entries.distinctBy(SimklLibraryEntry::stableKey),
playback = playback,
lastSyncedAtEpochMs = now,
lastCheckedAtEpochMs = now,
).reconcileWatchedPlayback()
}
}
internal fun mergeDelta(
current: List<SimklLibraryEntry>,
delta: SimklAllItemsResponse,
): List<SimklLibraryEntry> {
val merged = current.mapNotNull { entry -> entry.stableKey()?.let { key -> key to entry } }.toMap().toMutableMap()
delta.presentTypes().forEach { type ->
delta.entriesFor(type).forEach { entry ->
entry.stableKey()?.let { key -> merged[key] = entry }
}
}
return merged.values.sortedWith(simklEntryComparator)
}
internal fun reconcileRemovedEntries(
current: List<SimklLibraryEntry>,
authoritative: SimklAllItemsResponse,
): List<SimklLibraryEntry> {
val allowedKeys = SimklMediaType.entries.flatMapTo(mutableSetOf()) { type ->
authoritative.entriesFor(type).mapNotNull(SimklLibraryEntry::stableKey)
}
return current.filter { entry -> entry.stableKey() in allowedKeys }
.sortedWith(simklEntryComparator)
}
private fun hasAllItemsActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean {
if (previous == null) return true
return SimklMediaType.entries.any { type ->
current.domain(type).hasAllItemsActivityChangedFrom(previous.domain(type))
}
}
private fun SimklActivityDomain.hasAllItemsActivityChangedFrom(
previous: SimklActivityDomain,
): Boolean =
ratedAt != previous.ratedAt ||
plantowatch != previous.plantowatch ||
watching != previous.watching ||
completed != previous.completed ||
hold != previous.hold ||
dropped != previous.dropped
private fun hasRemovalActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean =
previous == null ||
SimklMediaType.entries.any { type ->
previous.domain(type).removedFromList != current.domain(type).removedFromList
}
private fun hasPlaybackActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean =
previous == null ||
SimklMediaType.entries.any { type ->
previous.domain(type).playback != current.domain(type).playback
}
private val simklEntryComparator = compareBy<SimklLibraryEntry>(
{ entry -> entry.mediaType.ordinal },
{ entry -> entry.media?.title.orEmpty().lowercase() },
{ entry -> entry.stableKey().orEmpty() },
)

View file

@ -0,0 +1,197 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonPrimitive
@Serializable
enum class SimklMediaType(val apiValue: String) {
@SerialName("shows") SHOWS("shows"),
@SerialName("movies") MOVIES("movies"),
@SerialName("anime") ANIME("anime"),
}
@Serializable
enum class SimklListStatus(val apiValue: String) {
@SerialName("watching") WATCHING("watching"),
@SerialName("plantowatch") PLAN_TO_WATCH("plantowatch"),
@SerialName("hold") ON_HOLD("hold"),
@SerialName("completed") COMPLETED("completed"),
@SerialName("dropped") DROPPED("dropped"),
}
@Serializable
data class SimklMedia(
val title: String? = null,
val poster: String? = null,
val year: Int? = null,
val runtime: Int? = null,
val ids: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class SimklEpisodeMapping(
val season: Int? = null,
val episode: Int? = null,
)
@Serializable
data class SimklEpisodeIds(
@SerialName("tvdb_id") val tvdbId: Long? = null,
)
@Serializable
data class SimklEpisode(
val number: Int? = null,
@SerialName("watched_at") val watchedAt: String? = null,
val tvdb: SimklEpisodeMapping? = null,
val ids: SimklEpisodeIds? = null,
)
@Serializable
data class SimklSeason(
val number: Int? = null,
val episodes: List<SimklEpisode> = emptyList(),
)
@Serializable
data class SimklLibraryEntry(
val mediaType: SimklMediaType = SimklMediaType.SHOWS,
@SerialName("added_to_watchlist_at") val addedToWatchlistAt: String? = null,
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
@SerialName("user_rated_at") val userRatedAt: String? = null,
@SerialName("user_rating") val userRating: Int? = null,
val status: SimklListStatus? = null,
@SerialName("last_watched") val lastWatched: String? = null,
@SerialName("next_to_watch") val nextToWatch: String? = null,
@SerialName("watched_episodes_count") val watchedEpisodesCount: Int = 0,
@SerialName("total_episodes_count") val totalEpisodesCount: Int = 0,
@SerialName("not_aired_episodes_count") val notAiredEpisodesCount: Int = 0,
val show: SimklMedia? = null,
val movie: SimklMedia? = null,
@SerialName("anime_type") val animeType: String? = null,
val seasons: List<SimklSeason> = emptyList(),
) {
val media: SimklMedia?
get() = movie ?: show
fun stableKey(): String? = media?.ids?.stableMediaId()?.let { id ->
"${mediaType.apiValue}:$id"
}
}
@Serializable
data class SimklAllItemsResponse(
val shows: List<SimklLibraryEntry>? = null,
val movies: List<SimklLibraryEntry>? = null,
val anime: List<SimklLibraryEntry>? = null,
) {
fun entriesFor(type: SimklMediaType): List<SimklLibraryEntry> = when (type) {
SimklMediaType.SHOWS -> shows
SimklMediaType.MOVIES -> movies
SimklMediaType.ANIME -> anime
}.orEmpty().map { entry -> entry.copy(mediaType = type) }
fun presentTypes(): Set<SimklMediaType> = buildSet {
if (shows != null) add(SimklMediaType.SHOWS)
if (movies != null) add(SimklMediaType.MOVIES)
if (anime != null) add(SimklMediaType.ANIME)
}
}
@Serializable
data class SimklActivitySettings(
val all: String? = null,
)
@Serializable
data class SimklActivityDomain(
val all: String? = null,
@SerialName("rated_at") val ratedAt: String? = null,
val playback: String? = null,
val plantowatch: String? = null,
val watching: String? = null,
val completed: String? = null,
val hold: String? = null,
val dropped: String? = null,
@SerialName("removed_from_list") val removedFromList: String? = null,
)
@Serializable
data class SimklActivities(
val all: String? = null,
val settings: SimklActivitySettings = SimklActivitySettings(),
@SerialName("tv_shows") val tvShows: SimklActivityDomain = SimklActivityDomain(),
val movies: SimklActivityDomain = SimklActivityDomain(),
val anime: SimklActivityDomain = SimklActivityDomain(),
) {
fun domain(type: SimklMediaType): SimklActivityDomain = when (type) {
SimklMediaType.SHOWS -> tvShows
SimklMediaType.MOVIES -> movies
SimklMediaType.ANIME -> anime
}
}
@Serializable
data class SimklPlaybackEpisode(
val season: Int? = null,
val number: Int? = null,
val title: String? = null,
@SerialName("tvdb_season") val tvdbSeason: Int? = null,
@SerialName("tvdb_number") val tvdbNumber: Int? = null,
)
@Serializable
data class SimklPlaybackSession(
val id: Long? = null,
val progress: Double = 0.0,
@SerialName("paused_at") val pausedAt: String? = null,
@SerialName("watched_at") val watchedAt: String? = null,
val type: String? = null,
val episode: SimklPlaybackEpisode? = null,
val show: SimklMedia? = null,
val anime: SimklMedia? = null,
val movie: SimklMedia? = null,
) {
val media: SimklMedia?
get() = movie ?: anime ?: show
val mediaType: SimklMediaType
get() = when {
movie != null -> SimklMediaType.MOVIES
anime != null -> SimklMediaType.ANIME
else -> SimklMediaType.SHOWS
}
}
@Serializable
data class SimklSyncSnapshot(
val schemaVersion: Int = 1,
val isInitialized: Boolean = false,
val watermark: String? = null,
val activities: SimklActivities? = null,
val entries: List<SimklLibraryEntry> = emptyList(),
val playback: List<SimklPlaybackSession> = emptyList(),
val lastSyncedAtEpochMs: Long? = null,
val lastCheckedAtEpochMs: Long? = null,
)
data class SimklSyncUiState(
val snapshot: SimklSyncSnapshot = SimklSyncSnapshot(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
internal fun Map<String, JsonElement>.idValue(key: String): String? =
get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank)
internal fun Map<String, JsonElement>.simklIdValue(): String? =
idValue("simkl") ?: idValue("simkl_id")
private fun Map<String, JsonElement>.stableMediaId(): String? {
simklIdValue()?.let { return "simkl:$it" }
val keys = listOf("imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu")
return keys.firstNotNullOfOrNull { key -> idValue(key)?.let { value -> "$key:$value" } }
}

View file

@ -0,0 +1,99 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement
internal sealed interface SimklAllItemsRequest {
val type: SimklMediaType?
data class Bootstrap(
override val type: SimklMediaType,
) : SimklAllItemsRequest
data class Changes(
val dateFrom: String,
) : SimklAllItemsRequest {
override val type: SimklMediaType? = null
}
data object CurrentIds : SimklAllItemsRequest {
override val type: SimklMediaType? = null
}
}
internal interface SimklSyncRemote {
suspend fun fetchActivities(): SimklActivities
suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse
suspend fun fetchPlayback(): List<SimklPlaybackSession>
}
internal class SimklApiSyncRemote(
private val client: SimklApiClient = SimklApi.client,
) : SimklSyncRemote {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
override suspend fun fetchActivities(): SimklActivities =
client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = "/sync/activities",
),
).body.decode()
override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse {
val path = request.type?.let { type -> "/sync/all-items/${type.apiValue}" }
?: "/sync/all-items"
val query = when (request) {
is SimklAllItemsRequest.Bootstrap -> mapOf(
"extended" to "full_anime_seasons",
"episode_watched_at" to "yes",
"episode_tvdb_id" to "yes",
"include_all_episodes" to "yes",
"language" to "en",
)
is SimklAllItemsRequest.Changes -> mapOf(
"date_from" to request.dateFrom,
"extended" to "full_anime_seasons",
"episode_watched_at" to "yes",
"episode_tvdb_id" to "yes",
"include_all_episodes" to "yes",
"language" to "en",
)
SimklAllItemsRequest.CurrentIds -> mapOf(
"extended" to "simkl_ids_only",
)
}
return client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = path,
query = query,
),
).body.decodeAllItems()
}
override suspend fun fetchPlayback(): List<SimklPlaybackSession> =
client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = "/sync/playback",
),
).body.decode()
private inline fun <reified T> String.decode(): T = json.decodeFromString(this)
private fun String.decodeAllItems(): SimklAllItemsResponse {
val element = json.parseToJsonElement(this)
return when {
element is JsonNull -> SimklAllItemsResponse()
element is JsonArray && element.isEmpty() -> SimklAllItemsResponse()
else -> json.decodeFromJsonElement(element)
}
}
}

View file

@ -0,0 +1,204 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingProfileStore
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SimklSyncRepository : TrackingProfileStore {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
private val log = Logger.withTag("SimklSync")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val refreshGate = SimklRefreshGate()
private val refreshRequestSequence = atomic(0L)
private val engine = SimklSyncEngine(
remote = SimklApiSyncRemote(),
nowEpochMs = SimklPlatformClock::nowEpochMs,
)
private val _state = MutableStateFlow(SimklSyncUiState())
val state: StateFlow<SimklSyncUiState> = _state.asStateFlow()
private var hasLoaded = false
private var profileGeneration = 0L
init {
TrackingProviderRegistry.registerProfileStore(this)
}
fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val snapshot = SimklSyncStorage.loadPayload()
?.trim()
?.takeIf(String::isNotEmpty)
?.let { payload ->
runCatching { json.decodeFromString<SimklSyncSnapshot>(payload) }
.onFailure { error -> log.w { "Failed to parse Simkl sync snapshot: ${error.message}" } }
.getOrNull()
}
?: SimklSyncSnapshot()
_state.value = SimklSyncUiState(snapshot = snapshot, hasLoaded = true)
SimklWatchDiagnostics.logSnapshot(stage = "cache-load", snapshot = snapshot)
}
fun refreshAsync(intent: TrackingRefreshIntent) {
refreshAsync(intent, SimklRefreshOrigin.UNKNOWN)
}
internal fun refreshAsync(
intent: TrackingRefreshIntent,
origin: SimklRefreshOrigin,
) {
scope.launch { refresh(intent, origin) }
}
suspend fun refresh(intent: TrackingRefreshIntent): Boolean =
refresh(intent, SimklRefreshOrigin.MANUAL_SYNC)
internal suspend fun refresh(
intent: TrackingRefreshIntent,
origin: SimklRefreshOrigin,
): Boolean {
ensureLoaded()
val requestId = refreshRequestSequence.incrementAndGet()
val requestedGeneration = profileGeneration
val requestedProfileId = ProfileRepository.activeProfileId
val before = _state.value
SimklWatchDiagnostics.logRefreshRequest(
requestId = requestId,
origin = origin,
intent = intent,
profileId = requestedProfileId,
profileGeneration = requestedGeneration,
authenticated = SimklAuthRepository.isAuthenticated.value,
snapshot = before.snapshot,
errorMessage = before.errorMessage,
)
val outcome = refreshGate.runIfNeeded(
profileGeneration = requestedGeneration,
shouldRun = {
val current = _state.value
val authenticated = SimklAuthRepository.isAuthenticated.value
val nowEpochMs = SimklPlatformClock.nowEpochMs()
val eligible = requestedGeneration == profileGeneration &&
authenticated &&
shouldRunSimklRefresh(
intent = intent,
lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs,
nowEpochMs = nowEpochMs,
hasError = current.errorMessage != null,
)
SimklWatchDiagnostics.logRefreshDecision(
requestId = requestId,
origin = origin,
intent = intent,
nowEpochMs = nowEpochMs,
lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs,
authenticated = authenticated,
hasError = current.errorMessage != null,
eligible = eligible,
)
eligible
},
) {
refreshSnapshot(requestedGeneration)
}
SimklWatchDiagnostics.logRefreshCompletion(
requestId = requestId,
origin = origin,
intent = intent,
outcome = outcome,
before = before,
after = _state.value,
)
val completed = _state.value
return requestedGeneration == profileGeneration &&
requestedProfileId == ProfileRepository.activeProfileId &&
SimklAuthRepository.isAuthenticated.value &&
completed.hasLoaded &&
completed.errorMessage == null
}
private suspend fun refreshSnapshot(generation: Long) {
val profileId = ProfileRepository.activeProfileId
val previous = _state.value
_state.value = previous.copy(isLoading = true, errorMessage = null)
val result = try {
engine.synchronize(previous.snapshot)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Simkl sync failed: ${error.message}" }
if (generation == profileGeneration && profileId == ProfileRepository.activeProfileId) {
_state.value = previous.copy(
isLoading = false,
hasLoaded = true,
errorMessage = error.message ?: "Unable to sync Simkl",
)
}
return
}
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return
SimklAuthRepository.synchronizeUserSettings(result.activities?.settings?.all)
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return
SimklSyncStorage.savePayload(json.encodeToString(result))
_state.value = SimklSyncUiState(
snapshot = result,
hasLoaded = true,
)
SimklWatchDiagnostics.logSnapshot(stage = "network-commit", snapshot = result)
}
internal fun commitPlaybackRemoval(sessionIds: Set<Long>) {
if (sessionIds.isEmpty()) return
ensureLoaded()
val current = _state.value
val updatedPlayback = current.snapshot.playback.filterNot { session -> session.id in sessionIds }
if (updatedPlayback.size == current.snapshot.playback.size) return
val updatedSnapshot = current.snapshot.copy(playback = updatedPlayback)
SimklSyncStorage.savePayload(json.encodeToString(updatedSnapshot))
_state.value = current.copy(snapshot = updatedSnapshot)
SimklWatchDiagnostics.logSnapshot(stage = "playback-removal", snapshot = updatedSnapshot)
}
override fun onProfileChanged() {
profileGeneration += 1L
hasLoaded = false
_state.value = SimklSyncUiState()
ensureLoaded()
}
override fun clearLocalState() {
profileGeneration += 1L
hasLoaded = false
_state.value = SimklSyncUiState()
SimklSyncStorage.savePayload("")
}
override fun removeStoredProfile(profileId: Int) {
SimklSyncStorage.removeProfile(profileId)
}
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.simkl
internal expect object SimklSyncStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
fun removeProfile(profileId: Int)
}

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.tracking.TrackingRefreshIntent
internal enum class SimklRefreshOrigin {
AUTHORIZATION,
LIBRARY,
MANUAL_SYNC,
MUTATION,
PLAYBACK_REMOVAL,
PROGRESS,
UNKNOWN,
WATCHED_ITEMS,
WATCHED_SERIES,
}
internal object SimklWatchDiagnostics {
private val log = Logger.withTag("SimklDiag")
fun logSnapshot(
stage: String,
snapshot: SimklSyncSnapshot,
) {
val entries = snapshot.entries
val episodeRows = entries.sumOf { entry ->
entry.seasons.sumOf { season -> season.episodes.size }
}
val exactWatchedEpisodeRows = entries.sumOf { entry ->
entry.seasons.sumOf { season ->
season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() }
}
}
log.d {
"snapshot stage=$stage initialized=${snapshot.isInitialized} entries=${entries.size} " +
"movies=${entries.count { it.mediaType == SimklMediaType.MOVIES }} " +
"shows=${entries.count { it.mediaType == SimklMediaType.SHOWS }} " +
"anime=${entries.count { it.mediaType == SimklMediaType.ANIME }} " +
"completedMovies=${entries.count { it.mediaType == SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " +
"completedSeries=${entries.count { it.mediaType != SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " +
"aggregateWatchedEntries=${entries.count { it.watchedEpisodesCount > 0 }} " +
"aggregateWatchedEpisodes=${entries.sumOf { it.watchedEpisodesCount }} " +
"episodeRows=$episodeRows exactWatchedEpisodeRows=$exactWatchedEpisodeRows " +
"playbackMovies=${snapshot.playback.count { it.mediaType == SimklMediaType.MOVIES }} " +
"playbackEpisodes=${snapshot.playback.count { it.mediaType != SimklMediaType.MOVIES }} " +
"missingCanonicalIds=${entries.count { it.media?.canonicalContentId() == null }} " +
"hasWatermark=${snapshot.watermark != null} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null}"
}
}
fun logRefreshRequest(
requestId: Long,
origin: SimklRefreshOrigin,
intent: TrackingRefreshIntent,
profileId: Int,
profileGeneration: Long,
authenticated: Boolean,
snapshot: SimklSyncSnapshot,
errorMessage: String?,
) {
log.i {
"refresh request id=$requestId origin=$origin intent=$intent profile=$profileId " +
"generation=$profileGeneration authenticated=$authenticated " +
"initialized=${snapshot.isInitialized} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null} " +
"hasWatermark=${snapshot.watermark != null} hasError=${errorMessage != null}"
}
}
fun logRefreshDecision(
requestId: Long,
origin: SimklRefreshOrigin,
intent: TrackingRefreshIntent,
nowEpochMs: Long,
lastCheckedAtEpochMs: Long?,
authenticated: Boolean,
hasError: Boolean,
eligible: Boolean,
) {
log.i {
"refresh decision id=$requestId origin=$origin intent=$intent eligible=$eligible " +
"authenticated=$authenticated " +
"hasError=$hasError " +
"elapsedMs=${lastCheckedAtEpochMs?.let { nowEpochMs - it }}"
}
}
fun logRefreshCompletion(
requestId: Long,
origin: SimklRefreshOrigin,
intent: TrackingRefreshIntent,
outcome: SimklRefreshGateOutcome,
before: SimklSyncUiState,
after: SimklSyncUiState,
) {
log.i {
"refresh completion id=$requestId origin=$origin intent=$intent outcome=$outcome " +
"checkedChanged=" +
"${before.snapshot.lastCheckedAtEpochMs != after.snapshot.lastCheckedAtEpochMs} " +
"watermarkChanged=${before.snapshot.watermark != after.snapshot.watermark} " +
"entriesBefore=${before.snapshot.entries.size} entriesAfter=${after.snapshot.entries.size} " +
"playbackBefore=${before.snapshot.playback.size} playbackAfter=${after.snapshot.playback.size} " +
"loading=${after.isLoading} hasLoaded=${after.hasLoaded} hasError=${after.errorMessage != null}"
}
}
fun logProjection(
stage: String,
snapshot: SimklSyncSnapshot,
projection: SimklWatchedProjection,
) {
val items = projection.items
log.d {
"watched projection stage=$stage inputEntries=${snapshot.entries.size} " +
"inputExactEpisodeRows=${snapshot.exactWatchedEpisodeRowCount()} " +
"outputItems=${items.size} outputMovies=${items.count { it.type == "movie" }} " +
"outputEpisodes=${items.count { it.season != null && it.episode != null }} " +
"outputSeriesSummaries=${items.count { it.type == "series" && it.season == null }} " +
"fullyWatchedSeries=${projection.fullyWatchedSeriesKeys.size}"
}
}
}
private fun SimklSyncSnapshot.exactWatchedEpisodeRowCount(): Int = entries.sumOf { entry ->
entry.seasons.sumOf { season ->
season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() }
}
}

View file

@ -0,0 +1,35 @@
package com.nuvio.app.features.tracking
interface TrackingAttributedItem {
val trackingContentId: String
val trackingProviderId: String?
val trackingProviderItemId: String?
val trackingSourceUrl: String?
}
data class TrackingAttribution(
val providerId: TrackingProviderId,
val providerItemId: String?,
val sourceUrl: String,
)
internal fun resolveTrackingAttribution(
contentId: String,
providerId: TrackingProviderId,
items: Sequence<TrackingAttributedItem>,
): TrackingAttribution? = items.firstNotNullOfOrNull { item ->
val sourceUrl = item.trackingSourceUrl?.trim()?.takeIf(String::isNotEmpty)
if (
sourceUrl != null &&
item.trackingContentId.equals(contentId, ignoreCase = true) &&
TrackingProviderId.fromStorage(item.trackingProviderId) == providerId
) {
TrackingAttribution(
providerId = providerId,
providerItemId = item.trackingProviderItemId,
sourceUrl = sourceUrl,
)
} else {
null
}
}

View file

@ -0,0 +1,31 @@
package com.nuvio.app.features.tracking
data class TrackingMembershipResolution(
val providerId: TrackingProviderId,
val requestedListKey: String,
val resolvedListKey: String,
) {
val wasRewritten: Boolean
get() = requestedListKey != resolvedListKey
}
enum class TrackingMembershipRemovalImpact {
WATCHED_HISTORY,
RATING,
}
data class TrackingMembershipRemovalConfirmation(
val providerId: TrackingProviderId,
val impacts: Set<TrackingMembershipRemovalImpact>,
)
data class TrackingMembershipApplyResult(
val resolutions: List<TrackingMembershipResolution> = emptyList(),
val requiredRemovalConfirmations: List<TrackingMembershipRemovalConfirmation> = emptyList(),
) {
val rewrites: List<TrackingMembershipResolution>
get() = resolutions.filter(TrackingMembershipResolution::wasRewritten)
val requiresRemovalConfirmation: Boolean
get() = requiredRemovalConfirmations.isNotEmpty()
}

View file

@ -0,0 +1,164 @@
package com.nuvio.app.features.tracking
enum class TrackingMediaKind {
MOVIE,
SHOW,
ANIME,
}
data class TrackingExternalIds(
val imdb: String? = null,
val tmdb: Long? = null,
val tvdb: String? = null,
val trakt: Long? = null,
val simkl: Long? = null,
val mal: Long? = null,
val anidb: Long? = null,
val anilist: Long? = null,
val kitsu: Long? = null,
) {
val hasAny: Boolean
get() = !imdb.isNullOrBlank() ||
tmdb != null ||
!tvdb.isNullOrBlank() ||
trakt != null ||
simkl != null ||
mal != null ||
anidb != null ||
anilist != null ||
kitsu != null
fun mergeMissing(other: TrackingExternalIds): TrackingExternalIds =
TrackingExternalIds(
imdb = imdb?.takeIf(String::isNotBlank) ?: other.imdb,
tmdb = tmdb ?: other.tmdb,
tvdb = tvdb?.takeIf(String::isNotBlank) ?: other.tvdb,
trakt = trakt ?: other.trakt,
simkl = simkl ?: other.simkl,
mal = mal ?: other.mal,
anidb = anidb ?: other.anidb,
anilist = anilist ?: other.anilist,
kitsu = kitsu ?: other.kitsu,
)
}
data class TrackingEpisode(
val season: Int? = null,
val number: Int,
val title: String? = null,
)
data class TrackingCatalogReference(
val contentId: String,
val contentType: String,
val videoId: String? = null,
)
data class TrackingMediaReference(
val kind: TrackingMediaKind,
val title: String? = null,
val year: Int? = null,
val ids: TrackingExternalIds = TrackingExternalIds(),
val episode: TrackingEpisode? = null,
val catalog: TrackingCatalogReference? = null,
) {
val hasResolvableIdentity: Boolean
get() = ids.hasAny || !title.isNullOrBlank()
val stableKey: String
get() = buildString {
append(kind.name.lowercase())
append(':')
append(
ids.simkl?.let { "simkl:$it" }
?: ids.imdb?.let { "imdb:$it" }
?: ids.tmdb?.let { "tmdb:$it" }
?: ids.tvdb?.let { "tvdb:$it" }
?: ids.trakt?.let { "trakt:$it" }
?: ids.mal?.let { "mal:$it" }
?: ids.anidb?.let { "anidb:$it" }
?: ids.anilist?.let { "anilist:$it" }
?: ids.kitsu?.let { "kitsu:$it" }
?: "title:${title.orEmpty().trim().lowercase()}:${year ?: 0}",
)
}
}
/**
* Parses the canonical IDs Nuvio receives from addons and tracker-backed lists.
* An unprefixed number retains its legacy Trakt meaning; it is never guessed to
* be a Simkl ID.
*/
fun parseTrackingExternalIds(rawValue: String?): TrackingExternalIds {
if (rawValue.isNullOrBlank()) return TrackingExternalIds()
val full = rawValue.trim()
if (full.startsWith("tt", ignoreCase = true)) {
return TrackingExternalIds(imdb = full.substringBefore(':'))
}
val prefix = full.substringBefore(':').lowercase()
val value = full.substringAfter(':', missingDelimiterValue = "").substringBefore(':').trim()
return when (prefix) {
"imdb" -> TrackingExternalIds(imdb = value.takeIf(String::isNotBlank))
"tmdb" -> TrackingExternalIds(tmdb = value.toLongOrNull())
"tvdb" -> TrackingExternalIds(tvdb = value.takeIf(String::isNotBlank))
"trakt" -> TrackingExternalIds(trakt = value.toLongOrNull())
"simkl" -> TrackingExternalIds(simkl = value.toLongOrNull())
"mal" -> TrackingExternalIds(mal = value.toLongOrNull())
"anidb" -> TrackingExternalIds(anidb = value.toLongOrNull())
"anilist" -> TrackingExternalIds(anilist = value.toLongOrNull())
"kitsu" -> TrackingExternalIds(kitsu = value.toLongOrNull())
else -> TrackingExternalIds(trakt = full.toLongOrNull())
}
}
fun trackingMediaKind(contentType: String, ids: TrackingExternalIds = TrackingExternalIds()): TrackingMediaKind {
val normalized = contentType.trim().lowercase()
return when {
normalized in setOf("movie", "film") -> TrackingMediaKind.MOVIE
normalized == "anime" || ids.mal != null || ids.anidb != null || ids.anilist != null || ids.kitsu != null -> {
TrackingMediaKind.ANIME
}
else -> TrackingMediaKind.SHOW
}
}
fun extractTrackingYear(value: String?): Int? =
value
?.let { YEAR_PATTERN.find(it)?.value }
?.toIntOrNull()
fun buildTrackingMediaReference(
contentType: String,
parentMetaId: String,
videoId: String? = null,
title: String? = null,
releaseInfo: String? = null,
seasonNumber: Int? = null,
episodeNumber: Int? = null,
episodeTitle: String? = null,
): TrackingMediaReference {
val ids = parseTrackingExternalIds(parentMetaId)
.mergeMissing(parseTrackingExternalIds(videoId))
return TrackingMediaReference(
kind = trackingMediaKind(contentType, ids),
title = title?.trim()?.takeIf(String::isNotBlank),
year = extractTrackingYear(releaseInfo),
ids = ids,
episode = episodeNumber?.let { number ->
TrackingEpisode(
season = seasonNumber,
number = number,
title = episodeTitle,
)
},
catalog = TrackingCatalogReference(
contentId = parentMetaId.trim(),
contentType = contentType.trim(),
videoId = videoId?.trim()?.takeIf(String::isNotBlank),
),
)
}
private val YEAR_PATTERN = Regex("(19|20)\\d{2}")

View file

@ -0,0 +1,252 @@
package com.nuvio.app.features.tracking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
enum class TrackingProviderId(
val storageId: String,
) {
TRAKT("trakt"),
SIMKL("simkl");
companion object {
fun fromStorage(value: String?): TrackingProviderId? =
entries.firstOrNull { provider ->
provider.storageId.equals(value?.trim(), ignoreCase = true) ||
provider.name.equals(value?.trim(), ignoreCase = true)
}
}
}
enum class TrackingCapability {
AUTHENTICATION,
LIBRARY_READ,
LIBRARY_WRITE,
WATCHED_READ,
WATCHED_WRITE,
PROGRESS_READ,
PROGRESS_WRITE,
SCROBBLE,
COMMENTS,
RECOMMENDATIONS,
}
data class TrackingProviderDescriptor(
val id: TrackingProviderId,
val displayName: String,
val capabilities: Set<TrackingCapability>,
)
interface TrackingProfileStore {
val providerId: TrackingProviderId
fun onProfileChanged()
fun clearLocalState()
fun removeStoredProfile(profileId: Int)
}
interface TrackingAuthProvider : TrackingProfileStore {
val descriptor: TrackingProviderDescriptor
val isAuthenticated: StateFlow<Boolean>
override val providerId: TrackingProviderId
get() = descriptor.id
fun ensureLoaded()
fun handleAuthCallback(url: String): Boolean = false
}
object TrackingProviderRegistry {
private val lock = SynchronizedObject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val authProviders = mutableMapOf<TrackingProviderId, TrackingAuthProvider>()
private val authObservationJobs = mutableMapOf<TrackingProviderId, Job>()
private val profileStores = mutableSetOf<TrackingProfileStore>()
private val listWriters = mutableMapOf<TrackingProviderId, TrackingListWriter>()
private val historyWriters = mutableMapOf<TrackingProviderId, TrackingHistoryWriter>()
private val scrobblers = mutableMapOf<TrackingProviderId, TrackingScrobbler>()
private val libraryProviders = mutableMapOf<TrackingProviderId, TrackingLibraryProvider>()
private val watchedProviders = mutableMapOf<TrackingProviderId, TrackingWatchedProvider>()
private val progressProviders = mutableMapOf<TrackingProviderId, TrackingProgressProvider>()
private val _connectedProviderIds = MutableStateFlow<Set<TrackingProviderId>>(emptySet())
val connectedProviderIds: StateFlow<Set<TrackingProviderId>> = _connectedProviderIds.asStateFlow()
fun register(provider: TrackingAuthProvider) {
val previousJob = synchronized(lock) {
authProviders[provider.descriptor.id] = provider
profileStores += provider
authObservationJobs.remove(provider.descriptor.id)
}
previousJob?.cancel()
val observationJob = scope.launch {
provider.isAuthenticated.collectLatest { isAuthenticated ->
_connectedProviderIds.update { connected ->
if (isAuthenticated) connected + provider.providerId else connected - provider.providerId
}
}
}
synchronized(lock) {
authObservationJobs[provider.descriptor.id] = observationJob
}
}
fun registerProfileStore(store: TrackingProfileStore) = synchronized(lock) {
profileStores += store
}
fun registerListWriter(writer: TrackingListWriter) = synchronized(lock) {
listWriters[writer.providerId] = writer
}
fun registerHistoryWriter(writer: TrackingHistoryWriter) = synchronized(lock) {
historyWriters[writer.providerId] = writer
}
fun registerScrobbler(scrobbler: TrackingScrobbler) = synchronized(lock) {
scrobblers[scrobbler.providerId] = scrobbler
}
fun registerLibraryProvider(provider: TrackingLibraryProvider) = synchronized(lock) {
libraryProviders[provider.providerId] = provider
}
fun registerWatchedProvider(provider: TrackingWatchedProvider) = synchronized(lock) {
watchedProviders[provider.providerId] = provider
}
fun registerProgressProvider(provider: TrackingProgressProvider) = synchronized(lock) {
progressProviders[provider.providerId] = provider
}
fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) {
authProviders[id]
}
fun isAuthenticated(id: TrackingProviderId): Boolean =
authProvider(id)?.also(TrackingAuthProvider::ensureLoaded)?.isAuthenticated?.value == true
fun connectedProviderIdsSnapshot(): Set<TrackingProviderId> {
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
return providerSnapshot()
.filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value }
.mapTo(linkedSetOf()) { provider -> provider.descriptor.id }
}
fun providersWith(capability: TrackingCapability): List<TrackingAuthProvider> =
providerSnapshot()
.filter { provider -> capability in provider.descriptor.capabilities }
.sortedBy { provider -> provider.descriptor.id.ordinal }
fun listWriter(id: TrackingProviderId): TrackingListWriter? = synchronized(lock) {
listWriters[id]
}
fun historyWriter(id: TrackingProviderId): TrackingHistoryWriter? = synchronized(lock) {
historyWriters[id]
}
fun scrobbler(id: TrackingProviderId): TrackingScrobbler? = synchronized(lock) {
scrobblers[id]
}
fun libraryProvider(id: TrackingProviderId): TrackingLibraryProvider? = synchronized(lock) {
libraryProviders[id]
}
fun libraryProviders(): List<TrackingLibraryProvider> = synchronized(lock) {
libraryProviders.entries
.sortedBy { (id, _) -> id.ordinal }
.map { (_, provider) -> provider }
}
fun watchedProvider(id: TrackingProviderId): TrackingWatchedProvider? = synchronized(lock) {
watchedProviders[id]
}
fun progressProvider(id: TrackingProviderId): TrackingProgressProvider? = synchronized(lock) {
progressProviders[id]
}
fun progressProviders(): List<TrackingProgressProvider> = synchronized(lock) {
progressProviders.entries
.sortedBy { (id, _) -> id.ordinal }
.map { (_, provider) -> provider }
}
fun connectedListWriters(): List<TrackingListWriter> =
connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE)
fun connectedHistoryWriters(): List<TrackingHistoryWriter> =
connectedPorts(historyWriters, TrackingCapability.WATCHED_WRITE)
fun connectedScrobblers(): List<TrackingScrobbler> =
connectedPorts(scrobblers, TrackingCapability.SCROBBLE)
fun connectedLibraryProviders(): List<TrackingLibraryProvider> =
connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ)
fun connectedWatchedProviders(): List<TrackingWatchedProvider> =
connectedPorts(watchedProviders, TrackingCapability.WATCHED_READ)
fun connectedProgressProviders(): List<TrackingProgressProvider> =
connectedPorts(progressProviders, TrackingCapability.PROGRESS_READ)
fun handleAuthCallback(url: String): Boolean =
providersWith(TrackingCapability.AUTHENTICATION)
.any { provider -> provider.handleAuthCallback(url) }
fun ensureLoaded() {
providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded)
}
fun onProfileChanged() {
profileStoreSnapshot().forEach(TrackingProfileStore::onProfileChanged)
}
fun clearLocalState() {
profileStoreSnapshot().forEach(TrackingProfileStore::clearLocalState)
}
fun removeStoredProfiles(profileIds: Iterable<Int>) {
val stores = profileStoreSnapshot()
profileIds.forEach { profileId ->
stores.forEach { store -> store.removeStoredProfile(profileId) }
}
}
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
authProviders.values.toList()
}
private fun profileStoreSnapshot(): List<TrackingProfileStore> = synchronized(lock) {
profileStores.toList()
}
private fun <T> connectedPorts(
ports: Map<TrackingProviderId, T>,
capability: TrackingCapability,
): List<T> {
val candidates = synchronized(lock) { ports.entries.map { it.key to it.value } }
return candidates
.asSequence()
.filter { (id, _) ->
val provider = authProvider(id)
provider != null &&
capability in provider.descriptor.capabilities &&
provider.also(TrackingAuthProvider::ensureLoaded).isAuthenticated.value
}
.sortedBy { (id, _) -> id.ordinal }
.map { (_, port) -> port }
.toList()
}
}

View file

@ -0,0 +1,156 @@
package com.nuvio.app.features.tracking
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
import kotlinx.coroutines.flow.Flow
enum class TrackingLibraryTabKind {
WATCHLIST,
PERSONAL,
STATUS,
}
data class TrackingLibraryTab(
val key: String,
val title: String,
val providerId: TrackingProviderId?,
val kind: TrackingLibraryTabKind,
val selectionGroup: String? = null,
val supportedContentTypes: Set<String>? = null,
val isMembershipDestination: Boolean = true,
)
fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean =
supportedContentTypes == null || supportedContentTypes.any { supported ->
supported.equals(contentType, ignoreCase = true)
}
internal fun trackingMembershipDestinations(
tabs: List<TrackingLibraryTab>,
): List<TrackingLibraryTab> = tabs.filter(TrackingLibraryTab::isMembershipDestination)
fun toggleTrackingLibraryMembership(
tabs: List<TrackingLibraryTab>,
membership: Map<String, Boolean>,
key: String,
): Map<String, Boolean> {
val target = tabs.firstOrNull { tab -> tab.key == key } ?: return membership
val selecting = membership[key] != true
return membership.toMutableMap().apply {
if (selecting && target.selectionGroup != null) {
tabs.filter { tab ->
tab.providerId == target.providerId && tab.selectionGroup == target.selectionGroup
}.forEach { tab -> this[tab.key] = false }
}
this[key] = selecting
}
}
data class TrackingLibrarySnapshot(
val items: List<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),
val tabs: List<TrackingLibraryTab> = emptyList(),
val hasLoaded: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null,
)
/** Why a provider refresh was requested. Providers own the matching cache policy. */
enum class TrackingRefreshIntent {
/** Lifecycle, startup, reconnect, or other application-driven refresh. */
AUTOMATIC,
/** An explicit user action, such as Sync now or pull-to-refresh. */
USER_INITIATED,
/** A successful write or authentication change made the local snapshot stale. */
INVALIDATED,
}
/**
* Provider-owned projection of remote library state into application models.
*
* Application repositories consume this port through [TrackingProviderRegistry]; provider DTOs,
* list semantics, cache policy, and mutation details stay inside the provider package.
*/
interface TrackingLibraryProvider {
val providerId: TrackingProviderId
val changes: Flow<Unit>
val connectionRefreshIntent: TrackingRefreshIntent
fun ensureLoaded()
fun prepare() = Unit
fun onProfileChanged() = Unit
fun clearLocalState() = Unit
suspend fun refresh(intent: TrackingRefreshIntent)
fun snapshot(): TrackingLibrarySnapshot
fun contains(contentId: String, contentType: String? = null): Boolean
fun find(contentId: String): LibraryItem?
suspend fun membership(item: LibraryItem): Map<String, Boolean>
fun toggledDefaultMembership(currentMembership: Map<String, Boolean>): Map<String, Boolean>
fun membershipRemovalConfirmation(
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
): TrackingMembershipRemovalConfirmation? = null
suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean = false,
): TrackingMembershipResolution?
}
/** Provider adapter for watched-history projection and explicit history mutations. */
interface TrackingWatchedProvider : WatchedSyncAdapter {
val providerId: TrackingProviderId
}
data class TrackingProgressSnapshot(
val entries: List<WatchProgressEntry> = emptyList(),
val hiddenContentIds: Set<String> = emptySet(),
val hasLoadedRemoteProgress: Boolean = false,
val errorMessage: String? = null,
)
/** Provider-owned projection and policy for remote playback progress. */
interface TrackingProgressProvider {
val providerId: TrackingProviderId
val changes: Flow<Unit>
/** True when the provider projection already contains display-ready metadata. */
val providesCompleteMetadata: Boolean
get() = false
/** True when completed progress is already reconciled into watched history by the provider. */
val ownsCompletedHistoryProjection: Boolean
get() = false
/** Optional age cutoff applied to continue-watching entries and completed next-up seeds. */
fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? = null
/** Provider policy for deciding whether a progress row is a valid completed next-up seed. */
fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean =
entry.shouldUseAsCompletedSeedForContinueWatching()
/** Maps provider episode numbering into the application's metadata numbering when needed. */
suspend fun prepareNextUpProgressEntries(
entries: List<WatchProgressEntry>,
contentId: String,
): List<WatchProgressEntry> = entries
fun ensureLoaded()
fun onProfileChanged() = Unit
fun clearLocalState() = Unit
fun onActivated() = Unit
suspend fun refresh(force: Boolean, sourceChanged: Boolean)
fun snapshot(): TrackingProgressSnapshot
suspend fun removeProgress(entries: Collection<WatchProgressEntry>)
fun applyOptimisticRemoval(entries: Collection<WatchProgressEntry>) = Unit
fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit
fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId
suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit
fun isHiddenFromProgress(contentId: String): Boolean = false
}

View file

@ -0,0 +1,93 @@
package com.nuvio.app.features.tracking
import co.touchlab.kermit.Logger
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
data class TrackingScrobbleFailure(
val providerId: TrackingProviderId,
val cause: Throwable,
)
object TrackingScrobbleCoordinator {
private val log = Logger.withTag("TrackingScrobble")
suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
TrackingProviderRegistry.ensureLoaded()
val failures = dispatchTrackingScrobble(
scrobblers = TrackingProviderRegistry.connectedScrobblers(),
profileId = profileId,
action = action,
event = event,
)
failures.forEach { failure ->
log.w(failure.cause) {
"${failure.providerId.storageId} scrobble ${action.wireValue} failed"
}
}
return failures
}
suspend fun scrobbleSeek(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
TrackingProviderRegistry.ensureLoaded()
val failures = dispatchTrackingSeekScrobble(
scrobblers = TrackingProviderRegistry.connectedScrobblers(),
profileId = profileId,
action = action,
event = event,
)
failures.forEach { failure ->
log.w(failure.cause) {
"${failure.providerId.storageId} seek scrobble ${action.wireValue} failed"
}
}
return failures
}
}
internal suspend fun dispatchTrackingSeekScrobble(
scrobblers: Collection<TrackingScrobbler>,
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> = dispatchTrackingScrobble(
scrobblers = scrobblers.filter { scrobbler ->
scrobbler.seekScrobblePolicy == TrackingSeekScrobblePolicy.STOP_AND_RESTART
},
profileId = profileId,
action = action,
event = event,
)
internal suspend fun dispatchTrackingScrobble(
scrobblers: Collection<TrackingScrobbler>,
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> = supervisorScope {
scrobblers.map { scrobbler ->
async {
try {
scrobbler.scrobble(profileId = profileId, action = action, event = event)
null
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
TrackingScrobbleFailure(providerId = scrobbler.providerId, cause = error)
}
}
}.awaitAll().filterNotNull()
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.tracking
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import kotlinx.coroutines.flow.StateFlow
/**
* Provider-neutral entry point for tracking source preferences.
*
* The serialized profile payload intentionally stays in the existing Trakt settings store so
* current installations keep their choices. New application code should depend on this facade;
* the persistence implementation can then be migrated without leaking a provider name again.
*/
typealias TrackingSettingsUiState = TraktSettingsUiState
object TrackingSettingsRepository {
val uiState: StateFlow<TrackingSettingsUiState>
get() = TraktSettingsRepository.uiState
fun ensureLoaded() = TraktSettingsRepository.ensureLoaded()
fun onProfileChanged() = TraktSettingsRepository.onProfileChanged()
fun clearLocalState() = TraktSettingsRepository.clearLocalState()
fun setLibrarySourceMode(source: LibrarySourceMode) =
TraktSettingsRepository.setLibrarySourceMode(source)
fun setWatchProgressSource(source: WatchProgressSource, profileId: Int) =
TraktSettingsRepository.setWatchProgressSource(source, profileId)
fun setContinueWatchingDaysCap(days: Int) =
TraktSettingsRepository.setContinueWatchingDaysCap(days)
fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) =
TraktSettingsRepository.setMoreLikeThisSource(source)
}

View file

@ -0,0 +1,54 @@
package com.nuvio.app.features.tracking
import com.nuvio.app.features.library.LibrarySourceMode
import kotlinx.serialization.Serializable
@Serializable
enum class WatchProgressSource {
TRAKT,
SIMKL,
NUVIO_SYNC;
val providerId: TrackingProviderId?
get() = when (this) {
TRAKT -> TrackingProviderId.TRAKT
SIMKL -> TrackingProviderId.SIMKL
NUVIO_SYNC -> null
}
companion object {
fun fromStorage(value: String?): WatchProgressSource =
entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE
}
}
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT
fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE
val LibrarySourceMode.providerId: TrackingProviderId?
get() = when (this) {
LibrarySourceMode.LOCAL -> null
LibrarySourceMode.TRAKT -> TrackingProviderId.TRAKT
LibrarySourceMode.SIMKL -> TrackingProviderId.SIMKL
}
fun effectiveWatchProgressSource(
requestedSource: WatchProgressSource,
isProviderAuthenticated: (TrackingProviderId) -> Boolean,
): WatchProgressSource {
val providerId = requestedSource.providerId ?: return WatchProgressSource.NUVIO_SYNC
return requestedSource.takeIf { isProviderAuthenticated(providerId) }
?: WatchProgressSource.NUVIO_SYNC
}
fun effectiveLibrarySourceMode(
requestedSource: LibrarySourceMode,
isProviderAuthenticated: (TrackingProviderId) -> Boolean,
): LibrarySourceMode {
val providerId = requestedSource.providerId ?: return LibrarySourceMode.LOCAL
return requestedSource.takeIf { isProviderAuthenticated(providerId) }
?: LibrarySourceMode.LOCAL
}

View file

@ -0,0 +1,94 @@
package com.nuvio.app.features.tracking
enum class TrackingListStatus(val wireValue: String) {
WATCHING("watching"),
PLAN_TO_WATCH("plantowatch"),
ON_HOLD("hold"),
COMPLETED("completed"),
DROPPED("dropped");
companion object {
internal fun fromWireValue(value: String?): TrackingListStatus? =
entries.firstOrNull { status -> status.wireValue.equals(value, ignoreCase = true) }
}
}
enum class TrackingScrobbleAction(val wireValue: String) {
START("start"),
PAUSE("pause"),
STOP("stop"),
}
enum class TrackingSeekScrobblePolicy {
NONE,
STOP_AND_RESTART,
}
data class TrackingHistoryItem(
val media: TrackingMediaReference,
val watchedAtEpochMs: Long? = null,
)
data class TrackingScrobbleEvent(
val media: TrackingMediaReference,
val progressPercent: Double,
)
data class TrackingMutationResult(
val attemptedCount: Int,
val notFoundCount: Int = 0,
val resolutions: List<TrackingMutationResolution> = emptyList(),
) {
val resolvedListStatuses: List<TrackingListStatus>
get() = resolutions.mapNotNull(TrackingMutationResolution::listStatus)
val isComplete: Boolean
get() = notFoundCount == 0
}
data class TrackingMutationResolution(
val listStatus: TrackingListStatus? = null,
val mediaKind: TrackingMediaKind? = null,
val providerSubtype: String? = null,
)
interface TrackingListWriter {
val providerId: TrackingProviderId
suspend fun moveToList(
profileId: Int,
items: Collection<TrackingMediaReference>,
destination: TrackingListStatus,
): TrackingMutationResult
suspend fun removeFromList(
profileId: Int,
items: Collection<TrackingMediaReference>,
): TrackingMutationResult
}
interface TrackingHistoryWriter {
val providerId: TrackingProviderId
suspend fun addToHistory(
profileId: Int,
items: Collection<TrackingHistoryItem>,
): TrackingMutationResult
suspend fun removeFromHistory(
profileId: Int,
items: Collection<TrackingMediaReference>,
): TrackingMutationResult
}
interface TrackingScrobbler {
val providerId: TrackingProviderId
val seekScrobblePolicy: TrackingSeekScrobblePolicy
get() = TrackingSeekScrobblePolicy.NONE
suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
)
}

View file

@ -1,5 +0,0 @@
package com.nuvio.app.features.trakt
fun handleTraktAuthCallbackUrl(url: String) {
TraktAuthRepository.onAuthCallbackReceived(url)
}

View file

@ -5,6 +5,11 @@ 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.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingAuthProvider
import com.nuvio.app.features.tracking.TrackingCapability
import com.nuvio.app.features.tracking.TrackingProviderDescriptor
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.CancellationException
@ -28,7 +33,7 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.StringResource
import kotlinx.coroutines.runBlocking
object TraktAuthRepository {
object TraktAuthRepository : TrackingAuthProvider {
private const val BASE_URL = "https://api.trakt.tv"
private const val AUTHORIZE_URL = "https://trakt.tv/oauth/authorize"
private const val API_VERSION = "2"
@ -45,14 +50,43 @@ object TraktAuthRepository {
val uiState: StateFlow<TraktAuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
override val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
override val descriptor = TrackingProviderDescriptor(
id = TrackingProviderId.TRAKT,
displayName = "Trakt",
capabilities = setOf(
TrackingCapability.AUTHENTICATION,
TrackingCapability.LIBRARY_READ,
TrackingCapability.LIBRARY_WRITE,
TrackingCapability.WATCHED_READ,
TrackingCapability.WATCHED_WRITE,
TrackingCapability.PROGRESS_READ,
TrackingCapability.PROGRESS_WRITE,
TrackingCapability.SCROBBLE,
TrackingCapability.COMMENTS,
TrackingCapability.RECOMMENDATIONS,
),
)
init {
TrackingProviderRegistry.register(this)
}
private var hasLoaded = false
private var currentProfileId: Int = 1
private var profileGeneration: Long = 0L
private var authState = TraktAuthState()
fun ensureLoaded(profileId: Int = ProfileRepository.activeProfileId) {
override fun ensureLoaded() {
ensureLoaded(ProfileRepository.activeProfileId)
}
override fun onProfileChanged() {
onProfileChanged(ProfileRepository.activeProfileId)
}
fun ensureLoaded(profileId: Int) {
if (hasLoaded && currentProfileId == profileId) return
loadFromDisk(profileId)
}
@ -61,7 +95,7 @@ object TraktAuthRepository {
loadFromDisk(profileId)
}
fun clearLocalState() {
override fun clearLocalState() {
hasLoaded = false
currentProfileId = 1
profileGeneration += 1L
@ -69,6 +103,10 @@ object TraktAuthRepository {
publish()
}
override fun removeStoredProfile(profileId: Int) {
TraktAuthStorage.removeProfile(profileId)
}
fun snapshot(profileId: Int = ProfileRepository.activeProfileId): TraktAuthUiState {
ensureLoaded(profileId)
return _uiState.value
@ -133,6 +171,15 @@ object TraktAuthRepository {
}
}
override fun handleAuthCallback(url: String): Boolean {
if (!isTraktAuthCallback(url)) return false
onAuthCallbackReceived(url)
return true
}
private fun isTraktAuthCallback(url: String): Boolean =
url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?")
suspend fun authorizedHeaders(profileId: Int = currentProfileId): Map<String, String>? {
ensureLoaded(profileId)
if (!authState.isAuthenticated) return null

View file

@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt
internal expect object TraktAuthStorage {
fun loadPayload(profileId: Int): String?
fun savePayload(profileId: Int, payload: String)
fun removeProfile(profileId: Int)
}

View file

@ -197,16 +197,6 @@ object TraktLibraryRepository {
return TraktMembershipSnapshot(listMembership = map)
}
suspend fun toggleWatchlist(item: LibraryItem) {
ensureLoaded()
val snapshot = getMembershipSnapshot(item)
val currentlyInWatchlist = snapshot.listMembership[WATCHLIST_KEY] == true
val desired = snapshot.listMembership.toMutableMap().apply {
this[WATCHLIST_KEY] = !currentlyInWatchlist
}
applyMembershipChanges(item, TraktMembershipChanges(desiredMembership = desired))
}
suspend fun applyMembershipChanges(item: LibraryItem, changes: TraktMembershipChanges) {
ensureLoaded()
val headers = TraktAuthRepository.authorizedHeaders() ?: return

View file

@ -4,6 +4,14 @@ 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 com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.TrackingScrobbler
import com.nuvio.app.features.tracking.TrackingSeekScrobblePolicy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
@ -39,7 +47,38 @@ internal sealed interface TraktScrobbleItem {
}
}
internal object TraktScrobbleRepository {
internal data class TraktEpisodeMappingInput(
val contentId: String,
val contentType: String,
val videoId: String?,
val season: Int,
val episode: Int,
val episodeTitle: String?,
)
internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMappingInput? {
val episodeReference = episode ?: return null
val season = episodeReference.season ?: return null
val contentId = catalog?.contentId?.takeIf(String::isNotBlank)
?: ids.imdb?.takeIf(String::isNotBlank)
?: ids.tmdb?.let { value -> "tmdb:$value" }
?: ids.trakt?.let { value -> "trakt:$value" }
?: return null
return TraktEpisodeMappingInput(
contentId = contentId,
contentType = catalog?.contentType?.takeIf(String::isNotBlank) ?: "series",
videoId = catalog?.videoId?.takeIf(String::isNotBlank),
season = season,
episode = episodeReference.number,
episodeTitle = episodeReference.title,
)
}
internal object TraktScrobbleRepository : TrackingScrobbler {
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
override val seekScrobblePolicy: TrackingSeekScrobblePolicy =
TrackingSeekScrobblePolicy.STOP_AND_RESTART
private data class ScrobbleStamp(
val profileId: Int,
val action: String,
@ -62,6 +101,26 @@ internal object TraktScrobbleRepository {
private val retryDelayMs = 1_500L
private val serverOverloadedRetryDelayMs = 5_000L
init {
TrackingProviderRegistry.registerScrobbler(this)
}
fun ensureRegistered() = Unit
override suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
) {
val item = buildItem(event.media) ?: return
sendScrobble(
profileId = profileId,
action = action.wireValue,
item = item,
progressPercent = event.progressPercent.toFloat(),
)
}
suspend fun scrobbleStart(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) {
sendScrobble(profileId = profileId, action = "start", item = item, progressPercent = progressPercent)
}
@ -126,6 +185,40 @@ internal object TraktScrobbleRepository {
}
}
private suspend fun buildItem(media: TrackingMediaReference): TraktScrobbleItem? {
val ids = TraktExternalIds(
trakt = media.ids.trakt?.toTraktIntOrNull(),
imdb = media.ids.imdb?.takeIf(String::isNotBlank),
tmdb = media.ids.tmdb?.toTraktIntOrNull(),
)
if (!ids.hasAnyId()) return null
if (media.kind == TrackingMediaKind.MOVIE) {
return TraktScrobbleItem.Movie(
title = media.title,
year = media.year,
ids = ids,
)
}
val mappingInput = media.toTraktEpisodeMappingInput() ?: return null
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = mappingInput.contentId,
contentType = mappingInput.contentType,
videoId = mappingInput.videoId,
season = mappingInput.season,
episode = mappingInput.episode,
episodeTitle = mappingInput.episodeTitle,
)
return TraktScrobbleItem.Episode(
showTitle = media.title,
showYear = media.year,
showIds = ids,
season = mappedEpisode?.season ?: mappingInput.season,
number = mappedEpisode?.episode ?: mappingInput.episode,
episodeTitle = mappingInput.episodeTitle,
)
}
private suspend fun sendScrobble(
profileId: Int,
action: String,
@ -325,6 +418,8 @@ internal object TraktScrobbleRepository {
}
}
private fun Long.toTraktIntOrNull(): Int? = takeIf { value -> value in 1L..Int.MAX_VALUE.toLong() }?.toInt()
@Serializable
private data class TraktScrobbleRequest(
@SerialName("movie") val movie: TraktMovieBody? = null,

View file

@ -12,6 +12,16 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
typealias WatchProgressSource = com.nuvio.app.features.tracking.WatchProgressSource
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource =
com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE
val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode =
com.nuvio.app.features.tracking.DEFAULT_LIBRARY_SOURCE_MODE
fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
com.nuvio.app.features.tracking.librarySourceModeFromStorage(value)
const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0
const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7
@ -27,23 +37,6 @@ val TraktContinueWatchingDaysOptions: List<Int> = listOf(
TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
)
@Serializable
enum class WatchProgressSource {
TRAKT,
NUVIO_SYNC;
companion object {
fun fromStorage(value: String?): WatchProgressSource =
entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE
}
}
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT
fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE
@Serializable
enum class MoreLikeThisSourcePreference {
TRAKT,

View file

@ -0,0 +1,110 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.tracking.TrackingLibraryProvider
import com.nuvio.app.features.tracking.TrackingLibrarySnapshot
import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingMembershipResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
object TraktTrackingLibraryProvider : TrackingLibraryProvider {
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
override val changes: Flow<Unit> = TraktLibraryRepository.uiState.map { Unit }
override val connectionRefreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.INVALIDATED
override fun ensureLoaded() = TraktLibraryRepository.ensureLoaded()
override fun prepare() = TraktLibraryRepository.preloadListTabsAsync()
override fun onProfileChanged() = TraktLibraryRepository.onProfileChanged()
override fun clearLocalState() = TraktLibraryRepository.clearLocalState()
override suspend fun refresh(intent: TrackingRefreshIntent) = when (intent) {
TrackingRefreshIntent.AUTOMATIC -> TraktLibraryRepository.ensureFresh()
TrackingRefreshIntent.USER_INITIATED,
TrackingRefreshIntent.INVALIDATED,
-> TraktLibraryRepository.refreshNow()
}
override fun snapshot(): TrackingLibrarySnapshot {
val state = TraktLibraryRepository.uiState.value
return TrackingLibrarySnapshot(
items = state.allItems,
sections = state.listTabs.mapNotNull { tab ->
state.entriesByList[tab.key]
.orEmpty()
.takeIf(List<LibraryItem>::isNotEmpty)
?.let { items ->
LibrarySection(
type = tab.key,
displayTitle = tab.title,
items = items,
)
}
},
tabs = state.listTabs.map(TraktListTab::toTrackingLibraryTab),
hasLoaded = state.hasLoaded,
isLoading = state.isLoading,
errorMessage = state.errorMessage,
)
}
override fun contains(contentId: String, contentType: String?): Boolean {
if (contentType != null) return TraktLibraryRepository.isInAnyList(contentId, contentType)
val item = find(contentId) ?: return false
return TraktLibraryRepository.isInAnyList(item.id, item.type)
}
override fun find(contentId: String): LibraryItem? =
TraktLibraryRepository.uiState.value.allItems.firstOrNull { item -> item.id == contentId }
override suspend fun membership(item: LibraryItem): Map<String, Boolean> =
TraktLibraryRepository.getMembershipSnapshot(item).listMembership
override fun toggledDefaultMembership(
currentMembership: Map<String, Boolean>,
): Map<String, Boolean> {
val watchlistKey = snapshot().tabs
.firstOrNull { tab -> tab.kind == TrackingLibraryTabKind.WATCHLIST }
?.key
?: return currentMembership
return toggledTraktWatchlistMembership(currentMembership, watchlistKey)
}
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
destructiveRemovalConfirmed: Boolean,
): TrackingMembershipResolution? {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
)
return null
}
}
internal fun toggledTraktWatchlistMembership(
currentMembership: Map<String, Boolean>,
watchlistKey: String,
): Map<String, Boolean> = currentMembership.toMutableMap().apply {
this[watchlistKey] = currentMembership[watchlistKey] != true
}
private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab =
TrackingLibraryTab(
key = key,
title = title,
providerId = TrackingProviderId.TRAKT,
kind = when (type) {
TraktListType.WATCHLIST -> TrackingLibraryTabKind.WATCHLIST
TraktListType.PERSONAL -> TrackingLibraryTabKind.PERSONAL
},
)

View file

@ -0,0 +1,143 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProgressSnapshot
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
object TraktTrackingProgressProvider : TrackingProgressProvider {
private val log = Logger.withTag("TraktProgressPort")
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
override val changes: Flow<Unit> = TraktProgressRepository.uiState.map { Unit }
override val providesCompleteMetadata: Boolean = true
override val ownsCompletedHistoryProjection: Boolean = true
override fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? {
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null
return nowEpochMs - normalizedDaysCap.toLong() * MILLIS_PER_DAY
}
override fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean {
if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false
if (entry.source != WatchProgressSourceTraktPlayback) return true
val explicitPercent = entry.normalizedProgressPercent ?: return false
if (explicitPercent < TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD) return false
val ageMs = nowEpochMs - entry.lastUpdatedEpochMs
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
}
override suspend fun prepareNextUpProgressEntries(
entries: List<WatchProgressEntry>,
contentId: String,
): List<WatchProgressEntry> = entries.map { entry ->
if (entry.parentMetaId != contentId) {
entry
} else {
val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = entry.parentMetaId,
contentType = entry.contentType,
season = entry.seasonNumber,
episode = entry.episodeNumber,
episodeTitle = entry.episodeTitle,
)
if (mapping == null) {
entry
} else {
entry.copy(
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
videoId = buildPlaybackVideoId(
parentMetaId = entry.parentMetaId,
seasonNumber = mapping.season,
episodeNumber = mapping.episode,
fallbackVideoId = entry.videoId,
),
episodeTitle = mapping.title ?: entry.episodeTitle,
)
}
}
}
override fun ensureLoaded() = TraktProgressRepository.ensureLoaded()
override fun onProfileChanged() = TraktProgressRepository.onProfileChanged()
override fun clearLocalState() = TraktProgressRepository.clearLocalState()
override fun onActivated() = TraktProgressRepository.clearLocalState()
override suspend fun refresh(force: Boolean, sourceChanged: Boolean) {
if (force || sourceChanged) {
TraktProgressRepository.invalidateAndRefresh()
} else {
TraktProgressRepository.refreshNow()
}
}
override fun snapshot(): TrackingProgressSnapshot {
val state = TraktProgressRepository.uiState.value
return TrackingProgressSnapshot(
entries = state.entries,
hasLoadedRemoteProgress = state.hasLoadedRemoteProgress,
errorMessage = state.errorMessage,
)
}
override suspend fun removeProgress(entries: Collection<WatchProgressEntry>) {
entries
.filter { entry -> isTraktCompatibleId(entry.parentMetaId) }
.distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) }
.forEach { entry ->
try {
TraktProgressRepository.removeProgress(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to remove Trakt progress for ${entry.parentMetaId}" }
}
}
}
override fun applyOptimisticRemoval(entries: Collection<WatchProgressEntry>) {
entries
.distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) }
.forEach { entry ->
TraktProgressRepository.applyOptimisticRemoval(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
}
}
override fun applyOptimisticProgress(entry: WatchProgressEntry) =
TraktProgressRepository.applyOptimisticProgress(entry)
override fun normalizeParentContentId(parentContentId: String, videoId: String?): String =
resolveEffectiveContentId(parentContentId, videoId)
override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) =
TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh)
override fun isHiddenFromProgress(contentId: String): Boolean =
TraktProgressRepository.isShowHiddenFromProgress(contentId)
}
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1_000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1_000L
private const val TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD = 95f

View file

@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem(
releaseInfo = releaseInfo,
season = video.season,
episode = video.episode,
videoId = video.id,
markedAtEpochMs = markedAtEpochMs,
)

View file

@ -1,7 +1,8 @@
package com.nuvio.app.features.watched
import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.tracking.TrackingAttributedItem
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.watchedKey
import kotlinx.serialization.Serializable
@ -15,8 +16,15 @@ data class WatchedItem(
val releaseInfo: String? = null,
val season: Int? = null,
val episode: Int? = null,
val videoId: String? = null,
override val trackingProviderId: String? = null,
override val trackingProviderItemId: String? = null,
override val trackingSourceUrl: String? = null,
val markedAtEpochMs: Long,
)
) : TrackingAttributedItem {
override val trackingContentId: String
get() = id
}
data class WatchedUiState(
val items: List<WatchedItem> = emptyList(),
@ -72,7 +80,7 @@ internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long {
append(second.toString().padStart(2, '0'))
append('Z')
}
return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value
return parseZonedIsoDateTimeToEpochMs(iso) ?: value
}
fun watchedItemKey(

View file

@ -3,15 +3,17 @@ package com.nuvio.app.features.watched
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.tracking.providerId
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedDeltaEvent
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
import kotlinx.atomicfu.locks.SynchronizedObject
@ -24,6 +26,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@ -40,15 +45,20 @@ private data class StoredWatchedPayload(
val dirtyWatchedKeys: Set<String> = emptySet(),
)
internal enum class WatchedTraktHistorySync {
internal enum class WatchedTrackerHistorySync {
Mirror,
Skip,
}
internal fun shouldMirrorWatchedMarkToTraktHistory(
sync: WatchedTraktHistorySync,
isTraktAuthenticated: Boolean,
): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated
internal data class WatchedPushOutcome(
val nuvioSyncSucceeded: Boolean = false,
val succeededTrackerProviderIds: Set<TrackingProviderId> = emptySet(),
)
internal fun shouldMirrorWatchedMarkToTrackers(
sync: WatchedTrackerHistorySync,
hasConnectedTracker: Boolean,
): Boolean = sync == WatchedTrackerHistorySync.Mirror && hasConnectedTracker
internal data class WatchedSourceOperation(
val source: WatchProgressSource,
@ -64,25 +74,28 @@ internal fun isWatchedSourceOperationCurrent(
internal fun watchedItemsForSource(
source: WatchProgressSource,
nuvioItems: Collection<WatchedItem>,
traktItems: Collection<WatchedItem>,
): Collection<WatchedItem> = when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioItems
WatchProgressSource.TRAKT -> traktItems
}
providerItems: Map<TrackingProviderId, Collection<WatchedItem>>,
): Collection<WatchedItem> = source.providerId
?.let { providerId -> providerItems[providerId].orEmpty() }
?: nuvioItems
internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean =
source == WatchProgressSource.NUVIO_SYNC
source.providerId == null
internal fun shouldAcknowledgeNuvioWatchedPush(
source: WatchProgressSource,
outcome: WatchedPushOutcome,
): Boolean = shouldPersistWatchedSource(source) && outcome.nuvioSyncSucceeded
internal fun replaceWatchedItemsForSource(
source: WatchProgressSource,
nuvioItems: MutableMap<String, WatchedItem>,
traktItems: MutableMap<String, WatchedItem>,
providerItems: MutableMap<TrackingProviderId, MutableMap<String, WatchedItem>>,
replacement: Map<String, WatchedItem>,
) {
val target = when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioItems
WatchProgressSource.TRAKT -> traktItems
}
val target = source.providerId
?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) }
?: nuvioItems
target.clear()
target.putAll(replacement)
}
@ -98,6 +111,7 @@ object WatchedRepository {
private const val watchedItemsDeltaPageSize = 900
private const val watchedDeltaOperationUpsert = "upsert"
private const val watchedDeltaOperationDelete = "delete"
private const val watchedDiagnosticSampleLimit = 10
private val accountScopeLock = SynchronizedObject()
private var accountScopeJob: Job = SupervisorJob()
@ -119,32 +133,35 @@ object WatchedRepository {
private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC
private var sourceGeneration: Long = 0L
private var nuvioItemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
private var traktItemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
private var providerItemsByKey: MutableMap<TrackingProviderId, MutableMap<String, WatchedItem>> = mutableMapOf()
private var nuvioFullyWatchedSeriesKeys: Set<String> = emptySet()
private var traktFullyWatchedSeriesKeys: Set<String> = emptySet()
private var providerFullyWatchedSeriesKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var providerExtraWatchedKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var nuvioHasLoaded: Boolean = false
private var traktHasLoaded: Boolean = false
private var loadedProviders: MutableSet<TrackingProviderId> = mutableSetOf()
private var nuvioHasLoadedRemote: Boolean = false
private var traktHasLoadedRemote: Boolean = false
private var providersLoadedFromRemote: MutableSet<TrackingProviderId> = mutableSetOf()
private var nuvioDirtyWatchedKeys: MutableSet<String> = mutableSetOf()
private var lastSuccessfulPushEpochMs: Long = 0L
private var deltaCursorEventId: Long = 0L
private var deltaInitialized: Boolean = false
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter
private var extraKeysObserverJob: Job? = null
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
if (!hasLoaded) {
loadFromDisk(ProfileRepository.activeProfileId)
activateEffectiveSource(
effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = connectedWatchedProviderIds(),
),
)
}
startExtraKeysObserverIfNeeded()
}
fun onProfileChanged(profileId: Int) {
@ -160,19 +177,21 @@ object WatchedRepository {
}
}
previousAccountJob.cancel()
extraKeysObserverJob = null
hasLoaded = false
currentProfileId = 1
profileGeneration += 1L
activeSource = WatchProgressSource.NUVIO_SYNC
sourceGeneration += 1L
nuvioItemsByKey.clear()
traktItemsByKey.clear()
providerItemsByKey.clear()
nuvioFullyWatchedSeriesKeys = emptySet()
traktFullyWatchedSeriesKeys = emptySet()
providerFullyWatchedSeriesKeys.clear()
providerExtraWatchedKeys.clear()
nuvioHasLoaded = false
traktHasLoaded = false
loadedProviders.clear()
nuvioHasLoadedRemote = false
traktHasLoadedRemote = false
providersLoadedFromRemote.clear()
nuvioDirtyWatchedKeys.clear()
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
@ -188,13 +207,14 @@ object WatchedRepository {
sourceGeneration += 1L
hasLoaded = true
nuvioItemsByKey.clear()
traktItemsByKey.clear()
providerItemsByKey.clear()
nuvioFullyWatchedSeriesKeys = emptySet()
traktFullyWatchedSeriesKeys = emptySet()
providerFullyWatchedSeriesKeys.clear()
providerExtraWatchedKeys.clear()
nuvioHasLoaded = true
traktHasLoaded = false
loadedProviders.clear()
nuvioHasLoadedRemote = false
traktHasLoadedRemote = false
providersLoadedFromRemote.clear()
nuvioDirtyWatchedKeys.clear()
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
@ -231,18 +251,32 @@ object WatchedRepository {
}
private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource {
if (activeSource == source) return source
if (source == WatchProgressSource.TRAKT) {
traktItemsByKey.clear()
traktFullyWatchedSeriesKeys = emptySet()
traktHasLoaded = false
traktHasLoadedRemote = false
} else {
if (activeSource == source) {
log.i {
"Watched source activation unchanged source=$source generation=$sourceGeneration " +
"items=${itemCountForSource(source)} loaded=${hasLoadedSource(source)}"
}
return source
}
val previousSource = activeSource
source.providerId?.let { providerId ->
providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear()
providerFullyWatchedSeriesKeys[providerId] = emptySet()
providerExtraWatchedKeys.remove(providerId)
loadedProviders -= providerId
providersLoadedFromRemote -= providerId
} ?: run {
nuvioHasLoadedRemote = false
}
activeSource = source
sourceGeneration += 1L
stopExtraKeysObserver()
log.i {
"Watched source activated previous=$previousSource current=$source generation=$sourceGeneration " +
"provider=${source.providerId?.storageId}"
}
publish()
startExtraKeysObserverIfNeeded()
return source
}
@ -270,26 +304,26 @@ object WatchedRepository {
)
suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = connectedWatchedProviderIds(),
),
forceSnapshot = false,
)
}
suspend fun forceSnapshotRefreshFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = connectedWatchedProviderIds(),
),
forceSnapshot = true,
)
@ -300,8 +334,8 @@ object WatchedRepository {
source: WatchProgressSource,
forceSnapshot: Boolean = true,
): Boolean {
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
if (ProfileRepository.activeProfileId != profileId) {
log.d { "Skipping watched refresh for inactive profile $profileId" }
return false
@ -312,7 +346,12 @@ object WatchedRepository {
val effectiveSource = activateEffectiveSource(source)
val operation = newRefreshOperation(profileId) ?: return false
if (effectiveSource == WatchProgressSource.NUVIO_SYNC) {
log.i {
"Watched refresh request profile=$profileId requestedSource=$source effectiveSource=$effectiveSource " +
"forceSnapshot=$forceSnapshot profileGeneration=$profileGeneration " +
"sourceGeneration=$sourceGeneration"
}
if (effectiveSource.providerId == null) {
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) {
// Local watched state is authoritative when this account has no Nuvio upstream.
@ -323,14 +362,19 @@ object WatchedRepository {
}
}
return try {
if (effectiveSource == WatchProgressSource.TRAKT) {
effectiveSource.providerId?.let { providerId ->
val provider = TrackingProviderRegistry.watchedProvider(providerId)
?: run {
log.w { "Watched provider missing provider=${providerId.storageId} source=$effectiveSource" }
return false
}
pullSnapshotFromAdapter(
adapter = traktSyncAdapter,
adapter = provider,
operation = operation,
profileId = profileId,
resetDeltaState = true,
)
} else if (forceSnapshot) {
} ?: if (forceSnapshot) {
refreshNuvioSnapshot(
operation = operation,
profileId = profileId,
@ -387,7 +431,27 @@ object WatchedRepository {
profileId = profileId,
pageSize = watchedItemsPageSize,
)
if (!isActiveOperation(operation)) return false
val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId)
val source = operation.sourceOperation.source
log.i {
"Watched adapter result source=$source provider=${source.providerId?.storageId ?: "nuvio"} " +
"profile=$profileId serverItems=${serverItems.size} " +
"movies=${serverItems.count { it.type == "movie" }} " +
"episodes=${serverItems.count { it.season != null && it.episode != null }} " +
"seriesSummaries=${serverItems.count { it.type != "movie" && it.season == null }} " +
"fullyWatchedSeries=${fullyWatchedSeriesKeys?.size} " +
"itemKeys=[${diagnosticItemKeySample(serverItems)}] " +
"fullyWatchedKeys=[${fullyWatchedSeriesKeys.orEmpty().take(watchedDiagnosticSampleLimit).joinToString(",")}]"
}
if (!isActiveOperation(operation)) {
log.w {
"Watched adapter result discarded source=$source profile=$profileId " +
"operationProfileGeneration=${operation.profileGeneration} currentProfileGeneration=$profileGeneration " +
"operationSourceGeneration=${operation.sourceOperation.generation} currentSourceGeneration=$sourceGeneration " +
"activeSource=$activeSource"
}
return false
}
val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList()
val mergedSnapshot = mergeWatchedSnapshot(
@ -402,22 +466,26 @@ object WatchedRepository {
replaceWatchedItemsForSource(
source = operation.sourceOperation.source,
nuvioItems = nuvioItemsByKey,
traktItems = traktItemsByKey,
providerItems = providerItemsByKey,
replacement = mergedSnapshot.items,
)
when (operation.sourceOperation.source) {
WatchProgressSource.NUVIO_SYNC -> {
nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet()
nuvioHasLoaded = true
nuvioHasLoadedRemote = true
if (resetDeltaState) {
deltaCursorEventId = 0L
deltaInitialized = false
}
fullyWatchedSeriesKeys?.let { keys ->
setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys)
}
val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId)
operation.sourceOperation.source.providerId?.let { providerId ->
if (extraWatchedKeys.isNotEmpty()) {
providerExtraWatchedKeys[providerId] = extraWatchedKeys
}
WatchProgressSource.TRAKT -> {
traktHasLoaded = true
traktHasLoadedRemote = true
loadedProviders += providerId
providersLoadedFromRemote += providerId
} ?: run {
nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet()
nuvioHasLoaded = true
nuvioHasLoadedRemote = true
if (resetDeltaState) {
deltaCursorEventId = 0L
deltaInitialized = false
}
}
publish()
@ -601,32 +669,32 @@ object WatchedRepository {
}
private fun itemsForSource(source: WatchProgressSource): MutableMap<String, WatchedItem> =
when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioItemsByKey
WatchProgressSource.TRAKT -> traktItemsByKey
}
source.providerId
?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf) }
?: nuvioItemsByKey
private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set<String> =
when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys
WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys
}
source.providerId
?.let { providerId -> providerFullyWatchedSeriesKeys[providerId].orEmpty() }
?: nuvioFullyWatchedSeriesKeys
private fun setFullyWatchedSeriesKeysForSource(
source: WatchProgressSource,
keys: Set<String>,
) {
when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys = keys
WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys = keys
source.providerId?.let { providerId ->
providerFullyWatchedSeriesKeys[providerId] = keys
} ?: run {
nuvioFullyWatchedSeriesKeys = keys
}
}
private fun hasLoadedSource(source: WatchProgressSource): Boolean =
when (source) {
WatchProgressSource.NUVIO_SYNC -> nuvioHasLoaded
WatchProgressSource.TRAKT -> traktHasLoaded
}
source.providerId?.let(loadedProviders::contains) ?: nuvioHasLoaded
private fun itemCountForSource(source: WatchProgressSource): Int = source.providerId
?.let { providerId -> providerItemsByKey[providerId]?.size ?: 0 }
?: nuvioItemsByKey.size
fun toggleWatched(item: WatchedItem) {
ensureLoaded()
@ -645,16 +713,20 @@ object WatchedRepository {
}
fun markWatched(items: Collection<WatchedItem>) {
markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror)
markWatched(items = items, trackerHistorySync = WatchedTrackerHistorySync.Mirror)
}
internal fun markWatchedFromPlaybackCompletion(item: WatchedItem, syncRemote: Boolean = true) {
markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip, syncRemote = syncRemote)
markWatched(
items = listOf(item),
trackerHistorySync = WatchedTrackerHistorySync.Skip,
syncRemote = syncRemote,
)
}
private fun markWatched(
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
trackerHistorySync: WatchedTrackerHistorySync,
syncRemote: Boolean = true,
) {
ensureLoaded()
@ -668,7 +740,7 @@ object WatchedRepository {
timestampedItems.forEach { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems[key] = watchedItem
if (source == WatchProgressSource.NUVIO_SYNC) {
if (source.providerId == null) {
nuvioDirtyWatchedKeys += key
}
}
@ -679,7 +751,7 @@ object WatchedRepository {
if (syncRemote) {
pushMarksToServer(
items = timestampedItems,
traktHistorySync = traktHistorySync,
trackerHistorySync = trackerHistorySync,
source = source,
)
}
@ -716,10 +788,25 @@ object WatchedRepository {
val targetItems = itemsForSource(source)
val removedItems = items.mapNotNull { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems.remove(key)?.also {
if (source == WatchProgressSource.NUVIO_SYNC) {
targetItems.remove(key)?.let { storeItem ->
// Preserve videoId from the original request (store items don't have it)
if (watchedItem.videoId != null && storeItem.videoId == null) {
storeItem.copy(videoId = watchedItem.videoId)
} else {
storeItem
}
}?.also {
if (source.providerId == null) {
nuvioDirtyWatchedKeys -= key
}
// Optimistically remove from extra keys so publish() doesn't re-add it
source.providerId?.let { providerId ->
providerExtraWatchedKeys[providerId]?.let { extraKeys ->
if (key in extraKeys) {
providerExtraWatchedKeys[providerId] = extraKeys - key
}
}
}
}
}
if (removedItems.isNotEmpty()) {
@ -728,6 +815,10 @@ object WatchedRepository {
persistNuvio()
}
pushDeleteToServer(items = removedItems, source = source)
} else if (source.providerId != null) {
// Items not found in local store (e.g. anime resolved via snapshot fallback).
// Still push delete to provider so it can remove from remote.
pushDeleteToServer(items = items.toList(), source = source)
}
}
@ -756,6 +847,12 @@ object WatchedRepository {
)
val seriesWatchedItem = meta.toSeriesWatchedItem()
val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type)
log.i {
"Watched series reconciliation source=$activeSource content=${meta.type}:${meta.id} " +
"episodes=${meta.videos.size} shouldMarkSeries=$shouldMarkSeriesWatched " +
"hasSeriesMarker=$hasSeriesWatchedMarker " +
"matchingItems=${itemsForSource(activeSource).values.count { it.id == meta.id }}"
}
if (shouldMarkSeriesWatched) {
if (!hasSeriesWatchedMarker) {
markWatched(seriesWatchedItem)
@ -821,7 +918,7 @@ object WatchedRepository {
private fun pushMarksToServer(
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
trackerHistorySync: WatchedTrackerHistorySync,
source: WatchProgressSource,
) {
val profileId = currentProfileId
@ -829,13 +926,13 @@ object WatchedRepository {
accountScopeSnapshot().launch {
runCatching {
if (items.isEmpty()) return@runCatching
val pushed = pushToTargetsForSource(
val outcome = pushToTargetsForSource(
profileId = profileId,
items = items,
traktHistorySync = traktHistorySync,
trackerHistorySync = trackerHistorySync,
source = source,
)
if (pushed && shouldPersistWatchedSource(source)) {
if (shouldAcknowledgeNuvioWatchedPush(source = source, outcome = outcome)) {
recordSuccessfulPush(
profileId = profileId,
operationGeneration = operationGeneration,
@ -871,22 +968,71 @@ object WatchedRepository {
val items = watchedItemsForSource(
source = activeSource,
nuvioItems = nuvioItemsByKey.values,
traktItems = traktItemsByKey.values,
providerItems = providerItemsByKey.mapValues { (_, itemsByKey) -> itemsByKey.values },
)
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs }
_fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeysForSource(activeSource)
val fullyWatchedSeriesKeys = fullyWatchedSeriesKeysForSource(activeSource)
val watchedKeys = items.mapTo(linkedSetOf()) {
watchedItemKey(it.type, it.id, it.season, it.episode)
}
// Merge extra watched keys from providers (e.g. Simkl anime alternate IDs)
activeSource.providerId?.let { providerId ->
providerExtraWatchedKeys[providerId]?.let { extraKeys -> watchedKeys += extraKeys }
}
val isLoaded = hasLoadedSource(activeSource)
val hasLoadedRemoteItems = activeSource.providerId
?.let(providersLoadedFromRemote::contains)
?: nuvioHasLoadedRemote
_fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeys
_uiState.value = WatchedUiState(
items = items,
watchedKeys = items.mapTo(linkedSetOf()) {
watchedItemKey(it.type, it.id, it.season, it.episode)
},
isLoaded = hasLoadedSource(activeSource),
hasLoadedRemoteItems = when (activeSource) {
WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote
WatchProgressSource.TRAKT -> traktHasLoadedRemote
},
watchedKeys = watchedKeys,
isLoaded = isLoaded,
hasLoadedRemoteItems = hasLoadedRemoteItems,
)
log.i {
"Watched publish source=$activeSource provider=${activeSource.providerId?.storageId ?: "nuvio"} " +
"items=${items.size} keys=${watchedKeys.size} fullyWatchedSeries=${fullyWatchedSeriesKeys.size} " +
"isLoaded=$isLoaded hasLoadedRemote=$hasLoadedRemoteItems " +
"itemKeys=[${diagnosticItemKeySample(items)}] " +
"fullyWatchedKeys=[${fullyWatchedSeriesKeys.take(watchedDiagnosticSampleLimit).joinToString(",")}]"
}
}
private fun diagnosticItemKeySample(items: Collection<WatchedItem>): String = items
.asSequence()
.take(watchedDiagnosticSampleLimit)
.joinToString(separator = ",") { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}
/**
* Observes provider extra watched keys (e.g. Simkl anime alternate IDs).
* When the provider's snapshot changes (after mutations, syncs), recomputes
* extra keys and re-publishes so watchedKeys stays reactive and current.
*/
private fun startExtraKeysObserverIfNeeded() {
if (extraKeysObserverJob != null) return
val providerId = activeSource.providerId ?: return
val adapter = TrackingProviderRegistry.connectedWatchedProviders()
.firstOrNull { it.providerId == providerId } ?: return
extraKeysObserverJob = accountScopeSnapshot().launch {
adapter.observeExtraWatchedKeys(currentProfileId)
.distinctUntilChanged()
.collectLatest { extraKeys ->
val changed = providerExtraWatchedKeys[providerId] != extraKeys
if (changed) {
providerExtraWatchedKeys[providerId] = extraKeys
publish()
}
}
}
}
private fun stopExtraKeysObserver() {
extraKeysObserverJob?.cancel()
extraKeysObserverJob = null
}
private fun persistNuvio() {
@ -938,25 +1084,38 @@ object WatchedRepository {
private suspend fun pushToTargetsForSource(
profileId: Int,
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
trackerHistorySync: WatchedTrackerHistorySync,
source: WatchProgressSource,
): Boolean {
val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory(
sync = traktHistorySync,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
): WatchedPushOutcome {
var nuvioSyncSucceeded = false
val succeededTrackerProviderIds = linkedSetOf<TrackingProviderId>()
if (source.providerId == null) {
try {
syncAdapter.push(profileId = profileId, items = items)
nuvioSyncSucceeded = true
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to push watched items to Nuvio Sync" }
}
}
if (trackerHistorySync == WatchedTrackerHistorySync.Mirror) {
TrackingProviderRegistry.connectedWatchedProviders().forEach { provider ->
try {
provider.push(profileId = profileId, items = items)
succeededTrackerProviderIds += provider.providerId
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to push watched items to ${provider.providerId.storageId}" }
}
}
}
return WatchedPushOutcome(
nuvioSyncSucceeded = nuvioSyncSucceeded,
succeededTrackerProviderIds = succeededTrackerProviderIds,
)
if (source == WatchProgressSource.TRAKT) {
if (!shouldMirrorToTrakt) return false
traktSyncAdapter.push(profileId = profileId, items = items)
return true
}
syncAdapter.push(profileId = profileId, items = items)
if (shouldMirrorToTrakt) {
traktSyncAdapter.push(profileId = profileId, items = items)
}
return true
}
private suspend fun deleteFromTargetsForSource(
@ -964,14 +1123,24 @@ object WatchedRepository {
items: Collection<WatchedItem>,
source: WatchProgressSource,
) {
if (source == WatchProgressSource.TRAKT) {
traktSyncAdapter.delete(profileId = profileId, items = items)
return
if (source.providerId == null) {
try {
syncAdapter.delete(profileId = profileId, items = items)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to delete watched items from Nuvio Sync" }
}
}
syncAdapter.delete(profileId = profileId, items = items)
if (TraktAuthRepository.isAuthenticated.value) {
traktSyncAdapter.delete(profileId = profileId, items = items)
TrackingProviderRegistry.connectedWatchedProviders().forEach { provider ->
try {
provider.delete(profileId = profileId, items = items)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to delete watched items from ${provider.providerId.storageId}" }
}
}
}
@ -979,6 +1148,10 @@ object WatchedRepository {
synchronized(accountScopeLock) {
accountScope
}
private fun connectedWatchedProviderIds(): Set<TrackingProviderId> =
TrackingProviderRegistry.connectedWatchedProviders()
.mapTo(linkedSetOf()) { provider -> provider.providerId }
}
internal data class WatchedSnapshotMerge(
@ -1040,23 +1213,13 @@ internal fun acknowledgeSuccessfulWatchedPush(
return remainingDirtyKeys
}
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,
): Boolean = shouldUseTraktProgress(
isAuthenticated = isAuthenticated,
source = source,
)
internal fun effectiveWatchedSource(
requestedSource: WatchProgressSource,
isTraktAuthenticated: Boolean,
): WatchProgressSource =
if (shouldUseTraktWatchedSync(isAuthenticated = isTraktAuthenticated, source = requestedSource)) {
WatchProgressSource.TRAKT
} else {
WatchProgressSource.NUVIO_SYNC
}
connectedProviderIds: Set<TrackingProviderId>,
): WatchProgressSource = effectiveWatchProgressSource(
requestedSource = requestedSource,
isProviderAuthenticated = { providerId -> providerId in connectedProviderIds },
)
private fun String.isSeriesLikeWatchedType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")

View file

@ -30,14 +30,25 @@ object WatchingState {
metaType: String,
metaId: String,
episode: MetaVideo,
): Boolean = watchedKeys.contains(
watchedItemKey(
): Boolean {
val key = watchedItemKey(
type = metaType,
id = metaId,
season = episode.season,
episode = episode.episode,
),
)
)
if (watchedKeys.contains(key)) return true
// Fallback for franchise-parent anime: meta.id (e.g. "mal:49233") may differ
// from the actual entry ID in Simkl. Check via video ID resolution in snapshot.
// Only for anime-style video IDs (mal:, kitsu:, etc.) — not IMDB/TVDB content.
val videoId = episode.id
val episodeNumber = episode.episode
if (episodeNumber != null) {
return com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(videoId, episodeNumber)
}
return false
}
fun areEpisodesWatched(
watchedKeys: Set<String>,

View file

@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingWatchedProvider
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.trakt.TraktPlatformClock
@ -23,7 +25,8 @@ private const val WATCHED_MAX_PAGES = 1_000
private const val WATCHED_SHOWS_EXTENDED = "progress"
object TraktWatchedSyncAdapter : WatchedSyncAdapter {
object TraktWatchedSyncAdapter : TrackingWatchedProvider {
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
private val log = Logger.withTag("TraktWatchedSync")
private val json = Json {
ignoreUnknownKeys = true

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.features.watched.WatchedItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
data class WatchedDeltaEvent(
val eventId: Long,
@ -19,6 +21,22 @@ interface WatchedSyncAdapter {
pageSize: Int,
): List<WatchedItem>
suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set<String>? = null
/**
* Returns extra watched keys that should be added to the watched keys set
* without creating additional watched items (to avoid duplicates in CW).
* Used by Simkl for anime alternate content IDs (MAL, AniDB, etc.).
*/
suspend fun pullExtraWatchedKeys(profileId: Int): Set<String> = emptySet()
/**
* Returns a Flow of extra watched keys that updates reactively when the
* provider's state changes (e.g. after mutations or syncs).
* Default implementation emits the result of pullExtraWatchedKeys once.
*/
fun observeExtraWatchedKeys(profileId: Int): Flow<Set<String>> = flowOf(emptySet())
suspend fun getDeltaCursor(profileId: Int): Long? = null
suspend fun pullDelta(

View file

@ -1,7 +1,7 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.tracking.WatchProgressSource
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.flow.MutableStateFlow

Some files were not shown because too many files have changed in this diff Show more