mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-26 17:12:16 +00:00
Merge 26e7b23f02 into b60995324b
This commit is contained in:
commit
b8512a4a80
148 changed files with 13440 additions and 2102 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml
Normal file
22
composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 & 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'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 Simkl’s 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 Simkl’s 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 can’t 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 A–Z</string>
|
||||
<string name="library_sort_title_desc">Title Z–A</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">Couldn’t 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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = "||")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(':')
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.painter.Painter
|
|||
internal enum class IntegrationLogo {
|
||||
Tmdb,
|
||||
Trakt,
|
||||
Simkl,
|
||||
MdbList,
|
||||
IntroDb,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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:"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() },
|
||||
)
|
||||
|
|
@ -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" } }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
fun handleTraktAuthCallbackUrl(url: String) {
|
||||
TraktAuthRepository.onAuthCallbackReceived(url)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem(
|
|||
releaseInfo = releaseInfo,
|
||||
season = video.season,
|
||||
episode = video.episode,
|
||||
videoId = video.id,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue