Move data/player/domain logic into commonMain KMP source sets

Sweeps addon repositories, watchlist/profile storage, Trakt/Nuvio
sync, discovery models, and player policy classes out of Android-only
packages into data/commonMain and player/commonMain, with thin
androidMain wrappers where platform APIs are still required.
This commit is contained in:
KhooLy 2026-07-16 23:00:28 +03:00
parent 7085c5c7bf
commit fc6bc620b3
152 changed files with 3227 additions and 2767 deletions

View file

@ -301,7 +301,9 @@ tasks.matching { it.name == "preBuild" }.configureEach {
tasks.withType<Test>().configureEach {
dependsOn(rootProject.tasks.named("buildFluxaCoreHost"))
dependsOn(rootProject.tasks.named("buildFluxaStreamingEngineHost"))
dependsOn(generateFluxaCoreUniFfiBindings)
jvmArgs("-Djava.library.path=${rustCrateDir.resolve("target/debug").absolutePath}")
systemProperty(
"jna.library.path",
rustCrateDir.resolve("target/debug").absolutePath

View file

@ -0,0 +1,208 @@
package com.fluxa.app.core.rust
import android.util.Log
import com.fluxa.app.data.local.*
import com.fluxa.app.data.repository.NuvioAccountImportCoordinator
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktIntegration
import com.fluxa.app.data.repository.TraktRepository
import com.google.gson.Gson
import kotlinx.coroutines.withTimeoutOrNull
internal class AndroidAuthEffectHandler(
private val repository: StremioRepository,
private val traktRepository: TraktRepository,
private val watchlistManager: WatchlistManager,
private val nuvioAccountImportCoordinator: NuvioAccountImportCoordinator,
private val gson: Gson
) {
suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = when (effect.type) {
"runExternalSync" -> runExternalSync(effect)
"runAuthFlow" -> runAuthFlow(effect)
"exchangeAuthCode" -> exchangeAuthCode(effect)
"refreshAuthToken" -> refreshAuthToken(effect)
"syncExternalIntegration" -> syncExternalIntegration(effect)
else -> failure(effect, "unsupported_auth_effect")
}
private suspend fun runExternalSync(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.parseProfile() ?: return success(effect, emptyMap<String, Any?>())
if (effect.payload.string("provider") == "nuvio") {
val updatedProfile = nuvioAccountImportCoordinator.sync(profile) {}
return success(effect, mapOf("profile" to updatedProfile))
}
return success(
effect,
mapOf(
"snapshot" to when (effect.payload.string("provider")) {
"trakt" -> traktRepository.getSyncSnapshot(profile, effect.payload.string("language", profile.safeLanguage))
else -> repository.getExternalContinueWatching(profile, effect.payload.string("language", profile.safeLanguage))
}
)
)
}
private suspend fun runAuthFlow(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
return when (effect.payload.string("provider")) {
"trakt" -> when (effect.payload.string("mode")) {
"deviceCode" -> success(effect, repository.createTraktDeviceCode())
else -> failure(effect, "unsupported_auth_mode")
}
else -> failure(effect, "unsupported_auth_provider")
}
}
private suspend fun exchangeAuthCode(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.parseProfile() ?: return failure(effect, "missing_profile")
val updated = when (payload.string("provider")) {
"trakt" -> {
val response = repository.exchangeTraktCode(payload.string("code"))
profile.copy(
traktAccessToken = response.accessToken,
traktRefreshToken = response.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
)
}
"traktDevice" -> {
val response = repository.exchangeTraktDeviceCode(payload.string("code"))
if (!response.isSuccessful) {
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
return success(
effect,
mapOf(
"status" to "pending",
"errorCode" to (errorCode ?: "http_${response.code()}"),
"httpCode" to response.code(),
"retryAfterSeconds" to response.headers()["Retry-After"]?.toLongOrNull()
)
)
}
val responseBody = response.body() ?: return failure(effect, "empty_device_token")
profile.copy(
traktAccessToken = responseBody.accessToken,
traktRefreshToken = responseBody.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(responseBody.createdAt, responseBody.expiresIn)
)
}
"mal" -> {
val response = repository.exchangeMalCode(payload.string("code"), payload.string("codeVerifier"))
profile.copy(
malAccessToken = response.accessToken,
malRefreshToken = response.refreshToken,
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}
"simkl" -> profile.copy(simklAccessToken = repository.exchangeSimklCode(payload.string("code")).accessToken)
"anilist" -> {
val response = repository.exchangeAnilistCode(payload.string("code"))
profile.copy(
anilistAccessToken = response.accessToken,
anilistRefreshToken = response.refreshToken,
anilistTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}
else -> return failure(effect, "unsupported_auth_provider")
}
return success(effect, mapOf("profile" to updated))
}
private suspend fun refreshAuthToken(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.parseProfile() ?: return failure(effect, "missing_profile")
val updated = when (effect.payload.string("provider")) {
"trakt" -> refreshTraktTokenIfNeeded(profile)
"mal" -> refreshMalTokenIfNeeded(profile)
else -> return failure(effect, "unsupported_auth_provider")
}
return success(effect, mapOf("profile" to updated))
}
private suspend fun refreshTraktTokenIfNeeded(profile: UserProfile): UserProfile {
val refreshToken = profile.traktRefreshToken?.takeIf(String::isNotBlank) ?: return profile
val refreshWindowMs = 24L * 60L * 60L * 1000L
if (!profile.traktAccessToken.isNullOrBlank() && profile.safeTraktTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) return profile
return runCatching {
val response = traktRepository.refreshTraktToken(refreshToken)
profile.copy(
traktAccessToken = response.accessToken,
traktRefreshToken = response.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
)
}.getOrElse { throwable ->
Log.w("Trakt", "Token refresh failed", throwable)
if ((throwable as? retrofit2.HttpException)?.code() in setOf(400, 401)) {
profile.copy(traktAccessToken = null, traktRefreshToken = null, traktTokenExpiresAt = null)
} else profile
}
}
private suspend fun refreshMalTokenIfNeeded(profile: UserProfile): UserProfile {
val refreshToken = profile.malRefreshToken?.takeIf(String::isNotBlank) ?: return profile
val refreshWindowMs = 24L * 60L * 60L * 1000L
if (!profile.malAccessToken.isNullOrBlank() && profile.safeMalTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) return profile
return runCatching {
val response = repository.refreshMalToken(refreshToken)
profile.copy(
malAccessToken = response.accessToken,
malRefreshToken = response.refreshToken ?: refreshToken,
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}.getOrElse { throwable ->
Log.w("Mal", "Token refresh failed", throwable)
if ((throwable as? retrofit2.HttpException)?.code() in setOf(400, 401)) {
profile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null)
} else profile
}
}
private suspend fun syncExternalIntegration(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.parseProfile() ?: return failure(effect, "missing_profile")
if (payload.string("provider") == "stremio") {
if (profile.authKey.isBlank()) return failure(effect, "missing_stremio_token")
val addons = repository.getUserAddons(profile.authKey, forceRefresh = true)
val library = repository.getLibraryItems(profile.authKey)
val updated = profile.copy(localAddons = addons.map { it.transportUrl }.distinct())
return success(
effect,
mapOf(
"profile" to updated,
"snapshot" to mapOf("addons" to addons, "library" to library),
"externalContinueWatching" to library
)
)
}
val traktToken = profile.traktAccessToken ?: return failure(effect, "missing_trakt_token")
val language = payload.string("language", profile.safeLanguage)
val snapshot = traktRepository.getTraktSyncSnapshot(profile, language)
val watchedState = withTimeoutOrNull(8_000L) { traktRepository.getTraktWatchedState(traktToken) }
if (watchedState != null) {
watchlistManager.replaceExternalWatchedEpisodes("trakt", watchedState.episodeIdsBySeries)
watchlistManager.replaceExternalWatchedContentDurations("trakt", watchedState.durationRecords)
}
val externalItems = repository.getExternalContinueWatching(profile, language)
val updated = profile.copy(
traktLastSyncAt = System.currentTimeMillis(),
traktLastSyncedItems = snapshot.syncedItems,
traktLastContinueWatchingCount = snapshot.continueWatchingCount,
traktLastWatchlistCount = snapshot.watchlistCount
)
return success(
effect,
mapOf(
"profile" to updated,
"snapshot" to snapshot,
"watchedState" to watchedState,
"externalContinueWatching" to externalItems
)
)
}
private fun Map<String, Any?>.parseProfile(): UserProfile? = parseProfile(gson)
private fun success(effect: NativeHeadlessEffect, value: Any?) =
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
private fun failure(effect: NativeHeadlessEffect, code: String) =
HeadlessEffectCompletion(effectId = effect.id, status = "error", error = mapOf("code" to code))
}

View file

@ -0,0 +1,88 @@
package com.fluxa.app.core.rust
import android.content.Context
import com.fluxa.app.common.ReleaseDateUtils
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.ui.catalog.CalendarUpcomingItem
import com.fluxa.app.ui.catalog.CalendarWidgetProvider
import com.fluxa.app.ui.catalog.EpisodeCalendarLoader
import com.fluxa.app.ui.catalog.EpisodeNotificationHelper
import com.google.gson.Gson
internal class AndroidCalendarEffectHandler(
private val context: Context,
private val repository: StremioRepository,
private val watchlistManager: WatchlistManager,
private val gson: Gson
) {
suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = when (effect.type) {
"readCalendarMonth" -> readMonth(effect)
"replaceExternalContinueWatching" -> replaceExternalContinueWatching(effect)
"updateCalendarWidget" -> updateWidget(effect)
"notifyReleasedEpisodes" -> notifyReleasedEpisodes(effect)
else -> failure(effect, "unsupported_calendar_effect")
}
private suspend fun readMonth(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.parseProfile(gson)
val year = effect.payload.number("year")?.toInt() ?: return failure(effect, "missing_year")
val month = effect.payload.number("month")?.toInt() ?: return failure(effect, "missing_month")
val plannedItems = effect.payload.list("plannedItems").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
}
val result = EpisodeCalendarLoader(repository, watchlistManager).loadMonth(profile, year, month, plannedItems)
return success(
effect,
mapOf(
"items" to result.items,
"localItems" to result.localItems,
"externalItems" to result.externalItems
)
)
}
private suspend fun replaceExternalContinueWatching(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val items = effect.payload.list("items").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
}
watchlistManager.replaceExternalContinueWatching(items)
return success(effect, mapOf("count" to items.size))
}
private fun updateWidget(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.parseProfile(gson)
val items = calendarItems(effect)
CalendarWidgetProvider.updateCalendar(
context = context,
items = items,
language = profile?.safeLanguage ?: "en",
accentColorArgb = profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
)
return success(effect, mapOf("count" to items.size))
}
private suspend fun notifyReleasedEpisodes(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.parseProfile(gson)
val items = calendarItems(effect)
EpisodeNotificationHelper.notifyReleasedEpisodes(
context = context,
profile = profile,
items = items,
todayIso = ReleaseDateUtils.todayIso()
)
return success(effect, mapOf("count" to items.size))
}
private fun calendarItems(effect: NativeHeadlessEffect): List<CalendarUpcomingItem> =
effect.payload.list("items").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull()
}
private fun success(effect: NativeHeadlessEffect, value: Any?) =
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
private fun failure(effect: NativeHeadlessEffect, code: String) =
HeadlessEffectCompletion(effectId = effect.id, status = "error", error = mapOf("code" to code))
}

View file

@ -0,0 +1,172 @@
package com.fluxa.app.core.rust
import android.util.Log
import com.fluxa.app.data.remote.CastMember
import com.fluxa.app.data.remote.DetailTrailer
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.MetaDetail
import com.fluxa.app.data.remote.MetaLink
import com.fluxa.app.data.remote.MetaRating
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.SubtitleData
import com.fluxa.app.data.remote.Video
import com.fluxa.app.data.repository.CloudStreamCatalogClient
import com.fluxa.app.data.repository.toStremioType
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.plugins.cloudstream.ExternalExtensionRunner
import com.fluxa.app.plugins.cloudstream.ScraperActor
import com.fluxa.app.plugins.cloudstream.ScraperLoadResult
import com.fluxa.app.plugins.cloudstream.ScraperSearchResult
import com.fluxa.app.plugins.cloudstream.ScraperSubtitle
import com.fluxa.app.plugins.cloudstream.ScraperTrailer
import kotlinx.coroutines.withTimeoutOrNull
internal class AndroidCloudStreamRuntime(private val pluginManager: PluginManager) {
suspend fun loadMetaDetail(id: String): MetaDetail? {
val (apiName, url) = CloudStreamCatalogClient.decodeCsId(id) ?: return null
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: run {
Log.w("CS3Detail", "Plugin not found: $apiName")
return null
}
val runner = ExternalExtensionRunner()
val load = withTimeoutOrNull(20_000L) { runner.loadContent(api, url) } ?: run {
Log.w("CS3Detail", "$apiName: loadContent timed out or returned null for url=$url")
return null
}
val videos = load.episodes?.mapIndexed { index, episode ->
Video(
id = CloudStreamCatalogClient.encodeCsId(apiName, episode.data),
name = episode.name ?: "Episode ${index + 1}",
season = episode.season,
number = episode.episode,
released = episode.date?.toIsoDate(),
thumbnail = episode.posterUrl,
overview = episode.description,
rating = episode.rating?.let(::ratingString),
episodeRuntime = episode.runTime
)
}
return MetaDetail(
id = id,
type = load.type.toStremioType(),
name = load.title,
genres = load.tags,
poster = load.posterUrl,
background = load.backgroundPosterUrl ?: load.posterUrl,
logo = load.logoUrl,
description = load.plot,
releaseInfo = load.year?.toString(),
released = load.year?.let { "$it-01-01" },
runtime = load.duration?.let { "${it}m" },
videos = videos,
trailers = load.trailers?.mapIndexedNotNull { index, trailer -> trailer.toDetailTrailer(apiName, index) },
imdbRating = load.rating?.let(::ratingString),
ageRating = load.contentRating,
ratings = load.rating?.let { listOf(MetaRating("Cloudstream", ratingString(it))) },
cast = load.actors?.map { it.toCastMember() },
links = load.toMetaLinks(),
status = if (load.comingSoon) "Coming Soon" else load.status,
originalName = load.synonyms?.firstOrNull { it != load.title },
collectionParts = load.recommendations?.mapNotNull { it.toMeta(apiName) }
)
}
suspend fun loadStreams(id: String, directTimeoutMs: Long): List<Stream> {
val (apiName, data) = CloudStreamCatalogClient.decodeCsId(id) ?: return emptyList()
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: return emptyList()
val runner = ExternalExtensionRunner()
val direct = try {
withTimeoutOrNull(directTimeoutMs) { runner.loadStreams(api, data) }
} catch (_: Throwable) {
null
}
if (direct != null && direct.links.isNotEmpty()) return direct.links.toStreams(apiName, direct.subtitles)
val streamData = try {
withTimeoutOrNull(15_000L) { runner.loadContent(api, data)?.data }
} catch (_: Exception) {
null
} ?: data
val result = withTimeoutOrNull(30_000L) { runner.loadStreams(api, streamData) } ?: return emptyList()
return result.links.toStreams(apiName, result.subtitles)
}
fun qualityScore(quality: String): Int {
val value = quality.lowercase()
return when {
value.contains("4k") || value.contains("2160") -> 2160
value.contains("1440") -> 1440
value.contains("1080") -> 1080
value.contains("720") -> 720
value.contains("480") -> 480
value.contains("360") -> 360
value.contains("240") -> 240
else -> 0
}
}
private fun List<com.fluxa.app.plugins.cloudstream.ScraperStreamLink>.toStreams(
apiName: String,
subtitles: List<ScraperSubtitle>
): List<Stream> = sortedByDescending { qualityScore(it.quality) }.map { link ->
Stream(
name = " $apiName\n${link.quality}",
title = link.name,
url = link.url,
subtitles = subtitles.map { it.toSubtitleData() },
behaviorHints = buildMap {
put("proxyHeaders", buildMap { put("request", link.headers) })
link.referer?.let { put("referer", it) }
put("cs3Type", link.type)
put("isM3u8", link.isM3u8)
put("isDash", link.isDash)
},
addonName = " $apiName"
)
}
private fun Long.toIsoDate(): String {
val millis = if (this > 10_000_000_000L) this else this * 1000L
return java.time.Instant.ofEpochMilli(millis).atZone(java.time.ZoneOffset.UTC).toLocalDate().toString()
}
private fun ratingString(rating: Int): String = "%.1f".format(java.util.Locale.US, rating.toFloat() / 10f)
private fun ScraperActor.toCastMember() = CastMember(name = name, character = role, profilePath = image)
private fun ScraperTrailer.toDetailTrailer(apiName: String, index: Int): DetailTrailer? {
val targetUrl = url.takeIf { it.isNotBlank() } ?: return null
return DetailTrailer("cs3:$apiName:trailer:$index", "Trailer ${index + 1}", if (raw) "Trailer" else "Extractor", targetUrl, null, apiName)
}
private fun ScraperSearchResult.toMeta(apiName: String): Meta? {
val name = title.takeIf { it.isNotBlank() } ?: return null
return Meta(
id = CloudStreamCatalogClient.encodeCsId(apiName, url),
name = name,
type = type?.toStremioType() ?: "movie",
poster = posterUrl,
releaseInfo = year?.toString(),
imdbRating = quality,
background = posterUrl
)
}
private fun ScraperLoadResult.toMetaLinks(): List<MetaLink>? {
val links = mutableListOf<MetaLink>()
uniqueUrl?.takeIf { it.isNotBlank() }?.let { links += MetaLink("Source", "Cloudstream", it) }
url.takeIf { it.isNotBlank() && it != uniqueUrl }?.let { links += MetaLink("Page", "Cloudstream", it) }
syncData.orEmpty().forEach { (key, value) -> if (key.isNotBlank() && value.isNotBlank()) links += MetaLink(key, "Cloudstream Sync", value) }
synonyms.orEmpty().forEach { links += MetaLink(it, "Cloudstream Synonym", it) }
nextAiringUnixTime?.let { unix ->
val label = listOfNotNull(nextAiringSeason?.let { "S$it" }, nextAiringEpisode?.let { "E$it" }).joinToString("").ifBlank { "Next airing" }
links += MetaLink(label, "Cloudstream Next Airing", unix.toString())
}
seasonNames.orEmpty().forEach { season ->
val name = season.name?.takeIf { it.isNotBlank() } ?: "Season ${season.displaySeason ?: season.season}"
links += MetaLink(name, "Cloudstream Season", season.season.toString())
}
return links.takeIf { it.isNotEmpty() }
}
private fun ScraperSubtitle.toSubtitleData() = SubtitleData(url = url, lang = lang)
}

View file

@ -0,0 +1,39 @@
package com.fluxa.app.core.rust
import android.content.Context
import com.fluxa.app.data.local.OfflineDownloadManager
import com.fluxa.app.data.local.OfflineSubtitleOption
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.Video
import com.google.gson.Gson
internal class AndroidOfflineEffectHandler(
private val context: Context,
private val gson: Gson
) {
suspend fun enqueue(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val result = OfflineDownloadManager.getInstance(context).enqueue(
profileId = payload.stringOrNull("profileId"),
meta = gson.fromJson(gson.toJsonTree(payload["meta"]), Meta::class.java),
video = payload.objectValue("video")?.let { gson.fromJson(gson.toJsonTree(it), Video::class.java) },
videoId = payload.stringOrNull("videoId"),
stream = gson.fromJson(gson.toJsonTree(payload["stream"]), Stream::class.java),
subtitle = payload.objectValue("subtitle")?.let {
gson.fromJson(gson.toJsonTree(it), OfflineSubtitleOption::class.java)
},
profileLanguage = payload.stringOrNull("language")
)
return result.fold(
onSuccess = { HeadlessEffectCompletion(effect.id, "ok", value = it) },
onFailure = {
HeadlessEffectCompletion(
effectId = effect.id,
status = "error",
error = mapOf("code" to (it.message ?: "offline_download_failed"))
)
}
)
}
}

View file

@ -113,6 +113,24 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
private val _streamProgressFlow = MutableSharedFlow<StreamProgressUpdate>(replay = 0, extraBufferCapacity = 32)
val streamProgressFlow: SharedFlow<StreamProgressUpdate> = _streamProgressFlow
private val authEffectHandler = AndroidAuthEffectHandler(
repository = repository,
traktRepository = traktRepository,
watchlistManager = watchlistManager,
nuvioAccountImportCoordinator = nuvioAccountImportCoordinator,
gson = gson
)
private val calendarEffectHandler = AndroidCalendarEffectHandler(
context = context,
repository = repository,
watchlistManager = watchlistManager,
gson = gson
)
private val offlineEffectHandler = AndroidOfflineEffectHandler(context, gson)
private val cloudStreamRuntime = AndroidCloudStreamRuntime(pluginManager)
override suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = withContext(Dispatchers.IO) {
runCatching {
syncWatchlistProfile(effect)
@ -148,17 +166,17 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
"fetchDiscoverPage" -> fetchCatalogPage(effect)
"fetchSeasonEpisodes" -> fetchSeasonEpisodes(effect)
"fetchSubtitles" -> fetchSubtitles(effect)
"runExternalSync" -> runExternalSync(effect)
"runAuthFlow" -> runAuthFlow(effect)
"exchangeAuthCode" -> exchangeAuthCode(effect)
"refreshAuthToken" -> refreshAuthToken(effect)
"syncExternalIntegration" -> syncExternalIntegration(effect)
"runExternalSync",
"runAuthFlow",
"exchangeAuthCode",
"refreshAuthToken",
"syncExternalIntegration" -> authEffectHandler.execute(effect)
"writeSettings" -> writeSettings(effect)
"readCalendarMonth" -> readCalendarMonth(effect)
"replaceExternalContinueWatching" -> replaceExternalContinueWatching(effect)
"updateCalendarWidget" -> updateCalendarWidget(effect)
"notifyReleasedEpisodes" -> notifyReleasedEpisodes(effect)
"enqueueOfflineDownload" -> enqueueOfflineDownload(effect)
"readCalendarMonth",
"replaceExternalContinueWatching",
"updateCalendarWidget",
"notifyReleasedEpisodes" -> calendarEffectHandler.execute(effect)
"enqueueOfflineDownload" -> offlineEffectHandler.enqueue(effect)
else -> error(effect, "unsupported_effect")
}
}.getOrElse { throwable ->
@ -1070,196 +1088,6 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
return ok(effect, mapOf("subtitles" to stream?.subtitles.orEmpty()))
}
private suspend fun runExternalSync(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.profile() ?: return ok(effect, emptyMap<String, Any?>())
if (effect.payload.string("provider") == "nuvio") {
val updatedProfile = nuvioAccountImportCoordinator.sync(profile) {}
return ok(effect, mapOf("profile" to updatedProfile))
}
return ok(
effect,
mapOf(
"snapshot" to when (effect.payload.string("provider")) {
"trakt" -> traktRepository.getSyncSnapshot(profile, effect.payload.string("language", profile.safeLanguage))
else -> repository.getExternalContinueWatching(profile, effect.payload.string("language", profile.safeLanguage))
}
)
)
}
private suspend fun runAuthFlow(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
return when (effect.payload.string("provider")) {
"trakt" -> when (effect.payload.string("mode")) {
"deviceCode" -> ok(effect, repository.createTraktDeviceCode())
else -> error(effect, "unsupported_auth_mode")
}
else -> error(effect, "unsupported_auth_provider")
}
}
private suspend fun exchangeAuthCode(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.profile() ?: return error(effect, "missing_profile")
val updated = when (payload.string("provider")) {
"trakt" -> {
val response = repository.exchangeTraktCode(payload.string("code"))
profile.copy(
traktAccessToken = response.accessToken,
traktRefreshToken = response.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
)
}
"traktDevice" -> {
val response = repository.exchangeTraktDeviceCode(payload.string("code"))
if (!response.isSuccessful) {
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
return ok(
effect,
mapOf(
"status" to "pending",
"errorCode" to (errorCode ?: "http_${response.code()}"),
"httpCode" to response.code(),
"retryAfterSeconds" to response.headers()["Retry-After"]?.toLongOrNull()
)
)
}
val tokenResponse = response.body() ?: return error(effect, "empty_device_token")
profile.copy(
traktAccessToken = tokenResponse.accessToken,
traktRefreshToken = tokenResponse.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(tokenResponse.createdAt, tokenResponse.expiresIn)
)
}
"mal" -> {
val response = repository.exchangeMalCode(payload.string("code"), payload.string("codeVerifier"))
profile.copy(
malAccessToken = response.accessToken,
malRefreshToken = response.refreshToken,
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}
"simkl" -> {
val response = repository.exchangeSimklCode(payload.string("code"))
profile.copy(simklAccessToken = response.accessToken)
}
"anilist" -> {
val response = repository.exchangeAnilistCode(payload.string("code"))
profile.copy(
anilistAccessToken = response.accessToken,
anilistRefreshToken = response.refreshToken,
anilistTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}
else -> return error(effect, "unsupported_auth_provider")
}
return ok(effect, mapOf("profile" to updated))
}
private suspend fun refreshAuthToken(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.profile() ?: return error(effect, "missing_profile")
val updated = when (payload.string("provider")) {
"trakt" -> refreshTraktTokenIfNeeded(profile)
"mal" -> refreshMalTokenIfNeeded(profile)
else -> return error(effect, "unsupported_auth_provider")
}
return ok(effect, mapOf("profile" to updated))
}
private suspend fun refreshTraktTokenIfNeeded(profile: UserProfile): UserProfile {
val refreshToken = profile.traktRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
val refreshWindowMs = 24L * 60L * 60L * 1000L
if (!profile.traktAccessToken.isNullOrBlank() && profile.safeTraktTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) {
return profile
}
return runCatching {
val response = traktRepository.refreshTraktToken(refreshToken)
profile.copy(
traktAccessToken = response.accessToken,
traktRefreshToken = response.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
)
}.getOrElse { throwable ->
Log.w("Trakt", "Token refresh failed", throwable)
val status = (throwable as? retrofit2.HttpException)?.code()
if (status == 400 || status == 401) {
profile.copy(traktAccessToken = null, traktRefreshToken = null, traktTokenExpiresAt = null)
} else {
profile
}
}
}
private suspend fun refreshMalTokenIfNeeded(profile: UserProfile): UserProfile {
val refreshToken = profile.malRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
val refreshWindowMs = 24L * 60L * 60L * 1000L
if (!profile.malAccessToken.isNullOrBlank() && profile.safeMalTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) {
return profile
}
return runCatching {
val response = repository.refreshMalToken(refreshToken)
profile.copy(
malAccessToken = response.accessToken,
malRefreshToken = response.refreshToken ?: refreshToken,
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
)
}.getOrElse { throwable ->
Log.w("Mal", "Token refresh failed", throwable)
val status = (throwable as? retrofit2.HttpException)?.code()
if (status == 400 || status == 401) {
profile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null)
} else {
profile
}
}
}
private suspend fun syncExternalIntegration(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.profile() ?: return error(effect, "missing_profile")
if (payload.string("provider") == "stremio") {
if (profile.authKey.isBlank()) return error(effect, "missing_stremio_token")
val addons = repository.getUserAddons(profile.authKey, forceRefresh = true)
val library = repository.getLibraryItems(profile.authKey)
val updated = profile.copy(localAddons = addons.map { addon -> addon.transportUrl }.distinct())
return ok(
effect,
mapOf(
"profile" to updated,
"snapshot" to mapOf("addons" to addons, "library" to library),
"externalContinueWatching" to library
)
)
}
val traktToken = profile.traktAccessToken
if (traktToken.isNullOrBlank()) return error(effect, "missing_trakt_token")
val language = payload.string("language", profile.safeLanguage)
val snapshot = traktRepository.getTraktSyncSnapshot(profile, language)
val watchedState = withTimeoutOrNull(8_000L) {
traktRepository.getTraktWatchedState(traktToken)
}
if (watchedState != null) {
watchlistManager.replaceExternalWatchedEpisodes("trakt", watchedState.episodeIdsBySeries)
watchlistManager.replaceExternalWatchedContentDurations("trakt", watchedState.durationRecords)
}
val externalItems = repository.getExternalContinueWatching(profile, language)
val updated = profile.copy(
traktLastSyncAt = System.currentTimeMillis(),
traktLastSyncedItems = snapshot.syncedItems,
traktLastContinueWatchingCount = snapshot.continueWatchingCount,
traktLastWatchlistCount = snapshot.watchlistCount
)
return ok(
effect,
mapOf(
"profile" to updated,
"snapshot" to snapshot,
"watchedState" to watchedState,
"externalContinueWatching" to externalItems
)
)
}
private fun writeSettings(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
return ok(
effect,
@ -1270,73 +1098,6 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
)
}
private suspend fun readCalendarMonth(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.profile()
val year = effect.payload.number("year")?.toInt() ?: return error(effect, "missing_year")
val month = effect.payload.number("month")?.toInt() ?: return error(effect, "missing_month")
val plannedItems = effect.payload.list("plannedItems").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
}
val result = EpisodeCalendarLoader(repository, watchlistManager).loadMonth(profile, year, month, plannedItems)
return ok(
effect,
mapOf(
"items" to result.items,
"localItems" to result.localItems,
"externalItems" to result.externalItems
)
)
}
private suspend fun replaceExternalContinueWatching(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val items = effect.payload.list("items").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
}
watchlistManager.replaceExternalContinueWatching(items)
return ok(effect, mapOf("count" to items.size))
}
private fun updateCalendarWidget(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.profile()
val items = calendarItems(effect)
CalendarWidgetProvider.updateCalendar(
context = context,
items = items,
language = profile?.safeLanguage ?: "en",
accentColorArgb = profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
)
return ok(effect, mapOf("count" to items.size))
}
private suspend fun notifyReleasedEpisodes(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val profile = effect.payload.profile()
val items = calendarItems(effect)
EpisodeNotificationHelper.notifyReleasedEpisodes(
context = context,
profile = profile,
items = items,
todayIso = ReleaseDateUtils.todayIso()
)
return ok(effect, mapOf("count" to items.size))
}
private suspend fun enqueueOfflineDownload(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val result = OfflineDownloadManager.getInstance(context).enqueue(
profileId = payload.stringOrNull("profileId"),
meta = gson.fromJson(gson.toJsonTree(payload["meta"]), Meta::class.java),
video = payload.objectValue("video")?.let { gson.fromJson(gson.toJsonTree(it), Video::class.java) },
videoId = payload.stringOrNull("videoId"),
stream = gson.fromJson(gson.toJsonTree(payload["stream"]), Stream::class.java),
subtitle = payload.objectValue("subtitle")?.let { gson.fromJson(gson.toJsonTree(it), OfflineSubtitleOption::class.java) },
profileLanguage = payload.stringOrNull("language")
)
return result.fold(
onSuccess = { ok(effect, it) },
onFailure = { error(effect, it.message ?: "offline_download_failed") }
)
}
internal fun ok(effect: NativeHeadlessEffect, value: Any?): HeadlessEffectCompletion =
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
@ -1363,207 +1124,15 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
private fun com.google.gson.JsonObject.getAsJsonArrayOrNull(key: String): JsonArray? =
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
internal fun calendarItems(effect: NativeHeadlessEffect): List<CalendarUpcomingItem> =
effect.payload.list("items").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull()
}
internal fun isTmdbContentId(id: String): Boolean =
id.startsWith("tmdb:", ignoreCase = true) || id.toIntOrNull() != null
internal suspend fun loadCsNativeMetaDetail(id: String): MetaDetail? {
val (apiName, url) = CloudStreamCatalogClient.decodeCsId(id) ?: return null
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: run {
Log.w("CS3Detail", "Plugin not found: $apiName")
return null
}
val runner = ExternalExtensionRunner()
val load = withTimeoutOrNull(20_000L) { runner.loadContent(api, url) } ?: run {
Log.w("CS3Detail", "$apiName: loadContent timed out or returned null for url=$url")
return null
}
Log.d("CS3Detail", "$apiName: load type=${load.type}, episodeCount=${load.episodes?.size ?: "null"}")
val stremioType = load.type.toStremioType()
val videos = load.episodes?.mapIndexed { idx, ep ->
Video(
id = CloudStreamCatalogClient.encodeCsId(apiName, ep.data),
name = ep.name ?: "Episode ${idx + 1}",
season = ep.season,
number = ep.episode,
released = ep.date?.toCs3IsoDate(),
thumbnail = ep.posterUrl,
overview = ep.description,
rating = ep.rating?.let(::cs3RatingString),
episodeRuntime = ep.runTime
)
}
Log.d("CS3Detail", "$apiName: built MetaDetail with ${videos?.size ?: "null"} videos")
return MetaDetail(
id = id,
type = stremioType,
name = load.title,
genres = load.tags,
poster = load.posterUrl,
background = load.backgroundPosterUrl ?: load.posterUrl,
logo = load.logoUrl,
description = load.plot,
releaseInfo = load.year?.toString(),
released = load.year?.let { "$it-01-01" },
runtime = load.duration?.let { "${it}m" },
videos = videos,
trailers = load.trailers?.mapIndexedNotNull { index, trailer -> trailer.toDetailTrailer(apiName, index) },
imdbRating = load.rating?.let(::cs3RatingString),
ageRating = load.contentRating,
ratings = load.rating?.let { listOf(MetaRating("Cloudstream", cs3RatingString(it))) },
cast = load.actors?.map { it.toCastMember() },
links = load.toMetaLinks(),
status = when {
load.comingSoon -> "Coming Soon"
else -> load.status
},
originalName = load.synonyms?.firstOrNull { it != load.title },
collectionParts = load.recommendations?.mapNotNull { it.toCs3Meta(apiName) }
)
}
internal suspend fun loadCsNativeMetaDetail(id: String): MetaDetail? = cloudStreamRuntime.loadMetaDetail(id)
private fun Long.toCs3IsoDate(): String {
val millis = if (this > 10_000_000_000L) this else this * 1000L
return java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneOffset.UTC)
.toLocalDate()
.toString()
}
internal suspend fun loadCsNativeStreams(id: String, directTimeoutMs: Long = 30_000L): List<Stream> =
cloudStreamRuntime.loadStreams(id, directTimeoutMs)
private fun cs3RatingString(rating: Int): String =
"%.1f".format(java.util.Locale.US, rating.toFloat() / 10f)
private fun ScraperActor.toCastMember(): CastMember =
CastMember(name = name, character = role, profilePath = image)
private fun ScraperTrailer.toDetailTrailer(apiName: String, index: Int): DetailTrailer? {
val targetUrl = url.takeIf { it.isNotBlank() } ?: return null
return DetailTrailer(
id = "cs3:$apiName:trailer:$index",
title = "Trailer ${index + 1}",
type = if (raw) "Trailer" else "Extractor",
url = targetUrl,
thumbnail = null,
source = apiName
)
}
private fun ScraperSearchResult.toCs3Meta(apiName: String): Meta? {
val title = title.takeIf { it.isNotBlank() } ?: return null
return Meta(
id = CloudStreamCatalogClient.encodeCsId(apiName, url),
name = title,
type = type?.toStremioType() ?: "movie",
poster = posterUrl,
releaseInfo = year?.toString(),
imdbRating = quality,
background = posterUrl
)
}
private fun ScraperLoadResult.toMetaLinks(): List<MetaLink>? {
val links = mutableListOf<MetaLink>()
uniqueUrl?.takeIf { it.isNotBlank() }?.let { links.add(MetaLink("Source", "Cloudstream", it)) }
url.takeIf { it.isNotBlank() && it != uniqueUrl }?.let { links.add(MetaLink("Page", "Cloudstream", it)) }
syncData.orEmpty().forEach { (key, value) ->
if (key.isNotBlank() && value.isNotBlank()) links.add(MetaLink(key, "Cloudstream Sync", value))
}
synonyms.orEmpty().forEach { synonym ->
links.add(MetaLink(synonym, "Cloudstream Synonym", synonym))
}
nextAiringUnixTime?.let { unix ->
val label = listOfNotNull(
nextAiringSeason?.let { "S$it" },
nextAiringEpisode?.let { "E$it" }
).joinToString("").ifBlank { "Next airing" }
links.add(MetaLink(label, "Cloudstream Next Airing", unix.toString()))
}
seasonNames.orEmpty().forEach { season ->
val name = season.name?.takeIf { it.isNotBlank() } ?: "Season ${season.displaySeason ?: season.season}"
links.add(MetaLink(name, "Cloudstream Season", season.season.toString()))
}
return links.takeIf { it.isNotEmpty() }
}
internal suspend fun loadCsNativeStreams(id: String, directTimeoutMs: Long = 30_000L): List<Stream> {
val (apiName, data) = CloudStreamCatalogClient.decodeCsId(id) ?: return emptyList()
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: return emptyList()
val runner = ExternalExtensionRunner()
val directResult = try {
withTimeoutOrNull(directTimeoutMs) { runner.loadStreams(api, data) }
} catch (_: Throwable) { null }
if (directResult != null && directResult.links.isNotEmpty()) {
return directResult.links
.sortedByDescending { csQualityScore(it.quality) }
.map { link ->
Stream(
name = " $apiName\n${link.quality}",
title = link.name,
url = link.url,
subtitles = directResult.subtitles.map { it.toSubtitleData() },
behaviorHints = buildMap {
put("proxyHeaders", buildMap { put("request", link.headers) })
link.referer?.let { put("referer", it) }
put("cs3Type", link.type)
put("isM3u8", link.isM3u8)
put("isDash", link.isDash)
},
addonName = " $apiName"
)
}
}
val streamData = try {
withTimeoutOrNull(15_000L) { runner.loadContent(api, data)?.data }
} catch (e: Exception) {
null
} ?: data
val result = withTimeoutOrNull(30_000L) {
runner.loadStreams(api, streamData)
} ?: return emptyList()
return result.links
.sortedByDescending { csQualityScore(it.quality) }
.map { link ->
Stream(
name = " $apiName\n${link.quality}",
title = link.name,
url = link.url,
subtitles = result.subtitles.map { it.toSubtitleData() },
behaviorHints = buildMap {
put("proxyHeaders", buildMap { put("request", link.headers) })
link.referer?.let { put("referer", it) }
put("cs3Type", link.type)
put("isM3u8", link.isM3u8)
put("isDash", link.isDash)
},
addonName = " $apiName"
)
}
}
private fun ScraperSubtitle.toSubtitleData() = SubtitleData(
url = url,
lang = lang
)
internal fun csQualityScore(quality: String): Int {
val q = quality.lowercase()
return when {
q.contains("4k") || q.contains("2160") -> 2160
q.contains("1440") -> 1440
q.contains("1080") -> 1080
q.contains("720") -> 720
q.contains("480") -> 480
q.contains("360") -> 360
q.contains("240") -> 240
else -> 0
}
}
internal fun csQualityScore(quality: String): Int = cloudStreamRuntime.qualityScore(quality)
internal suspend fun buildPlaybackStreamRequestIds(
type: String,

View file

@ -13,36 +13,6 @@ import com.fluxa.app.ui.catalog.horizontalCardWidth
import com.fluxa.app.ui.catalog.posterCardHeight
import com.fluxa.app.ui.catalog.posterCardWidth
const val CONTINUE_WATCHING_CATEGORY_ID = "continue_watching"
fun HomeCategory.isContinueWatchingCategory(): Boolean = id == CONTINUE_WATCHING_CATEGORY_ID
fun Meta.matchesFilter(filter: String): Boolean = when (filter) {
"movie" -> type == "movie"
"series" -> type == "series" || type == "tv" || type == "anime"
else -> true
}
fun orderHomeCategories(categories: List<HomeCategory>, filter: String = "all"): List<HomeCategory> {
return categories
.mapNotNull { category ->
val items = when {
category.isContinueWatchingCategory() || category.id == "library" -> {
if (filter == "all") category.items else {
category.items.filter { it.matchesFilter(filter) }.ifEmpty { category.items }
}
}
filter == "all" -> category.items
else -> category.items.filter { it.matchesFilter(filter) }
}
when {
items.isEmpty() && category.type != "collection_folder" -> null
items === category.items -> category
else -> category.copy(items = items)
}
}
}
fun resolveHomeCardLayout(category: HomeCategory, profile: UserProfile?): String {
return if (category.isContinueWatchingCategory()) {
profile?.resolvedContinueWatchingLayout ?: "horizontal"

View file

@ -4,6 +4,7 @@ import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.LibraryUserCollection
import com.fluxa.app.data.local.LibraryUserCollectionFolder
import com.fluxa.app.data.local.OfflineDownloadManager
import com.fluxa.app.data.local.isPlayable
import com.fluxa.app.data.local.ProfileManager
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistStore
@ -73,7 +74,7 @@ class AndroidLibraryDataSource(
}
val profileDownloads = downloads.filter { it.profileId == null || it.profileId == profile?.id }
val downloadGroups = profileDownloads.toOfflineDownloadGroups().map { group ->
val downloadGroups = profileDownloads.toOfflineDownloadGroups(String::toFileImageModel).map { group ->
LibraryDownloadGroupUiModel(
key = group.key,
title = group.title,

View file

@ -0,0 +1,43 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.player.StreamSelectionRequest
import com.fluxa.app.player.StreamSourceSelectionPolicy
internal object AndroidStreamSourceSelectionPolicy : StreamSourceSelectionPolicy {
override fun select(request: StreamSelectionRequest): Int = FluxaCoreNative.selectStreamIndex(
streams = request.streams,
currentVideoId = request.currentVideoId,
initialStreamIndex = request.initialStreamIndex,
savedUrl = request.savedUrl,
savedTitle = request.savedTitle,
sourceSelectionMode = request.sourceSelectionMode,
regexPattern = request.regexPattern,
preferredBingeGroup = request.preferredBingeGroup
)
}
internal fun selectStreamIndex(
streams: List<Stream>,
currentVideoId: String?,
initialStreamIndex: Int,
savedUrl: String?,
savedTitle: String?,
sourceSelectionMode: String,
regexPattern: String?,
preferredBingeGroup: String?
): Int {
return AndroidStreamSourceSelectionPolicy.select(
StreamSelectionRequest(
streams,
currentVideoId,
initialStreamIndex,
savedUrl,
savedTitle,
sourceSelectionMode,
regexPattern,
preferredBingeGroup
)
)
}

View file

@ -1,16 +0,0 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import java.util.Locale
internal data class ChoiceOption(val value: String, val label: String)
internal fun languageDisplayName(language: String, lang: String): String = when (language.lowercase()) {
"none", "", "__off__" -> AppStrings.t(lang, "settings.none")
"forced" -> AppStrings.t(lang, "settings.forced")
"original" -> AppStrings.t(lang, "settings.original")
"device_language" -> AppStrings.t(lang, "settings.device_language")
"tr", "tr-tr" -> Locale.forLanguageTag("tr").getDisplayLanguage(Locale.forLanguageTag("tr"))
"en", "en-us" -> "English"
else -> Locale.forLanguageTag(language).getDisplayLanguage(Locale.forLanguageTag(language)).takeIf { it.isNotBlank() } ?: language
}

View file

@ -1,5 +1,8 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.player.STREAM_SOURCE_MODE_FIRST
import com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*

View file

@ -1,47 +1,69 @@
package com.fluxa.app.ui.catalog
import android.util.Log
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.TraktDeviceCodeResponse
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktIntegration
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal class HomeAuthCoordinator(
private val repository: StremioRepository,
private val scope: CoroutineScope,
private val gson: Gson,
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
private val activeProfile: () -> UserProfile?,
private val updateActiveProfile: (UserProfile) -> Unit,
private val invalidateHome: () -> Unit
) {
fun exchangeTraktCode(
fun refreshTokenIfNeeded(
provider: String,
profile: UserProfile,
onProfileUpdated: (UserProfile) -> Unit
) {
scope.launch {
val result = dispatch(
mapOf(
"type" to "authRefreshRequested",
"provider" to provider,
"profile" to profile
)
)
updatedProfile(result)?.takeIf { it != profile }?.let { updated ->
accept(updated, onProfileUpdated)
}
}
}
fun exchangeCode(
provider: String,
code: String,
codeVerifier: String?,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
scope.launch(Dispatchers.IO) {
try {
val response = repository.exchangeTraktCode(code)
activeProfile()?.let { profile ->
val updated = profile.copy(
traktAccessToken = response.accessToken,
traktRefreshToken = response.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
)
withContext(Dispatchers.Main) {
onProfileUpdated(updated)
invalidateHome()
onComplete(true)
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) { onComplete(false) }
scope.launch {
val profile = activeProfile()
if (profile == null) {
onComplete(false)
return@launch
}
val result = dispatch(
mapOf(
"type" to "authExchangeRequested",
"provider" to provider,
"code" to code,
"codeVerifier" to codeVerifier,
"profile" to profile
)
)
val updated = updatedProfile(result)
if (updated == null) {
onComplete(false)
return@launch
}
accept(updated, onProfileUpdated)
onComplete(true)
}
}
@ -50,112 +72,86 @@ internal class HomeAuthCoordinator(
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean, String?) -> Unit
) {
scope.launch(Dispatchers.IO) {
try {
val codeResponse = repository.createTraktDeviceCode()
withContext(Dispatchers.Main) { onCodeReady(codeResponse) }
val startedAt = System.currentTimeMillis()
val expiresAt = startedAt + codeResponse.expiresIn * 1000L
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
var failureMessageKey: String? = "toast.trakt_connect_failed"
while (System.currentTimeMillis() < expiresAt) {
delay(intervalMs)
val response = repository.exchangeTraktDeviceCode(codeResponse.deviceCode)
if (response.isSuccessful) {
val tokenResponse = response.body() ?: break
activeProfile()?.let { profile ->
val updated = profile.copy(
traktAccessToken = tokenResponse.accessToken,
traktRefreshToken = tokenResponse.refreshToken,
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(tokenResponse.createdAt, tokenResponse.expiresIn)
)
withContext(Dispatchers.Main) {
onProfileUpdated(updated)
invalidateHome()
onComplete(true, null)
}
return@launch
}
scope.launch {
val profile = activeProfile()
if (profile == null) {
onComplete(false, "toast.trakt_connect_failed")
return@launch
}
val codeResult = dispatch(
mapOf(
"type" to "authFlowRequested",
"provider" to "trakt",
"mode" to "deviceCode"
)
)
val codeResponse = decode<TraktDeviceCodeResponse>(
(codeResult.state["auth"] as? Map<*, *>)?.get("result")
)
if (codeResponse == null) {
onComplete(false, "toast.trakt_connect_failed")
return@launch
}
onCodeReady(codeResponse)
val expiresAt = System.currentTimeMillis() + codeResponse.expiresIn * 1000L
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
var failureMessageKey = "toast.trakt_connect_failed"
while (System.currentTimeMillis() < expiresAt) {
delay(intervalMs)
val tokenResult = dispatch(
mapOf(
"type" to "authExchangeRequested",
"provider" to "traktDevice",
"code" to codeResponse.deviceCode,
"profile" to (activeProfile() ?: profile)
)
)
val authResult = (tokenResult.state["auth"] as? Map<*, *>)?.get("result") as? Map<*, *>
val updated = decode<UserProfile>(authResult?.get("profile"))
if (updated != null) {
accept(updated, onProfileUpdated)
onComplete(true, null)
return@launch
}
val errorCode = authResult?.get("errorCode") as? String
val httpCode = (authResult?.get("httpCode") as? Number)?.toInt()
when {
errorCode == "slow_down" || httpCode == 429 -> {
val retryAfterMs = (authResult.get("retryAfterSeconds") as? Number)?.toLong()?.times(1000L)
intervalMs = (retryAfterMs ?: intervalMs + 5_000L).coerceAtMost(60_000L)
}
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
failureMessageKey = "toast.trakt_device_code_expired"
break
}
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
when {
errorCode == "slow_down" || response.code() == 429 -> {
val retryAfterMs = response.headers()["Retry-After"]?.toLongOrNull()?.let { it * 1000L }
intervalMs = (retryAfterMs ?: (intervalMs + 5_000L)).coerceAtMost(60_000L)
continue
}
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
Log.w("Trakt", "Device auth expired: $errorCode")
failureMessageKey = "toast.trakt_device_code_expired"
break
}
errorCode == "authorization_pending" || response.code() in setOf(400, 404, 409, 428) -> {
continue
}
else -> {
Log.w("Trakt", "Device auth failed: HTTP ${response.code()} ${errorCode.orEmpty()}")
break
}
}
errorCode == "authorization_pending" || httpCode in setOf(400, 404, 409, 428) -> Unit
else -> break
}
if (System.currentTimeMillis() >= expiresAt) {
failureMessageKey = "toast.trakt_device_code_expired"
}
withContext(Dispatchers.Main) { onComplete(false, failureMessageKey) }
} catch (e: Exception) {
Log.w("Trakt", "Device authorization failed", e)
withContext(Dispatchers.Main) { onComplete(false, "toast.trakt_connect_failed") }
}
if (System.currentTimeMillis() >= expiresAt) {
failureMessageKey = "toast.trakt_device_code_expired"
}
onComplete(false, failureMessageKey)
}
}
fun exchangeMalCode(
code: String,
codeVerifier: String,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
scope.launch(Dispatchers.IO) {
try {
val response = repository.exchangeMalCode(code, codeVerifier)
activeProfile()?.let { profile ->
val updated = profile.copy(
malAccessToken = response.accessToken,
malRefreshToken = response.refreshToken
)
withContext(Dispatchers.Main) {
onProfileUpdated(updated)
onComplete(true)
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) { onComplete(false) }
}
}
private fun accept(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
updateActiveProfile(profile)
onProfileUpdated(profile)
invalidateHome()
}
fun exchangeSimklCode(
code: String,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
scope.launch(Dispatchers.IO) {
try {
val response = repository.exchangeSimklCode(code)
activeProfile()?.let { profile ->
val updated = profile.copy(simklAccessToken = response.accessToken)
withContext(Dispatchers.Main) {
onProfileUpdated(updated)
onComplete(true)
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) { onComplete(false) }
}
}
private fun updatedProfile(result: NativeHeadlessEngineResult): UserProfile? {
val auth = result.state["auth"] as? Map<*, *>
val authResult = auth?.get("result")
val value = (authResult as? Map<*, *>)?.get("profile")
?: authResult
?: (result.state["profile"] as? Map<*, *>)?.get("active")
return decode(value)
}
private inline fun <reified T> decode(value: Any?): T? {
if (value == null) return null
return runCatching { gson.fromJson(gson.toJsonTree(value), T::class.java) }.getOrNull()
}
}

View file

@ -1,5 +1,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.player.TrailerResolveResult
import android.util.LruCache
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager

View file

@ -1,95 +0,0 @@
package com.fluxa.app.ui.catalog
import android.content.Context
import com.fluxa.app.common.ReleaseDateUtils
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.data.local.*
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager
import com.fluxa.app.data.remote.Meta
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
internal data class HomeCalendarSnapshot(
val localItems: List<Meta>,
val externalItems: List<Meta>
)
internal class HomeCalendarController(
private val context: Context,
private val episodeCalendarLoader: EpisodeCalendarLoader,
private val watchlistManager: WatchlistManager,
private val scope: CoroutineScope,
private val activeProfile: () -> UserProfile?,
private val setActiveProfile: (UserProfile?) -> Unit,
private val onSnapshotLoaded: (HomeCalendarSnapshot) -> Unit,
private val coreState: FluxaCoreStateHandle
) {
private val gson = Gson()
private val _items = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
val items: StateFlow<List<CalendarUpcomingItem>> = _items.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun loadMonth(profile: UserProfile?, year: Int, month: Int) {
setActiveProfile(profile ?: activeProfile())
scope.launch(Dispatchers.IO) {
dispatchCalendar("setCalendarLoading", true)
try {
val calendarProfile = profile ?: activeProfile()
val result = episodeCalendarLoader.loadMonth(calendarProfile, year, month)
if (result.externalItems.isNotEmpty()) {
watchlistManager.replaceExternalContinueWatching(result.externalItems)
}
onSnapshotLoaded(
HomeCalendarSnapshot(
localItems = result.localItems,
externalItems = result.externalItems
)
)
dispatchCalendar("setCalendarItems", result.items)
CalendarWidgetProvider.updateCalendar(
context = context,
items = result.items,
language = calendarProfile?.safeLanguage ?: "en",
accentColorArgb = calendarProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
)
EpisodeNotificationHelper.notifyReleasedEpisodes(
context = context,
profile = profile,
items = result.items,
todayIso = ReleaseDateUtils.todayIso()
)
} finally {
dispatchCalendar("setCalendarLoading", false)
}
}
}
private fun dispatchCalendar(type: String, value: Any?) {
val snapshotJson = coreState.dispatch(CoreAction(type = type, value = value))
val calendar = gson.fromJson(snapshotJson, CoreStateSnapshot::class.java)?.calendar ?: return
_items.value = calendar.items
_isLoading.value = calendar.isLoading
}
private data class CoreAction(
val type: String,
val value: Any?
)
private data class CoreStateSnapshot(
val calendar: CoreCalendarSnapshot = CoreCalendarSnapshot()
)
private data class CoreCalendarSnapshot(
val items: List<CalendarUpcomingItem> = emptyList(),
val isLoading: Boolean = false
)
}

View file

@ -0,0 +1,148 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.Meta
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
internal class HomeCatalogPagingCoordinator(
private val scope: CoroutineScope,
private val platformContentGateway: HomePlatformContentGateway,
private val activeProfile: () -> UserProfile?,
private val categories: () -> List<HomeCategory>,
private val setCategories: (List<HomeCategory>) -> Unit,
private val folderCategories: () -> Map<String, HomeCategory>,
private val setFolderCategories: (Map<String, HomeCategory>) -> Unit,
private val normalizeItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
private val decodeItems: suspend (Any?) -> List<Meta>
) {
private val inFlight = mutableSetOf<String>()
fun loadMore(categoryId: String) {
val category = categories().firstOrNull { it.id == categoryId } ?: folderCategories()[categoryId] ?: return
if (!category.canLoadMore || !inFlight.add(categoryId)) return
scope.launch {
try {
val catalogSources = category.catalogSources.orEmpty()
val remoteSources = category.remoteSources.orEmpty()
if (catalogSources.isNotEmpty() || remoteSources.isNotEmpty()) {
loadCollectionPage(category, catalogSources, remoteSources)
} else {
loadCatalogPage(category)
}
} finally {
if (category.catalogSources.orEmpty().isNotEmpty() || category.remoteSources.orEmpty().isNotEmpty()) {
update(categoryId) { it.copy(folderSourcesLoading = false) }
}
inFlight.remove(categoryId)
}
}
}
private suspend fun loadCollectionPage(
category: HomeCategory,
catalogSources: List<HomeCatalogSource>,
remoteSources: List<LibraryRemoteSource>
) {
update(category.id) { it.copy(folderSourcesLoading = true) }
val nextSkip = if (category.items.isEmpty()) 0 else category.skip + 20
val language = activeProfile()?.safeLanguage ?: "en"
val sourceResults = Channel<Pair<HomeCatalogSource, List<Meta>>>(catalogSources.size)
coroutineScope {
val semaphore = Semaphore(4)
catalogSources.forEach { source ->
launch(Dispatchers.IO) {
semaphore.withPermit {
val items = platformContentGateway.addonCatalog(
source.transportUrl,
source.type,
source.catalogId,
nextSkip,
source.genre
)
sourceResults.send(source to normalizeItems(items, source.catalogId, language, source.genre))
}
}
}
repeat(catalogSources.size) {
val (source, items) = sourceResults.receive()
val sourceMap = items.flatMap { listOf("${it.type}:${it.id}" to source, it.id to source) }.toMap()
update(category.id) { existing ->
existing.copy(
items = (existing.items + items).distinctBy { "${it.type}:${it.id}" },
resultSources = existing.resultSources + sourceMap
)
}
}
}
val remoteItems = if (remoteSources.isEmpty()) emptyList() else fetchPage(
category,
skip = nextSkip,
transportUrl = null,
remoteSources = remoteSources
)
update(category.id) { existing ->
existing.copy(
items = if (remoteItems.isEmpty()) existing.items else (existing.items + remoteItems).distinctBy { "${it.type}:${it.id}" },
skip = nextSkip,
canLoadMore = existing.items.isNotEmpty() || remoteItems.isNotEmpty()
)
}
}
private suspend fun loadCatalogPage(category: HomeCategory) {
val items = fetchPage(
category,
skip = category.skip + category.items.size,
transportUrl = category.addonTransportUrl,
remoteSources = category.remoteSources.orEmpty()
)
update(category.id) { existing ->
existing.copy(
items = if (items.isEmpty()) existing.items else (existing.items + items).distinctBy { "${it.type}:${it.id}" },
canLoadMore = items.isNotEmpty()
)
}
}
private suspend fun fetchPage(
category: HomeCategory,
skip: Int,
transportUrl: String?,
remoteSources: List<LibraryRemoteSource>
): List<Meta> {
val result = dispatch(
mapOf(
"type" to "catalogPageRequested",
"categoryId" to category.id,
"transportUrl" to transportUrl,
"contentType" to category.type,
"catalogId" to category.catalogId,
"skip" to skip,
"genre" to category.addonGenre,
"search" to null,
"remoteSource" to remoteSources,
"profile" to activeProfile()
)
)
val home = result.state["home"] as? Map<*, *> ?: return emptyList()
val paging = home["paging"] as? Map<*, *> ?: return emptyList()
return decodeItems(paging["items"])
}
private fun update(categoryId: String, transform: (HomeCategory) -> HomeCategory) {
val hidden = folderCategories()[categoryId]
if (hidden != null) {
setFolderCategories(folderCategories() + (categoryId to transform(hidden)))
} else {
setCategories(categories().map { if (it.id == categoryId) transform(it) else it })
}
}
}

View file

@ -0,0 +1,76 @@
package com.fluxa.app.ui.catalog
import android.util.Log
import com.fluxa.app.data.local.*
import com.fluxa.app.domain.discovery.buildCs3MetadataFeedOptions
import com.fluxa.app.domain.discovery.effectiveHomeMetadataFeedSelection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
internal class HomeCloudStreamCoordinator(
private val scope: CoroutineScope,
private val gateway: HomePlatformContentGateway,
private val hasLoadedHome: StateFlow<Boolean>,
private val activeProfile: () -> UserProfile?,
private val categories: () -> List<HomeCategory>,
private val setCategories: (List<HomeCategory>) -> Unit,
private val billboardIsEmpty: () -> Boolean,
private val refreshBillboard: suspend (UserProfile?) -> Unit
) {
private var refreshJob: Job? = null
fun bind() {
scope.launch {
gateway.loadedApis
.map { apis -> apis.toCs3CatalogFeedDescriptors().map { "${it.pluginName}:${it.catalogIndex}:${it.catalogName}" }.toSet() }
.distinctUntilChanged()
.collect {
refresh()
if (billboardIsEmpty()) scope.launch { refreshBillboard(activeProfile()) }
}
}
}
fun refresh() {
refreshJob?.cancel()
refreshJob = scope.launch {
try {
hasLoadedHome.first { it }
val profile = activeProfile()
val toggles = profile?.homeFeedToggles
val apis = gateway.loadedApis.value.filter { it.hasMainPage }
val feedOptions = buildCs3MetadataFeedOptions(apis.toCs3CatalogFeedDescriptors())
val enabledKeys = when {
toggles == null || profile.cs3FeedsConfigured != true -> null
toggles.any { it.startsWith("cs3_catalog_") } -> effectiveHomeMetadataFeedSelection(toggles, feedOptions.map { it.key })?.toSet()
toggles.any { it.startsWith("cs3_plugin_") } -> toggles.toSet()
else -> null
}
if (apis.isEmpty()) {
val current = categories()
val retained = current.filterNot { it.id.startsWith("cs3_") }
if (retained.size != current.size) setCategories(retained)
return@launch
}
val icons = gateway.installedPlugins.value
.mapNotNull { plugin -> plugin.iconUrl?.takeIf { it.isNotBlank() }?.let { plugin.name to it } }
.toMap()
val rows = gateway.cloudHomeCategories(apis, icons, enabledKeys)
if (!isActive) return@launch
val rowsById = rows.associateBy { it.id }
val current = categories()
val existingIds = current.filter { it.id.startsWith("cs3_") }.mapTo(mutableSetOf()) { it.id }
val merged = current.mapNotNull { category -> if (category.id.startsWith("cs3_")) rowsById[category.id] else category }
setCategories(merged + rows.filter { it.id !in existingIds })
} catch (error: Throwable) {
Log.w("HomeCloudStream", "Refresh failed", error)
}
}
}
}

View file

@ -1,129 +0,0 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.data.local.*
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.AddonDescriptor
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.AddonRepository
import com.fluxa.app.domain.discovery.DiscoverCatalogContentLoader
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
import com.fluxa.app.domain.discovery.DiscoverRequest
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.Locale
internal class HomeDiscoverController(
private val addonRepository: AddonRepository,
private val discoverCatalogContentLoader: DiscoverCatalogContentLoader,
private val scope: CoroutineScope,
private val activeProfile: () -> UserProfile?,
private val userAddons: () -> List<AddonDescriptor>,
private val setUserAddons: (List<AddonDescriptor>) -> Unit,
private val normalizeCatalogItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
private val coreState: FluxaCoreStateHandle
) {
private val gson = Gson()
private val _results = MutableStateFlow<List<Meta>>(emptyList())
val results: StateFlow<List<Meta>> = _results.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _genres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
val genres: StateFlow<List<DiscoverGenreOption>> = _genres.asStateFlow()
private val _catalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
val catalogs: StateFlow<List<DiscoverCatalogOption>> = _catalogs.asStateFlow()
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
scope.launch {
dispatchDiscover("setDiscoverLoading", true)
try {
val language = activeProfile()?.safeLanguage ?: "en"
val results = discoverCatalogContentLoader.discover(
request = DiscoverRequest(
type = type,
catalogKey = catalogKey,
genre = genre,
year = year,
rating = rating,
provider = provider,
region = region
),
catalogOptions = _catalogs.value
) { items, _, catalogId, selectedGenre ->
normalizeCatalogItems(items, catalogId, language, selectedGenre)
}
dispatchDiscover("setDiscoverResults", results)
} catch (e: Exception) {
e.printStackTrace()
} finally {
dispatchDiscover("setDiscoverLoading", false)
}
}
}
fun clearGenres() {
scope.launch {
dispatchDiscover("setDiscoverGenres", emptyList<DiscoverGenreOption>())
}
}
fun loadCatalogFilters(type: String, selectedCatalogKey: String?) {
scope.launch {
val profile = activeProfile()
val language = profile?.safeLanguage ?: "en"
val allLabel = AppStrings.t(language, "auto.all")
val addons = userAddons().ifEmpty {
addonRepository.getUserAddons(profile?.authKey.orEmpty(), profile?.safeLocalAddons)
.also(setUserAddons)
}
val catalogOptions = buildDiscoverCatalogOptions(addons, type)
dispatchDiscover("setDiscoverCatalogs", catalogOptions)
val selectedCatalog = catalogOptions.firstOrNull { it.key == selectedCatalogKey }
val selectedGenres = selectedCatalog?.genres.orEmpty()
.distinct()
.sortedBy { it.lowercase(Locale.ROOT) }
.map { DiscoverGenreOption(it, it) }
val genres = if (selectedCatalog == null || selectedGenres.isEmpty()) {
emptyList()
} else {
val includeAll = !selectedCatalog.requiresGenre
if (includeAll) listOf(DiscoverGenreOption(null, allLabel)) + selectedGenres else selectedGenres
}
dispatchDiscover("setDiscoverGenres", genres)
}
}
private fun dispatchDiscover(type: String, value: Any?) {
val snapshotJson = coreState.dispatch(CoreAction(type = type, value = value))
val snapshot = gson.fromJson(snapshotJson, CoreStateSnapshot::class.java)?.discover ?: return
_results.value = snapshot.results
_isLoading.value = snapshot.isLoading
_genres.value = snapshot.genres
_catalogs.value = snapshot.catalogs
}
private data class CoreAction(
val type: String,
val value: Any?
)
private data class CoreStateSnapshot(
val discover: CoreDiscoverSnapshot = CoreDiscoverSnapshot()
)
private data class CoreDiscoverSnapshot(
val results: List<Meta> = emptyList(),
val isLoading: Boolean = false,
val genres: List<DiscoverGenreOption> = emptyList(),
val catalogs: List<DiscoverCatalogOption> = emptyList()
)
}

View file

@ -0,0 +1,189 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.AddonDescriptor
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
import com.fluxa.app.domain.discovery.buildDiscoverContentTypes
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal class HomeHeadlessBrowseCoordinator(
private val scope: CoroutineScope,
private val gson: Gson,
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
private val activeProfile: () -> UserProfile?,
private val userAddons: () -> List<AddonDescriptor>
) {
private val metaListType = object : TypeToken<List<Meta>>() {}.type
private val calendarItemListType = object : TypeToken<List<CalendarUpcomingItem>>() {}.type
private val catalogListType = object : TypeToken<List<DiscoverCatalogOption>>() {}.type
private val genreListType = object : TypeToken<List<DiscoverGenreOption>>() {}.type
private val contentTypeListType = object : TypeToken<List<String>>() {}.type
private val sourceMapType = object : TypeToken<Map<String, HomeCatalogSource>>() {}.type
private val results = MutableStateFlow<List<Meta>>(emptyList())
private val resultSources = MutableStateFlow<Map<String, HomeCatalogSource>>(emptyMap())
private val loading = MutableStateFlow(false)
private val genres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
private val catalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
private val contentTypes = MutableStateFlow<List<String>>(emptyList())
private val calendarItems = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
private val calendarLoading = MutableStateFlow(false)
private var discoverJob: Job? = null
val discoverUiState: StateFlow<DiscoverUiState> = combine(
combine(results, resultSources, loading) { items, sources, pending -> Triple(items, sources, pending) },
genres,
catalogs,
contentTypes
) { (items, sources, pending), genreOptions, catalogOptions, types ->
DiscoverUiState(items, pending, genreOptions, catalogOptions, types, sources)
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), DiscoverUiState())
val discoverGenres: StateFlow<List<DiscoverGenreOption>> = genres.asStateFlow()
val calendarUiState: StateFlow<CalendarUiState> = combine(calendarItems, calendarLoading) { items, pending ->
CalendarUiState(items, pending)
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CalendarUiState())
fun clearResults() {
results.value = emptyList()
}
fun catalogOptions(type: String): List<DiscoverCatalogOption> = buildDiscoverCatalogOptions(userAddons(), type)
fun availableContentTypes(): List<String> = buildDiscoverContentTypes(userAddons())
fun setLoading(value: Boolean) {
loading.value = value
}
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
discoverJob?.cancel()
discoverJob = scope.launch {
loading.value = true
try {
val profile = activeProfile()
val result = dispatch(
mapOf(
"type" to "discoverRequested",
"contentType" to type,
"filters" to mapOf(
"catalogKey" to catalogKey,
"genre" to genre,
"year" to year,
"rating" to rating,
"provider" to provider,
"region" to region
),
"profile" to profile,
"language" to (profile?.safeLanguage ?: "en")
)
)
val state = result.state["discover"] as? Map<*, *>
results.value = decodeList(state?.get("results"), metaListType)
resultSources.value = decodeObject(state?.get("resultSources"), sourceMapType) ?: emptyMap()
} finally {
loading.value = false
}
}
}
fun loadMore(transportUrl: String, contentType: String, catalogId: String, genre: String?) {
if (loading.value) return
scope.launch {
loading.value = true
try {
val result = dispatch(
mapOf(
"type" to "discoverPageRequested",
"transportUrl" to transportUrl,
"contentType" to contentType,
"catalogId" to catalogId,
"skip" to results.value.size,
"genre" to genre
)
)
val state = result.state["discover"] as? Map<*, *>
val updated = decodeList<Meta>(state?.get("results"), metaListType)
val source = HomeCatalogSource(transportUrl, catalogId, contentType, genre)
val sources = resultSources.value.toMutableMap()
updated.forEach { item ->
sources.putIfAbsent("${item.type}:${item.id}", source)
sources.putIfAbsent(item.id, source)
}
results.value = updated
resultSources.value = sources
} finally {
loading.value = false
}
}
}
fun loadCalendar(profile: UserProfile?, year: Int, month: Int, plannedItems: List<Meta>) {
scope.launch {
calendarLoading.value = true
try {
val result = dispatch(
mapOf(
"type" to "calendarMonthRequested",
"profile" to profile,
"year" to year,
"month" to month,
"plannedItems" to plannedItems
)
)
val state = result.state["calendar"] as? Map<*, *>
calendarItems.value = decodeList(state?.get("items"), calendarItemListType)
} finally {
calendarLoading.value = false
}
}
}
fun clearGenres() {
genres.value = emptyList()
}
fun loadFilters(type: String, selectedCatalogKey: String?, onLoaded: ((List<DiscoverCatalogOption>) -> Unit)?) {
scope.launch {
val profile = activeProfile()
val result = dispatch(
mapOf(
"type" to "discoverCatalogFiltersRequested",
"contentType" to type,
"selectedCatalogKey" to selectedCatalogKey,
"profile" to profile,
"language" to (profile?.safeLanguage ?: "en")
)
)
val state = result.state["discover"] as? Map<*, *> ?: return@launch
val updatedCatalogs = decodeList<DiscoverCatalogOption>(state["catalogs"], catalogListType)
catalogs.value = updatedCatalogs
genres.value = decodeList(state["genres"], genreListType)
contentTypes.value = decodeList(state["contentTypes"], contentTypeListType)
onLoaded?.invoke(updatedCatalogs)
}
}
private suspend fun <T> decodeList(value: Any?, type: java.lang.reflect.Type): List<T> = withContext(Dispatchers.Default) {
if (value == null) emptyList() else runCatching { gson.fromJson<List<T>>(gson.toJson(value), type) }.getOrDefault(emptyList())
}
private suspend fun <T> decodeObject(value: Any?, type: java.lang.reflect.Type): T? = withContext(Dispatchers.Default) {
if (value == null) null else runCatching { gson.fromJson<T>(gson.toJson(value), type) }.getOrNull()
}
}

View file

@ -0,0 +1,105 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.Meta
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal class HomeHeadlessSyncCoordinator(
private val scope: CoroutineScope,
private val gson: Gson,
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
private val setActiveProfile: (UserProfile) -> Unit,
private val setWatchlist: (List<Meta>) -> Unit,
private val setContinueWatching: (List<Meta>) -> Unit,
private val setExternalContinueWatching: (List<Meta>) -> Unit,
private val setLiked: (List<Meta>) -> Unit,
private val refreshDynamicRows: () -> Unit
) {
private val metaListType = object : TypeToken<List<Meta>>() {}.type
fun loadLibrary(profile: UserProfile?) {
scope.launch {
val result = dispatch(mapOf("type" to "libraryHydrateRequested", "profileId" to profile?.id))
val library = result.state["library"] as? Map<*, *> ?: return@launch
setWatchlist(decodeList(library["watchlist"]))
setContinueWatching(decodeList(library["continueWatching"]))
setLiked(decodeList(library["liked"]))
refreshDynamicRows()
}
}
fun syncTrakt(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
syncIntegration("trakt", profile, onProfileUpdated, onComplete)
}
fun syncStremio(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
syncIntegration("stremio", profile, onProfileUpdated, onComplete)
}
fun syncNuvio(
profile: UserProfile,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit,
onSynced: (UserProfile) -> Unit
) {
scope.launch {
val result = dispatch(
mapOf("type" to "externalSyncRequested", "provider" to "nuvio", "profile" to profile, "language" to profile.safeLanguage)
)
val sync = result.state["sync"] as? Map<*, *>
val updated = decodeProfile(
sync?.get("profile")
?: (sync?.get("snapshot") as? Map<*, *>)?.get("profile")
?: (result.state["profile"] as? Map<*, *>)?.get("active")
)
if (updated != null) {
setActiveProfile(updated)
onProfileUpdated(updated)
onSynced(updated)
}
onComplete(sync?.get("error") == null)
}
}
private fun syncIntegration(
provider: String,
profile: UserProfile,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
scope.launch {
val result = dispatch(
mapOf(
"type" to "externalIntegrationSyncRequested",
"provider" to provider,
"profile" to profile,
"language" to profile.safeLanguage
)
)
val sync = result.state["sync"] as? Map<*, *>
val snapshot = sync?.get("snapshot") as? Map<*, *>
val updated = decodeProfile(snapshot?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"))
if (updated != null) {
setActiveProfile(updated)
setExternalContinueWatching(decodeList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching")))
onProfileUpdated(updated)
refreshDynamicRows()
}
onComplete(sync?.get("error") == null && updated != null)
}
}
private suspend fun decodeProfile(value: Any?): UserProfile? = withContext(Dispatchers.Default) {
if (value == null) null else runCatching { gson.fromJson(gson.toJsonTree(value), UserProfile::class.java) }.getOrNull()
}
private suspend fun decodeList(value: Any?): List<Meta> = withContext(Dispatchers.Default) {
if (value == null) emptyList() else runCatching { gson.fromJson<List<Meta>>(gson.toJsonTree(value), metaListType) }.getOrDefault(emptyList())
}
}

View file

@ -46,8 +46,7 @@ object HomeRowRankingPolicy {
val json = FluxaCoreNative.optimizeHomeRowsJson(gson.toJson(request))
val optimized = gson.fromJson<List<HomeCategory>>(json, homeCategoryListType) ?: emptyList()
return optimized.map { category ->
val original = originalItemsById[category.id]
if (original != null) category.copy(items = original.items) else category
originalItemsById[category.id] ?: category
}
}
@ -73,8 +72,7 @@ object HomeRowRankingPolicy {
)
val result = gson.fromJson<List<HomeCategory>>(json, homeCategoryListType) ?: emptyList()
return result.map { category ->
val original = originalItemsById[category.id]
if (original != null) category.copy(items = original.items) else category
originalItemsById[category.id] ?: category
}
}

View file

@ -1,42 +0,0 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.local.*
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.AddonRepository
internal data class HomeSearchResults(
val flatItems: List<Meta>,
val rows: List<SearchResultRow>
)
internal class HomeSearchCoordinator(
private val addonRepository: AddonRepository,
private val historyStore: SearchHistoryStore
) {
suspend fun search(query: String, profile: UserProfile?, filter: (List<Meta>) -> List<Meta>): HomeSearchResults {
val normalizedQuery = query.trim()
val rows = addonRepository.searchRows(
query = normalizedQuery,
language = profile?.safeLanguage ?: "en",
authKey = profile?.authKey.orEmpty(),
localAddons = profile?.safeLocalAddons.orEmpty()
)
val results = rows.flatMap { it.items }.distinctBy { it.id }.take(80)
return HomeSearchResults(
flatItems = filter(results),
rows = rows.mapNotNull { row ->
val filteredItems = filter(row.items)
if (filteredItems.isEmpty()) null else row.copy(items = filteredItems)
}
)
}
fun addToHistory(meta: Meta, current: List<Meta>, profile: UserProfile?): List<Meta> {
val updated = current.toMutableList()
val existingIndex = updated.indexOfFirst { it.id == meta.id || it.name.equals(meta.name, ignoreCase = true) }
if (existingIndex != -1) updated.removeAt(existingIndex)
updated.add(0, meta.copy(description = null, cast = null, ratings = null, awards = null))
return updated.take(10).also { historyStore.save(it, profile) }
}
}

View file

@ -9,16 +9,12 @@ import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaHeadlessRuntimeFactory
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
import com.fluxa.app.domain.discovery.Cs3CatalogFeedDescriptor
import com.fluxa.app.domain.discovery.MetadataFeedOption
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
import com.fluxa.app.domain.discovery.buildDiscoverContentTypes
import com.fluxa.app.domain.discovery.buildCs3MetadataFeedOptions
import com.fluxa.app.domain.discovery.effectiveHomeMetadataFeedSelection
import com.fluxa.app.domain.discovery.isMetadataFeedEnabled
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
@ -40,13 +36,8 @@ import java.util.Locale
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.data.repository.CloudStreamCatalogClient
import com.lagradost.cloudstream3.MainAPI
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -74,13 +65,9 @@ class HomeViewModel @Inject constructor(
private val streamListType = object : TypeToken<List<Stream>>() {}.type
private val trailerListType = object : TypeToken<List<DetailTrailer>>() {}.type
private val categoryListType = object : TypeToken<List<HomeCategory>>() {}.type
private val calendarItemListType = object : TypeToken<List<CalendarUpcomingItem>>() {}.type
private val videoListType = object : TypeToken<List<Video>>() {}.type
private val subtitleListType = object : TypeToken<List<SubtitleData>>() {}.type
private val introTimestampsListType = object : TypeToken<List<IntroTimestamps>>() {}.type
private val discoverCatalogListType = object : TypeToken<List<DiscoverCatalogOption>>() {}.type
private val discoverGenreListType = object : TypeToken<List<DiscoverGenreOption>>() {}.type
private val discoverContentTypeListType = object : TypeToken<List<String>>() {}.type
private val addonListType = object : TypeToken<List<AddonDescriptor>>() {}.type
private val headlessRuntime = FluxaHeadlessRuntimeFactory.createUniFfi(headlessEnvironment)
private val initialSearchHistory = searchHistoryStore.load(null)
@ -109,10 +96,9 @@ class HomeViewModel @Inject constructor(
"library" to mapOf("uiState" to LibraryUiState())
)
)
private val _categories = MutableStateFlow<List<HomeCategory>>(emptyList())
val categories: StateFlow<List<HomeCategory>> = _categories
private val _collectionFolderCategories = MutableStateFlow<Map<String, HomeCategory>>(emptyMap())
val collectionFolderCategories: StateFlow<Map<String, HomeCategory>> = _collectionFolderCategories.asStateFlow()
private val categoryState = HomeCategoryStateStore()
val categories: StateFlow<List<HomeCategory>> = categoryState.categories
val collectionFolderCategories: StateFlow<Map<String, HomeCategory>> = categoryState.folderCategories
var savedHomeScrollIndex: Int = 0
var savedHomeScrollOffset: Int = 0
@ -120,7 +106,6 @@ class HomeViewModel @Inject constructor(
var savedTvHomeScrollOffset: Int = 0
var savedTvFocusedRowIndex: Int = -1
val savedCategoryScrollPositions: HashMap<String, Pair<Int, Int>> = HashMap()
private var categoriesRenderSignature: Int? = null
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
@ -140,38 +125,32 @@ class HomeViewModel @Inject constructor(
val searchResults: StateFlow<List<Meta>> = searchFocusState.searchResults
val searchRows: StateFlow<List<SearchResultRow>> = searchFocusState.searchRows
private val _headlessDiscoverResults = MutableStateFlow<List<Meta>>(emptyList())
private val _headlessDiscoverResultSources = MutableStateFlow<Map<String, HomeCatalogSource>>(emptyMap())
private val _headlessDiscoverLoading = MutableStateFlow(false)
private val _headlessDiscoverGenres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
private val _headlessDiscoverCatalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
private val _headlessDiscoverContentTypes = MutableStateFlow<List<String>>(emptyList())
private val discoverResultSourceMapType = object : TypeToken<Map<String, HomeCatalogSource>>() {}.type
private var discoverJob: Job? = null
private val browseCoordinator by lazy {
HomeHeadlessBrowseCoordinator(
scope = viewModelScope,
gson = gson,
dispatch = ::dispatchHeadless,
activeProfile = { currentActiveProfile },
userAddons = { _userAddons.value }
)
}
val discoverUiState: StateFlow<DiscoverUiState> get() = browseCoordinator.discoverUiState
val discoverGenres: StateFlow<List<DiscoverGenreOption>> get() = browseCoordinator.discoverGenres
val calendarUiState: StateFlow<CalendarUiState> get() = browseCoordinator.calendarUiState
val discoverUiState: StateFlow<DiscoverUiState> = combine(
combine(
_headlessDiscoverResults,
_headlessDiscoverResultSources,
_headlessDiscoverLoading
) { results, resultSources, loading -> Triple(results, resultSources, loading) },
_headlessDiscoverGenres,
_headlessDiscoverCatalogs,
_headlessDiscoverContentTypes
) { (results, resultSources, loading), genres, catalogs, contentTypes ->
DiscoverUiState(results, loading, genres, catalogs, contentTypes, resultSources)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DiscoverUiState())
val discoverGenres: StateFlow<List<DiscoverGenreOption>> get() = _headlessDiscoverGenres.asStateFlow()
private val _headlessCalendarItems = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
private val _headlessCalendarLoading = MutableStateFlow(false)
val calendarUiState: StateFlow<CalendarUiState> = combine(
_headlessCalendarItems,
_headlessCalendarLoading
) { items, loading -> CalendarUiState(items, loading) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CalendarUiState())
private val syncCoordinator by lazy {
HomeHeadlessSyncCoordinator(
scope = viewModelScope,
gson = gson,
dispatch = ::dispatchHeadless,
setActiveProfile = { setActiveProfileState(it) },
setWatchlist = ::setWatchlistState,
setContinueWatching = ::setCurrentWatchlistState,
setExternalContinueWatching = ::setExternalContinueWatchingState,
setLiked = ::setLikedItemsState,
refreshDynamicRows = ::refreshDynamicRows
)
}
private val billboardState = HomeBillboardStateHolder()
val billboardError: StateFlow<String?> = billboardState.error
@ -264,7 +243,20 @@ class HomeViewModel @Inject constructor(
private var searchJob: Job? = null
private val _isSearchLoading = MutableStateFlow(false)
val isSearchLoading: StateFlow<Boolean> = _isSearchLoading.asStateFlow()
private val loadMoreInFlight = mutableSetOf<String>()
private val pagingCoordinator by lazy {
HomeCatalogPagingCoordinator(
scope = viewModelScope,
platformContentGateway = platformContentGateway,
activeProfile = { currentActiveProfile },
categories = categoryState::currentCategories,
setCategories = ::setCategoriesState,
folderCategories = categoryState::currentFolderCategories,
setFolderCategories = categoryState::replaceFolderCategories,
normalizeItems = ::normalizeCatalogItems,
dispatch = ::dispatchHeadless,
decodeItems = { fromStateList(it, metaListType) }
)
}
private val playbackController by lazy {
coordinatorFactory.playback(
context = appContext,
@ -311,7 +303,7 @@ class HomeViewModel @Inject constructor(
setPool = { billboardState.poolValue = it },
index = { billboardState.indexValue },
setIndex = { billboardState.indexValue = it },
categories = { _categories.value },
categories = categoryState::currentCategories,
language = { currentActiveProfile?.safeLanguage ?: "en" },
setMovie = { billboardState.movieValue = it },
setLogo = { billboardState.logoValue = it },
@ -361,7 +353,7 @@ class HomeViewModel @Inject constructor(
continueWatchingItems = ::buildContinueWatchingItems,
normalizeCatalogItems = ::normalizeCatalogItems,
setCategories = ::setCategoriesState,
currentCategories = { _categories.value }
currentCategories = categoryState::currentCategories
)
}
@ -380,7 +372,7 @@ class HomeViewModel @Inject constructor(
private val dynamicRowsCoordinator by lazy {
HomeDynamicRowsCoordinator(
scope = viewModelScope,
categories = { _categories.value },
categories = categoryState::currentCategories,
setCategories = ::setCategoriesAndCache,
activeProfile = { currentActiveProfile },
buildUserCollectionHomeCategories = ::buildUserCollectionHomeCategories,
@ -389,6 +381,17 @@ class HomeViewModel @Inject constructor(
)
}
private val authCoordinator by lazy {
HomeAuthCoordinator(
scope = viewModelScope,
gson = gson,
dispatch = ::dispatchHeadless,
activeProfile = { currentActiveProfile },
updateActiveProfile = ::setActiveProfileState,
invalidateHome = { setCategoriesState(emptyList()) }
)
}
private val watchlistFlowBinder by lazy {
HomeWatchlistFlowBinder(
watchlistStore = watchlistStore,
@ -402,77 +405,26 @@ class HomeViewModel @Inject constructor(
)
}
private val cloudStreamCoordinator by lazy {
HomeCloudStreamCoordinator(
scope = viewModelScope,
gateway = platformContentGateway,
hasLoadedHome = _hasLoadedHome,
activeProfile = { currentActiveProfile },
categories = categoryState::currentCategories,
setCategories = ::setCategoriesAndCache,
billboardIsEmpty = { billboardState.poolValue.isEmpty() },
refreshBillboard = billboardLoader::load
)
}
init {
watchlistFlowBinder.bind()
observeCloudStreamPlugins()
cloudStreamCoordinator.bind()
}
private var cs3FetchJob: Job? = null
private fun scheduleCs3Refresh() {
cs3FetchJob?.cancel()
cs3FetchJob = viewModelScope.launch {
try {
_hasLoadedHome.first { it }
val profile = currentActiveProfile
val homeFeedToggles = profile?.homeFeedToggles
val cs3FeedsConfigured = profile?.cs3FeedsConfigured == true
val apis = platformContentGateway.loadedApis.value
.filter { it.hasMainPage }
val cs3FeedOptions = buildCs3MetadataFeedOptions(apis.toCs3CatalogFeedDescriptors())
val enabledCs3FeedKeys = if (homeFeedToggles == null || !cs3FeedsConfigured) {
null
} else if (homeFeedToggles.any { it.startsWith("cs3_catalog_") }) {
effectiveHomeMetadataFeedSelection(homeFeedToggles, cs3FeedOptions.map { it.key })?.toSet()
} else if (homeFeedToggles.any { it.startsWith("cs3_plugin_") }) {
homeFeedToggles.toSet()
} else {
null
}
android.util.Log.d("HomeViewModel", "CS3 refresh: ${apis.size} APIs: ${apis.map { it.name }}")
if (apis.isEmpty()) {
val withoutCs3 = _categories.value.filter { !it.id.startsWith("cs3_") }
if (withoutCs3.size != _categories.value.size) setCategoriesAndCache(withoutCs3)
return@launch
}
val iconsByApiName = platformContentGateway.installedPlugins.value
.filter { !it.iconUrl.isNullOrBlank() }
.associate { it.name to it.iconUrl!! }
val cs3Rows = platformContentGateway.cloudHomeCategories(
apis = apis,
iconsByApiName = iconsByApiName,
enabledFeedKeys = enabledCs3FeedKeys
)
android.util.Log.d("HomeViewModel", "CS3 refresh: got ${cs3Rows.size} rows from ${apis.size} APIs")
if (!isActive) return@launch
val cs3RowsById = cs3Rows.associateBy { it.id }
val currentCategories = _categories.value
val existingCs3Ids = currentCategories.filter { it.id.startsWith("cs3_") }.map { it.id }.toSet()
val merged = currentCategories.mapNotNull { cat ->
if (cat.id.startsWith("cs3_")) cs3RowsById[cat.id] else cat
}
val newCs3Rows = cs3Rows.filter { it.id !in existingCs3Ids }
setCategoriesAndCache(merged + newCs3Rows)
} catch (t: Throwable) {
android.util.Log.w("HomeViewModel", "CS3 refresh failed", t)
}
}
}
private fun observeCloudStreamPlugins() {
viewModelScope.launch {
platformContentGateway.loadedApis
.map { apis -> apis.toCs3CatalogFeedDescriptors().map { "${it.pluginName}:${it.catalogIndex}:${it.catalogName}" }.toSet() }
.distinctUntilChanged()
.collect {
scheduleCs3Refresh()
if (billboardState.poolValue.isEmpty()) {
viewModelScope.launch {
billboardLoader.load(currentActiveProfile)
}
}
}
}
cloudStreamCoordinator.refresh()
}
override fun onCleared() {
@ -710,7 +662,7 @@ class HomeViewModel @Inject constructor(
"initialStreamIndex" to initialStreamIndex,
"savedUrl" to savedUrl,
"savedTitle" to savedTitle,
"sourceSelectionMode" to (profile?.safeStreamSourceSelectionMode ?: STREAM_SOURCE_MODE_MANUAL),
"sourceSelectionMode" to (profile?.safeStreamSourceSelectionMode ?: com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL),
"regexPattern" to profile?.safeStreamSourceRegexPattern,
"preferredBingeGroup" to preferredBingeGroup,
"title" to meta.name,
@ -928,230 +880,36 @@ class HomeViewModel @Inject constructor(
}
fun loadMore(categoryId: String) {
val category = _categories.value.firstOrNull { it.id == categoryId }
?: _collectionFolderCategories.value[categoryId]
?: return
if (!category.canLoadMore || !loadMoreInFlight.add(categoryId)) return
viewModelScope.launch {
try {
val catalogSources = category.catalogSources.orEmpty()
val remoteSources = category.remoteSources.orEmpty()
if (catalogSources.isNotEmpty() || remoteSources.isNotEmpty()) {
updateHomeCategory(categoryId) { existing -> existing.copy(folderSourcesLoading = true) }
val nextSkip = if (category.items.isEmpty()) 0 else category.skip + 20
val lang = currentActiveProfile?.safeLanguage ?: "en"
val semaphore = Semaphore(permits = 4)
val sourceResults = Channel<Pair<HomeCatalogSource, List<Meta>>>(catalogSources.size)
coroutineScope {
catalogSources.forEach { source ->
launch(Dispatchers.IO) {
semaphore.withPermit {
sourceResults.send(
source to normalizeCatalogItems(
platformContentGateway.addonCatalog(
source.transportUrl,
source.type,
source.catalogId,
skip = nextSkip,
genre = source.genre
),
source.catalogId,
lang,
source.genre
)
)
}
}
}
repeat(catalogSources.size) {
val (source, sourceItems) = sourceResults.receive()
val sourceMap = sourceItems.flatMap { item ->
listOf("${item.type}:${item.id}" to source, item.id to source)
}.toMap()
updateHomeCategory(categoryId) { existing ->
existing.copy(
items = (existing.items + sourceItems).distinctBy { "${it.type}:${it.id}" },
resultSources = existing.resultSources + sourceMap
)
}
}
}
val remoteItems = if (remoteSources.isEmpty()) {
emptyList()
} else {
val result = dispatchHeadless(
mapOf(
"type" to "catalogPageRequested",
"categoryId" to categoryId,
"transportUrl" to null,
"contentType" to category.type,
"catalogId" to category.catalogId,
"skip" to nextSkip,
"genre" to null,
"search" to null,
"remoteSource" to remoteSources,
"profile" to currentActiveProfile
)
)
val home = result.state["home"] as? Map<*, *> ?: emptyMap<Any, Any>()
val paging = home["paging"] as? Map<*, *> ?: emptyMap<Any, Any>()
fromStateList<Meta>(paging["items"], metaListType)
}
updateHomeCategory(categoryId) { existing ->
existing.copy(
items = if (remoteItems.isEmpty()) existing.items else (existing.items + remoteItems).distinctBy { "${it.type}:${it.id}" },
skip = nextSkip,
canLoadMore = existing.items.isNotEmpty() || remoteItems.isNotEmpty()
)
}
return@launch
}
val result = dispatchHeadless(
mapOf(
"type" to "catalogPageRequested",
"categoryId" to categoryId,
"transportUrl" to category.addonTransportUrl,
"contentType" to category.type,
"catalogId" to category.catalogId,
"skip" to (category.skip + category.items.size),
"genre" to category.addonGenre,
"search" to null,
"remoteSource" to remoteSources,
"profile" to currentActiveProfile
)
)
val home = result.state["home"] as? Map<*, *> ?: return@launch
val paging = home["paging"] as? Map<*, *> ?: return@launch
val newItems = fromStateList<Meta>(paging["items"], metaListType)
updateHomeCategory(categoryId) { existing ->
existing.copy(
items = if (newItems.isEmpty()) existing.items else (existing.items + newItems).distinctBy { "${it.type}:${it.id}" },
canLoadMore = newItems.isNotEmpty()
)
}
} finally {
if (category.catalogSources.orEmpty().isNotEmpty() || category.remoteSources.orEmpty().isNotEmpty()) {
updateHomeCategory(categoryId) { existing -> existing.copy(folderSourcesLoading = false) }
}
loadMoreInFlight.remove(categoryId)
}
}
}
private fun updateHomeCategory(categoryId: String, update: (HomeCategory) -> HomeCategory) {
val hiddenCategory = _collectionFolderCategories.value[categoryId]
if (hiddenCategory != null) {
_collectionFolderCategories.value = _collectionFolderCategories.value + (categoryId to update(hiddenCategory))
return
}
setCategoriesState(
_categories.value.map { existing ->
if (existing.id == categoryId) {
update(existing)
} else {
existing
}
}
)
pagingCoordinator.loadMore(categoryId)
}
fun clearDiscoverResults() {
_headlessDiscoverResults.value = emptyList()
browseCoordinator.clearResults()
}
fun discoverCatalogOptions(type: String): List<DiscoverCatalogOption> =
buildDiscoverCatalogOptions(_userAddons.value, type)
browseCoordinator.catalogOptions(type)
fun discoverContentTypes(): List<String> = buildDiscoverContentTypes(_userAddons.value)
fun discoverContentTypes(): List<String> = browseCoordinator.availableContentTypes()
fun setDiscoverLoading(isLoading: Boolean) {
_headlessDiscoverLoading.value = isLoading
browseCoordinator.setLoading(isLoading)
}
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
discoverJob?.cancel()
discoverJob = viewModelScope.launch {
_headlessDiscoverLoading.value = true
try {
val result = dispatchHeadless(
mapOf(
"type" to "discoverRequested",
"contentType" to type,
"filters" to mapOf(
"catalogKey" to catalogKey,
"genre" to genre,
"year" to year,
"rating" to rating,
"provider" to provider,
"region" to region
),
"profile" to currentActiveProfile,
"language" to (currentActiveProfile?.safeLanguage ?: "en")
)
)
val discover = result.state["discover"] as? Map<*, *>
_headlessDiscoverResults.value = fromStateList(discover?.get("results"), metaListType)
_headlessDiscoverResultSources.value = fromStateSourceMap(discover?.get("resultSources"))
} finally {
_headlessDiscoverLoading.value = false
}
}
browseCoordinator.discover(type, catalogKey, genre, year, rating, provider, region)
}
fun loadMoreDiscoverResults(transportUrl: String, contentType: String, catalogId: String, genre: String?) {
if (_headlessDiscoverLoading.value) return
viewModelScope.launch {
_headlessDiscoverLoading.value = true
try {
val result = dispatchHeadless(
mapOf(
"type" to "discoverPageRequested",
"transportUrl" to transportUrl,
"contentType" to contentType,
"catalogId" to catalogId,
"skip" to _headlessDiscoverResults.value.size,
"genre" to genre
)
)
val discover = result.state["discover"] as? Map<*, *>
val updatedResults = fromStateList<Meta>(discover?.get("results"), metaListType)
val source = HomeCatalogSource(transportUrl, catalogId, contentType, genre)
val updatedSources = _headlessDiscoverResultSources.value.toMutableMap()
updatedResults.forEach { item ->
updatedSources.putIfAbsent("${item.type}:${item.id}", source)
updatedSources.putIfAbsent(item.id, source)
}
_headlessDiscoverResults.value = updatedResults
_headlessDiscoverResultSources.value = updatedSources
} finally {
_headlessDiscoverLoading.value = false
}
}
browseCoordinator.loadMore(transportUrl, contentType, catalogId, genre)
}
fun loadCalendarMonth(activeProfile: UserProfile?, year: Int, month: Int, plannedItems: List<Meta> = emptyList()) {
viewModelScope.launch {
_headlessCalendarLoading.value = true
try {
val result = dispatchHeadless(
mapOf(
"type" to "calendarMonthRequested",
"profile" to activeProfile,
"year" to year,
"month" to month,
"plannedItems" to plannedItems
)
)
val calendar = result.state["calendar"] as? Map<*, *>
_headlessCalendarItems.value = fromStateList(calendar?.get("items"), calendarItemListType)
} finally {
_headlessCalendarLoading.value = false
}
}
browseCoordinator.loadCalendar(activeProfile, year, month, plannedItems)
}
fun loadDiscoverGenres(type: String) {
_headlessDiscoverGenres.value = emptyList()
browseCoordinator.clearGenres()
}
fun loadDiscoverCatalogFilters(
@ -1159,23 +917,7 @@ class HomeViewModel @Inject constructor(
selectedCatalogKey: String?,
onLoaded: ((List<DiscoverCatalogOption>) -> Unit)? = null
) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "discoverCatalogFiltersRequested",
"contentType" to type,
"selectedCatalogKey" to selectedCatalogKey,
"profile" to currentActiveProfile,
"language" to (currentActiveProfile?.safeLanguage ?: "en")
)
)
val discover = result.state["discover"] as? Map<*, *> ?: return@launch
val catalogs = fromStateList<DiscoverCatalogOption>(discover["catalogs"], discoverCatalogListType)
_headlessDiscoverCatalogs.value = catalogs
_headlessDiscoverGenres.value = fromStateList(discover["genres"], discoverGenreListType)
_headlessDiscoverContentTypes.value = fromStateList(discover["contentTypes"], discoverContentTypeListType)
onLoaded?.invoke(catalogs)
}
browseCoordinator.loadFilters(type, selectedCatalogKey, onLoaded)
}
fun setUserAddons(addons: List<AddonDescriptor>) {
@ -1217,7 +959,7 @@ class HomeViewModel @Inject constructor(
savedTvFocusedRowIndex = -1
savedCategoryScrollPositions.clear()
}
if (_categories.value.isEmpty()) {
if (categoryState.currentCategories().isEmpty()) {
val cached = homeCategoryCache.load(activeProfile)
if (cached.isNotEmpty()) {
setCategoriesState(cached)
@ -1261,29 +1003,11 @@ class HomeViewModel @Inject constructor(
}
fun refreshTraktTokenIfNeeded(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
refreshAuthTokenIfNeeded("trakt", profile, onProfileUpdated)
authCoordinator.refreshTokenIfNeeded("trakt", profile, onProfileUpdated)
}
fun refreshMalTokenIfNeeded(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
refreshAuthTokenIfNeeded("mal", profile, onProfileUpdated)
}
private fun refreshAuthTokenIfNeeded(provider: String, profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "authRefreshRequested",
"provider" to provider,
"profile" to profile
)
)
val auth = result.state["auth"] as? Map<*, *>
val updated = fromStateObject(auth?.get("result")?.let { (it as? Map<*, *>)?.get("profile") } ?: auth?.get("result"), UserProfile::class.java)
if (updated != null && updated != profile) {
setActiveProfileState(updated)
onProfileUpdated(updated)
}
}
authCoordinator.refreshTokenIfNeeded("mal", profile, onProfileUpdated)
}
fun refreshExternalContinueWatching() {
@ -1291,19 +1015,7 @@ class HomeViewModel @Inject constructor(
}
fun loadLibraryData(activeProfile: UserProfile?) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "libraryHydrateRequested",
"profileId" to activeProfile?.id
)
)
val library = result.state["library"] as? Map<*, *> ?: return@launch
setWatchlistState(fromStateList(library["watchlist"], metaListType))
setCurrentWatchlistState(fromStateList(library["continueWatching"], metaListType))
setLikedItemsState(fromStateList(library["liked"], metaListType))
refreshDynamicRows()
}
syncCoordinator.loadLibrary(activeProfile)
}
fun syncTraktIntegration(
@ -1311,26 +1023,7 @@ class HomeViewModel @Inject constructor(
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "externalIntegrationSyncRequested",
"provider" to "trakt",
"profile" to profile,
"language" to profile.safeLanguage
)
)
val sync = result.state["sync"] as? Map<*, *>
val error = sync?.get("error")
val updated = fromStateObject((sync?.get("snapshot") as? Map<*, *>)?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
if (updated != null) {
setActiveProfileState(updated)
onProfileUpdated(updated)
setExternalContinueWatchingState(fromStateList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching"), metaListType))
refreshDynamicRows()
}
onComplete(error == null && updated != null)
}
syncCoordinator.syncTrakt(profile, onProfileUpdated, onComplete)
}
fun syncNuvioIntegration(
@ -1338,29 +1031,7 @@ class HomeViewModel @Inject constructor(
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "externalSyncRequested",
"provider" to "nuvio",
"profile" to profile,
"language" to profile.safeLanguage
)
)
val sync = result.state["sync"] as? Map<*, *>
val updated = fromStateObject(
sync?.get("profile")
?: (sync?.get("snapshot") as? Map<*, *>)?.get("profile")
?: (result.state["profile"] as? Map<*, *>)?.get("active"),
UserProfile::class.java
)
if (updated != null) {
setActiveProfileState(updated)
onProfileUpdated(updated)
loadLibraryData(updated)
}
onComplete(sync?.get("error") == null)
}
syncCoordinator.syncNuvio(profile, onProfileUpdated, onComplete, ::loadLibraryData)
}
suspend fun isNuvioHealthy(): Boolean = nuvioSyncCoordinator.isHealthy()
@ -1376,26 +1047,7 @@ class HomeViewModel @Inject constructor(
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
viewModelScope.launch {
val result = dispatchHeadless(
mapOf(
"type" to "externalIntegrationSyncRequested",
"provider" to "stremio",
"profile" to profile,
"language" to profile.safeLanguage
)
)
val sync = result.state["sync"] as? Map<*, *>
val snapshot = sync?.get("snapshot") as? Map<*, *>
val updated = fromStateObject(snapshot?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
if (updated != null) {
setActiveProfileState(updated)
setExternalContinueWatchingState(fromStateList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching"), metaListType))
onProfileUpdated(updated)
refreshDynamicRows()
}
onComplete(sync?.get("error") == null && updated != null)
}
syncCoordinator.syncStremio(profile, onProfileUpdated, onComplete)
}
private fun buildUserCollectionHomeCategories(profile: UserProfile?, showAboveContinueWatching: Boolean? = null): List<HomeCategory> {
@ -1439,32 +1091,7 @@ class HomeViewModel @Inject constructor(
}
private fun setCategoriesState(categories: List<HomeCategory>) {
val hiddenCollectionFolders = categories.filter { it.isCollectionFolderCategory() }
val visibleCategories = categories.filterNot { it.isCollectionFolderCategory() || it.type == "collection" }
when {
categories.isEmpty() -> _collectionFolderCategories.value = emptyMap()
hiddenCollectionFolders.isNotEmpty() || categories.any { it.type == "collection" } -> {
val existingFolders = _collectionFolderCategories.value
_collectionFolderCategories.value = hiddenCollectionFolders.associate { incoming ->
val existing = existingFolders[incoming.id]
incoming.id to if (existing == null || (existing.items.isEmpty() && !existing.folderSourcesLoading)) {
incoming
} else {
incoming.copy(
items = existing.items,
skip = existing.skip,
canLoadMore = existing.canLoadMore,
resultSources = existing.resultSources,
folderSourcesLoading = existing.folderSourcesLoading
)
}
}
}
}
val signature = visibleCategories.renderSignatureForHome()
if (categoriesRenderSignature == signature) return
categoriesRenderSignature = signature
_categories.value = visibleCategories
categoryState.setCategories(categories)
}
private fun setLoadingState(isLoading: Boolean) {
@ -1555,195 +1182,19 @@ class HomeViewModel @Inject constructor(
}
}
private suspend fun fromStateSourceMap(value: Any?): Map<String, HomeCatalogSource> {
return withContext(Dispatchers.Default) {
runCatching {
gson.fromJson<Map<String, HomeCatalogSource>>(gson.toJsonTree(value), discoverResultSourceMapType)
}.getOrNull() ?: emptyMap()
}
}
fun exchangeTraktCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
authCoordinator.exchangeCode("trakt", code, null, onProfileUpdated, onComplete)
fun exchangeTraktCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
exchangeAuthCode("trakt", code, null, onProfileUpdated, onComplete)
}
fun startTraktDeviceAuthorization(onCodeReady: (TraktDeviceCodeResponse) -> Unit, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean, String?) -> Unit) =
authCoordinator.startTraktDeviceAuthorization(onCodeReady, onProfileUpdated, onComplete)
fun startTraktDeviceAuthorization(
onCodeReady: (TraktDeviceCodeResponse) -> Unit,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean, String?) -> Unit
) {
viewModelScope.launch {
val profile = currentActiveProfile
if (profile == null) {
onComplete(false, "toast.trakt_connect_failed")
return@launch
}
val codeResult = dispatchHeadless(
mapOf(
"type" to "authFlowRequested",
"provider" to "trakt",
"mode" to "deviceCode"
)
)
val codeResponse = fromStateObject((codeResult.state["auth"] as? Map<*, *>)?.get("result"), TraktDeviceCodeResponse::class.java)
if (codeResponse == null) {
onComplete(false, "toast.trakt_connect_failed")
return@launch
}
onCodeReady(codeResponse)
val startedAt = System.currentTimeMillis()
val expiresAt = startedAt + codeResponse.expiresIn * 1000L
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
var failureMessageKey: String? = "toast.trakt_connect_failed"
while (System.currentTimeMillis() < expiresAt) {
delay(intervalMs)
val tokenResult = dispatchHeadless(
mapOf(
"type" to "authExchangeRequested",
"provider" to "traktDevice",
"code" to codeResponse.deviceCode,
"profile" to (currentActiveProfile ?: profile)
)
)
val auth = tokenResult.state["auth"] as? Map<*, *>
val result = auth?.get("result") as? Map<*, *>
val updated = fromStateObject(result?.get("profile"), UserProfile::class.java)
if (updated != null) {
setActiveProfileState(updated)
onProfileUpdated(updated)
setCategoriesState(emptyList())
onComplete(true, null)
return@launch
}
val errorCode = result?.get("errorCode") as? String
val httpCode = (result?.get("httpCode") as? Number)?.toInt()
when {
errorCode == "slow_down" || httpCode == 429 -> {
val retryAfterMs = (result.get("retryAfterSeconds") as? Number)?.toLong()?.let { it * 1000L }
intervalMs = (retryAfterMs ?: (intervalMs + 5_000L)).coerceAtMost(60_000L)
}
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
failureMessageKey = "toast.trakt_device_code_expired"
break
}
errorCode == "authorization_pending" || httpCode in setOf(400, 404, 409, 428) -> {
continue
}
else -> break
}
}
if (System.currentTimeMillis() >= expiresAt) {
failureMessageKey = "toast.trakt_device_code_expired"
}
onComplete(false, failureMessageKey)
}
}
fun exchangeMalCode(code: String, codeVerifier: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
authCoordinator.exchangeCode("mal", code, codeVerifier, onProfileUpdated, onComplete)
fun exchangeMalCode(code: String, codeVerifier: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
exchangeAuthCode("mal", code, codeVerifier, onProfileUpdated, onComplete)
}
fun exchangeSimklCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
authCoordinator.exchangeCode("simkl", code, null, onProfileUpdated, onComplete)
fun exchangeSimklCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
exchangeAuthCode("simkl", code, null, onProfileUpdated, onComplete)
}
fun exchangeAnilistCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
exchangeAuthCode("anilist", code, null, onProfileUpdated, onComplete)
}
private fun exchangeAuthCode(
provider: String,
code: String,
codeVerifier: String?,
onProfileUpdated: (UserProfile) -> Unit,
onComplete: (Boolean) -> Unit
) {
viewModelScope.launch {
val profile = currentActiveProfile
if (profile == null) {
onComplete(false)
return@launch
}
val result = dispatchHeadless(
mapOf(
"type" to "authExchangeRequested",
"provider" to provider,
"code" to code,
"codeVerifier" to codeVerifier,
"profile" to profile
)
)
val updated = fromStateObject((result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
if (updated != null) {
setActiveProfileState(updated)
onProfileUpdated(updated)
setCategoriesState(emptyList())
onComplete(true)
} else {
onComplete(false)
}
}
}
fun exchangeAnilistCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
authCoordinator.exchangeCode("anilist", code, null, onProfileUpdated, onComplete)
}
private fun List<MainAPI>.toCs3CatalogFeedDescriptors(): List<Cs3CatalogFeedDescriptor> {
return filter { it.hasMainPage }.flatMap { api ->
api.mainPage.mapIndexed { index, page ->
Cs3CatalogFeedDescriptor(
pluginName = api.name,
catalogName = page.name.takeIf { it.isNotBlank() } ?: api.name,
catalogIndex = index
)
}
}
}
private fun List<HomeCategory>.renderSignatureForHome(): Int {
var result = size
for (category in this) {
result = 31 * result + category.id.hashCode()
result = 31 * result + category.name.hashCode()
result = 31 * result + category.type.hashCode()
result = 31 * result + (category.addonIconUrl?.hashCode() ?: 0)
result = 31 * result + category.items.size
result = 31 * result + category.skip
result = 31 * result + category.canLoadMore.hashCode()
if (category.type == "collection_folder") {
continue
}
val visibleCount = minOf(category.items.size, 24)
for (index in 0 until visibleCount) {
val item = category.items[index]
result = 31 * result + item.id.hashCode()
result = 31 * result + item.type.hashCode()
result = 31 * result + item.name.hashCode()
result = 31 * result + (item.poster?.hashCode() ?: 0)
result = 31 * result + (item.background?.hashCode() ?: 0)
result = 31 * result + (item.logo?.hashCode() ?: 0)
result = 31 * result + (item.releaseInfo?.hashCode() ?: 0)
result = 31 * result + (item.reason?.hashCode() ?: 0)
result = 31 * result + (item.timeOffset?.hashCode() ?: 0)
result = 31 * result + (item.duration?.hashCode() ?: 0)
result = 31 * result + (item.lastVideoId?.hashCode() ?: 0)
result = 31 * result + (item.lastEpisodeName?.hashCode() ?: 0)
result = 31 * result + (item.continueWatchingPoster?.hashCode() ?: 0)
result = 31 * result + (item.continueWatchingBackground?.hashCode() ?: 0)
}
}
return result
}
private fun HomeCategory.isCollectionFolderCategory(): Boolean {
return type == "collection_folder"
}
private fun Video.continueWatchingTitleForHome(): String {
val seasonEpisode = if (season != null && number != null) "S$season, E$number" else null
val title = name?.trim()?.takeIf { it.isNotBlank() }
return when {
seasonEpisode != null && title != null -> "$seasonEpisode: $title"
seasonEpisode != null -> seasonEpisode
else -> title.orEmpty()
}
}

View file

@ -6,25 +6,15 @@ import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager
import com.fluxa.app.data.remote.AddonDescriptor
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.AddonRepository
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktRepository
import com.fluxa.app.data.repository.TraktWatchedState
import com.fluxa.app.domain.discovery.DiscoverCatalogContentLoader
import com.fluxa.app.domain.discovery.StreamDiscoveryUseCase
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
class HomeViewModelCoordinatorFactory @Inject constructor() {
internal fun search(addonRepository: AddonRepository, searchHistoryStore: SearchHistoryStore): HomeSearchCoordinator {
return HomeSearchCoordinator(addonRepository, searchHistoryStore)
}
internal fun episodeCalendar(repository: StremioRepository, watchlistManager: WatchlistManager): EpisodeCalendarLoader {
return EpisodeCalendarLoader(repository, watchlistManager)
}
internal fun library(
repository: StremioRepository,
traktRepository: TraktRepository,
@ -35,59 +25,6 @@ class HomeViewModelCoordinatorFactory @Inject constructor() {
return HomeLibraryCoordinator(repository, traktRepository, scope, coreState, gson)
}
internal fun auth(
repository: StremioRepository,
scope: CoroutineScope,
activeProfile: () -> UserProfile?,
invalidateHome: () -> Unit
): HomeAuthCoordinator {
return HomeAuthCoordinator(repository, scope, activeProfile, invalidateHome)
}
internal fun discover(
addonRepository: AddonRepository,
discoverCatalogContentLoader: DiscoverCatalogContentLoader,
scope: CoroutineScope,
activeProfile: () -> UserProfile?,
userAddons: () -> List<AddonDescriptor>,
setUserAddons: (List<AddonDescriptor>) -> Unit,
normalizeCatalogItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
coreState: FluxaCoreStateHandle
): HomeDiscoverController {
return HomeDiscoverController(
addonRepository = addonRepository,
discoverCatalogContentLoader = discoverCatalogContentLoader,
scope = scope,
activeProfile = activeProfile,
userAddons = userAddons,
setUserAddons = setUserAddons,
normalizeCatalogItems = normalizeCatalogItems,
coreState = coreState
)
}
internal fun calendar(
context: Context,
episodeCalendarLoader: EpisodeCalendarLoader,
watchlistManager: WatchlistManager,
scope: CoroutineScope,
activeProfile: () -> UserProfile?,
setActiveProfile: (UserProfile?) -> Unit,
onSnapshotLoaded: (HomeCalendarSnapshot) -> Unit,
coreState: FluxaCoreStateHandle
): HomeCalendarController {
return HomeCalendarController(
context = context,
episodeCalendarLoader = episodeCalendarLoader,
watchlistManager = watchlistManager,
scope = scope,
activeProfile = activeProfile,
setActiveProfile = setActiveProfile,
onSnapshotLoaded = onSnapshotLoaded,
coreState = coreState
)
}
internal fun playback(
context: Context,
repository: StremioRepository,

View file

@ -4,6 +4,9 @@ import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.ReleaseDateUtils
import com.fluxa.app.core.StremioId
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.Video
import com.fluxa.app.domain.discovery.Cs3CatalogFeedDescriptor
import com.lagradost.cloudstream3.MainAPI
internal fun preferredHomeRowLabels(lang: String): List<String> {
return listOf(
@ -53,3 +56,25 @@ internal fun isRecentlyReleased(dateStr: String?, windowDays: Int): Boolean {
internal fun isUpcomingRelease(dateStr: String?): Boolean {
return ReleaseDateUtils.isUpcoming(dateStr)
}
internal fun List<MainAPI>.toCs3CatalogFeedDescriptors(): List<Cs3CatalogFeedDescriptor> {
return filter { it.hasMainPage }.flatMap { api ->
api.mainPage.mapIndexed { index, page ->
Cs3CatalogFeedDescriptor(
pluginName = api.name,
catalogName = page.name.takeIf { it.isNotBlank() } ?: api.name,
catalogIndex = index
)
}
}
}
internal fun Video.continueWatchingTitleForHome(): String {
val seasonEpisode = if (season != null && number != null) "S$season, E$number" else null
val title = name?.trim()?.takeIf { it.isNotBlank() }
return when {
seasonEpisode != null && title != null -> "$seasonEpisode: $title"
seasonEpisode != null -> seasonEpisode
else -> title.orEmpty()
}
}

View file

@ -1,42 +1,8 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.local.OfflineDownloadItem
import java.io.File
import java.util.Locale
internal fun List<OfflineDownloadItem>.toOfflineDownloadGroups(): List<OfflineDownloadGroup> {
return groupBy { item -> item.metaId.ifBlank { item.title } }
.values
.map { episodes ->
val sorted = episodes.sortedWith(compareBy<OfflineDownloadItem> { it.videoId.orEmpty() }.thenByDescending { it.createdAt })
val first = sorted.first()
OfflineDownloadGroup(
key = first.metaId.ifBlank { first.title },
title = first.title,
poster = first.localPosterPath?.toFileImageModel() ?: first.poster ?: first.localBackgroundPath?.toFileImageModel() ?: first.background,
episodes = sorted,
totalBytes = sorted.sumOf { it.totalBytes.takeIf { bytes -> bytes > 0L } ?: it.downloadedBytes }
)
}
.sortedBy { it.title.lowercase(Locale.ROOT) }
}
internal fun OfflineDownloadItem.effectiveSizeLabel(): String? {
val size = totalBytes.takeIf { it > 0L } ?: downloadedBytes.takeIf { it > 0L } ?: return null
return size.formatDownloadBytes()
}
internal fun String.toFileImageModel(): String {
return File(this).takeIf { it.exists() }?.toURI()?.toString() ?: this
}
internal fun Long.formatDownloadBytes(): String {
val value = this.toDouble()
return when {
this >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.1f GB", value / (1024.0 * 1024.0 * 1024.0))
this >= 1024L * 1024L -> String.format(Locale.US, "%.0f MB", value / (1024.0 * 1024.0))
this >= 1024L -> String.format(Locale.US, "%.0f KB", value / 1024.0)
else -> "$this B"
}
}

View file

@ -2,6 +2,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.shared.feature.player.PlayerSeekFeedback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
@ -250,7 +251,7 @@ internal fun BoxScope.PlayerTransientOverlays(
.zIndex(300f)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
SeekFeedback(seekDirection, seekFeedbackMs.takeIf { it > 0L } ?: if (seekDirection > 0) seekForwardMs else seekBackwardMs)
PlayerSeekFeedback(seekDirection, seekFeedbackMs.takeIf { it > 0L } ?: if (seekDirection > 0) seekForwardMs else seekBackwardMs)
}
}
}

View file

@ -1,136 +1,17 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
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.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage
import com.fluxa.app.player.MediaTrack
import com.fluxa.app.data.remote.Meta
import java.util.Locale
internal fun nativeLanguageName(code: String): String {
val normalized = code.lowercase(Locale.ROOT)
val locale = Locale.forLanguageTag(normalized)
val native = locale.getDisplayLanguage(locale).trim()
return native.takeIf { it.isNotBlank() }?.replaceFirstChar { ch -> ch.titlecase(locale) } ?: code
return native.takeIf { it.isNotBlank() }?.replaceFirstChar { it.titlecase(locale) } ?: code
}
internal fun Meta.withCurrentEpisodeArtwork(artwork: String?): Meta {
val episodeArtwork = artwork?.takeIf { it.isNotBlank() } ?: return this
if (type != "series") return this
return copy(
continueWatchingPoster = episodeArtwork,
continueWatchingBackground = episodeArtwork
)
}
@Composable
fun SeekFeedback(direction: Int, seekMs: Long) {
val seconds = (seekMs / 1000L).coerceAtLeast(1L)
val transition = rememberInfiniteTransition(label = "seek")
val chevronShift by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(FluxaDimensions.AnimDuration.sidebarSlide, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
), label = "chevronShift"
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Canvas(modifier = Modifier.size(120.dp, 82.dp)) {
val sign = if (direction > 0) 1f else -1f
val stroke = size.minDimension * 0.075f
val centerY = size.height / 2f
val baseX = size.width / 2f - sign * size.width * 0.14f
repeat(3) { index ->
val phase = ((chevronShift + index * 0.28f) % 1f)
val alpha = 0.22f + phase * 0.58f
val x = baseX + sign * (index * size.width * 0.12f + phase * size.width * 0.05f)
drawLine(
color = Color.White.copy(alpha = alpha),
start = Offset(x - sign * size.width * 0.06f, centerY - size.height * 0.12f),
end = Offset(x + sign * size.width * 0.06f, centerY),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = Color.White.copy(alpha = alpha),
start = Offset(x + sign * size.width * 0.06f, centerY),
end = Offset(x - sign * size.width * 0.06f, centerY + size.height * 0.12f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
Text(
text = if (direction > 0) "+$seconds" else "-$seconds",
color = Color.White,
fontWeight = FontWeight.Black,
fontSize = 22.sp,
style = androidx.compose.ui.text.TextStyle(
shadow = Shadow(color = Color.Black.copy(alpha = 0.55f), offset = Offset(2f, 4f), blurRadius = 8f)
)
)
}
}
internal fun formatTime(ms: Long): String {
val totalSec = ms / 1000
val hr = totalSec / 3600
val min = (totalSec % 3600) / 60
val sec = totalSec % 60
return if (hr > 0) {
String.format(java.util.Locale.US, "%02d:%02d:%02d", hr, min, sec)
} else {
String.format(java.util.Locale.US, "%02d:%02d", min, sec)
}
return copy(continueWatchingPoster = episodeArtwork, continueWatchingBackground = episodeArtwork)
}

View file

@ -1,29 +0,0 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.data.remote.Stream
internal const val STREAM_SOURCE_MODE_MANUAL = "manual"
internal const val STREAM_SOURCE_MODE_FIRST = "first"
internal fun selectStreamIndex(
streams: List<Stream>,
currentVideoId: String?,
initialStreamIndex: Int,
savedUrl: String?,
savedTitle: String?,
sourceSelectionMode: String,
regexPattern: String?,
preferredBingeGroup: String?
): Int {
return FluxaCoreNative.selectStreamIndex(
streams = streams,
currentVideoId = currentVideoId,
initialStreamIndex = initialStreamIndex,
savedUrl = savedUrl,
savedTitle = savedTitle,
sourceSelectionMode = sourceSelectionMode,
regexPattern = regexPattern,
preferredBingeGroup = preferredBingeGroup
)
}

View file

@ -8,17 +8,12 @@ import com.fluxa.app.domain.discovery.*
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.models.SubtitleTrackRef
import com.fluxa.app.player.MediaTrack
import com.fluxa.app.player.resolveAudioLanguagePreference
import java.util.Locale
object TrackSelectionState {
private fun resolveAudioLanguagePref(profile: UserProfile?, meta: Meta): String? {
val isAnime = meta.genres?.any { it.lowercase().contains("anime") } == true
if (isAnime && profile?.safeAnimePreferJapaneseAudio == true) return "ja"
return when (val pref = profile?.preferredAudioLanguage) {
"original" -> meta.originalLanguage?.takeIf { it.isNotBlank() }
"device_language" -> Locale.getDefault().language.takeIf { it.isNotBlank() }
else -> pref
}
return resolveAudioLanguagePreference(profile, meta, Locale.getDefault().language)
}
fun resolvePreferredAudioLanguage(profile: UserProfile?, meta: Meta): String {

View file

@ -17,27 +17,10 @@ import okhttp3.Dns
import org.json.JSONObject
import java.net.Inet4Address
import java.util.concurrent.ConcurrentHashMap
internal data class SubtitleInfo(
val languageTag: String,
val label: String,
val url: String,
val mimeType: String,
val isAuto: Boolean
)
internal data class TrailerResult(
val streamUrl: String,
val audioUrl: String?,
val subtitles: List<SubtitleInfo>,
val streamMimeType: String?
)
internal sealed interface TrailerResolveResult {
data class Ok(val data: TrailerResult) : TrailerResolveResult
data object GeoBlocked : TrailerResolveResult
data object Failed : TrailerResolveResult
}
import com.fluxa.app.player.TrailerPolicy
import com.fluxa.app.player.TrailerResolveResult
import com.fluxa.app.player.TrailerResult
import com.fluxa.app.player.TrailerSubtitle
internal object TrailerResolver {
@ -52,11 +35,8 @@ internal object TrailerResolver {
fun init(cacheDir: java.io.File) = Unit
private fun videoId(youtubeUrl: String): String? =
Regex("(?:v=|youtu\\.be/|embed/)([A-Za-z0-9_-]{11})").find(youtubeUrl)?.groupValues?.get(1)
suspend fun resolve(youtubeUrl: String): TrailerResolveResult = withContext(Dispatchers.IO) {
val id = videoId(youtubeUrl) ?: return@withContext TrailerResolveResult.Failed
val id = TrailerPolicy.youtubeVideoId(youtubeUrl) ?: return@withContext TrailerResolveResult.Failed
memCache[id]?.let { return@withContext it }
val result = try {
@ -126,7 +106,7 @@ internal object TrailerResolver {
(0 until tracks.length()).mapNotNull { index ->
tracks.optJSONObject(index)?.let { track ->
val url = track.optString("baseUrl")
if (url.isBlank()) null else SubtitleInfo(
if (url.isBlank()) null else TrailerSubtitle(
languageTag = track.optString("languageCode", "und"),
label = track.optJSONObject("name")?.optString("simpleText")?.ifBlank { track.optString("languageCode") } ?: "",
url = url,
@ -187,7 +167,8 @@ internal object TrailerResolver {
val pixels = resolution?.let { it.groupValues[1].toLong() * it.groupValues[2].toLong() } ?: 0L
val bandwidth = Regex("BANDWIDTH=(\\d+)").find(streamAttributes)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
val url = masterUrl.toHttpUrlOrNull()?.resolve(line)?.toString() ?: return@forEach
if (best == null || pixels > best!!.first || (pixels == best!!.first && bandwidth > best!!.second)) {
val currentBest = best
if (currentBest == null || pixels > currentBest.first || (pixels == currentBest.first && bandwidth > currentBest.second)) {
best = Triple(pixels, bandwidth, url)
}
}
@ -203,7 +184,7 @@ internal object TrailerResolver {
subtitles = response.optJSONArray("subtitles")?.let { tracks ->
(0 until tracks.length()).map { i ->
val track = tracks.getJSONObject(i)
SubtitleInfo(
TrailerSubtitle(
languageTag = track.getString("languageTag"),
label = track.getString("label"),
url = track.getString("url"),

View file

@ -5,6 +5,7 @@ import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import com.fluxa.app.shared.feature.player.PlayerContentUiModel
import com.fluxa.app.shared.feature.player.formatPlayerTime
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@ -379,7 +380,7 @@ internal fun TvPlayerUIContent(
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = formatTime(if (isScrubbing) scrubPosition else position),
text = formatPlayerTime(if (isScrubbing) scrubPosition else position),
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
@ -397,7 +398,7 @@ internal fun TvPlayerUIContent(
Text(
text = when {
isSwitchingAudioSource -> playerText(lang, "english_source")
hasStartedPlaying && duration > 0 -> formatTime(duration)
hasStartedPlaying && duration > 0 -> formatPlayerTime(duration)
else -> playerStatusText(lang, detailedStatus)
},
color = Color.White,

View file

@ -1,6 +1,7 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.shared.feature.player.formatPlayerTime
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -198,7 +199,7 @@ internal fun MobilePlayerSeekbar(
)
}
Text(
text = formatTime(sliderPosition.toLong()),
text = formatPlayerTime(sliderPosition.toLong()),
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,

View file

@ -2,6 +2,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.shared.feature.player.formatPlayerTime
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
@ -207,7 +208,7 @@ internal fun MobilePlayerUIContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(if (isScrubbing) scrubPosition else position),
text = formatPlayerTime(if (isScrubbing) scrubPosition else position),
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
@ -230,7 +231,7 @@ internal fun MobilePlayerUIContent(
)
}
Text(
text = formatTime(duration),
text = formatPlayerTime(duration),
color = Color.White.copy(alpha = 0.84f),
fontSize = 14.sp,
fontWeight = FontWeight.Medium

View file

@ -409,8 +409,8 @@ class FluxaCoreBenchmarkTest {
assertTrue(nativeCacheStorageFunctions.isEmpty())
assertTrue(nativeFunctionNames.any { it == "cacheEntryPolicyJsonNative" })
assertTrue(nativeFunctionNames.any { it == "cacheTrimPolicyJsonNative" })
assertTrue(nativeFunctionNames.any { it == "startLocalStreamServerNative" })
assertTrue(nativeFunctionNames.any { it == "stopLocalStreamServerNative" })
assertFalse(nativeFunctionNames.any { it == "startLocalStreamServerNative" })
assertFalse(nativeFunctionNames.any { it == "stopLocalStreamServerNative" })
assertTrue(nativeFunctionNames.any { it == "parseManifestJsonNative" })
}

View file

@ -149,7 +149,7 @@ class FluxaCorePlatformContractTest {
assertEquals(600L, prefs.playerForwardBufferSeconds)
assertEquals(0L, prefs.playerBackBufferSeconds)
assertEquals("modern", prefs.detailEpisodeViewMode)
assertEquals("dv8", prefs.dolbyVisionFallbackMode)
assertEquals("hdr10", prefs.dolbyVisionFallbackMode)
assertEquals("manual", prefs.streamSourceSelectionMode)
}
@ -327,9 +327,9 @@ class FluxaCorePlatformContractTest {
@Test
fun nativeCoreSurfaceDoesNotExposeAddonResultMutationApis() {
val publicMethodNames = FluxaCoreNative::class.java.methods.map { it.name.lowercase() }
val mutationWords = listOf("rank", "sort", "reorder", "filterstreams", "filtermeta")
val forbiddenMethods = setOf("rankAddonResults", "reorderAddonResults", "filterAddonStreams", "filterAddonMeta")
assertFalse(publicMethodNames.any { method -> mutationWords.any(method::contains) })
assertFalse(publicMethodNames.any { it in forbiddenMethods.map(String::lowercase) })
}
@Test
@ -498,9 +498,9 @@ class FluxaCorePlatformContractTest {
"force" to true
)
)
assertEquals("readHomeBootstrap", home.effects.single().type)
assertEquals("p1", home.effects.single().payload["profileId"])
assertEquals("tr", home.effects.single().payload["language"])
val homeBootstrap = home.effects.single { it.type == "readHomeBootstrap" }
assertEquals("p1", homeBootstrap.payload["profileId"])
assertEquals("tr", homeBootstrap.payload["language"])
val library = engine.dispatch(
mapOf(
@ -508,8 +508,8 @@ class FluxaCorePlatformContractTest {
"item" to mapOf("id" to "tt1", "name" to "Movie", "type" to "movie")
)
)
assertEquals("writeLibraryCommand", library.effects.single().type)
assertEquals("p1", library.effects.single().payload["profileId"])
val libraryCommand = library.effects.single { it.type == "writeLibraryCommand" }
assertEquals("p1", libraryCommand.payload["profileId"])
val progress = engine.dispatch(
mapOf(
@ -642,8 +642,8 @@ class FluxaCorePlatformContractTest {
assertTrue(android.storage)
assertTrue(android.player)
assertTrue(android.plugins)
assertTrue(android.torrent)
assertTrue(android.localStream)
assertFalse(android.torrent)
assertFalse(android.localStream)
assertTrue(portable.http)
assertTrue(portable.storage)

View file

@ -1,13 +1,12 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.remote.MetaDetailResponse
import com.google.gson.GsonBuilder
import com.fluxa.app.data.remote.decodeMetaDetailPayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StremioAddonResourceClientTest {
@Test
fun aiometadataMetaDetailKeepsEpisodesTrailersAndNullableSeasonPosters() {
val json = """
@ -44,9 +43,7 @@ class StremioAddonResourceClientTest {
}
""".trimIndent()
val detail = GsonBuilder().create()
.fromJson(json, MetaDetailResponse::class.java)
.meta!!
val detail = decodeMetaDetailPayload(json)!!
val episode = detail.videos!!.single()
assertEquals("Step Into My Office", episode.name)
@ -77,9 +74,7 @@ class StremioAddonResourceClientTest {
}
""".trimIndent()
val detail = GsonBuilder().create()
.fromJson(json, MetaDetailResponse::class.java)
.meta!!
val detail = decodeMetaDetailPayload(json)!!
assertEquals(
listOf("Nicolas Cage", "Pedro Pascal", "Bella Ramsey"),

View file

@ -27,15 +27,15 @@ class DolbyVisionShaderMathTest {
}
private val m2Inv = arrayOf(
floatArrayOf(1f, 1f, 1f),
floatArrayOf(0.0086053084f, -0.0086053084f, 0.5600319713f),
floatArrayOf(0.1110296250f, -0.1110296250f, -0.3206271744f)
floatArrayOf(1f, 0.0086053084f, 0.1110296250f),
floatArrayOf(1f, -0.0086053084f, -0.1110296250f),
floatArrayOf(1f, 0.5600319713f, -0.3206271744f)
)
private val mLmsXyz = arrayOf(
floatArrayOf(2.0701800566f, 0.3650292938f, -0.0495955500f),
floatArrayOf(-1.3264812278f, 0.6804494857f, -0.0494211680f),
floatArrayOf(0.2066101766f, -0.0454801996f, 1.1879355232f)
floatArrayOf(2.0701800566f, -1.3264812278f, 0.2066101766f),
floatArrayOf(0.3650292938f, 0.6804494857f, -0.0454801996f),
floatArrayOf(-0.0495955500f, -0.0494211680f, 1.1879355232f)
)
private val mXyzBt709 = arrayOf(
@ -83,7 +83,11 @@ class DolbyVisionShaderMathTest {
val lms = floatArrayOf(pqEotf(lmsPq[0]), pqEotf(lmsPq[1]), pqEotf(lmsPq[2]))
val xyz = mat3Mul(mLmsXyz, lms)
val rgb = mat3Mul(mXyzBt2020, xyz).map { max(it, 0f) }.toFloatArray()
return floatArrayOf(pqOetf(rgb[0]), pqOetf(rgb[1]), pqOetf(rgb[2]))
return floatArrayOf(
pqOetf(rgb[0].coerceIn(0f, 1f)),
pqOetf(rgb[1].coerceIn(0f, 1f)),
pqOetf(rgb[2].coerceIn(0f, 1f))
)
}
@Test

View file

@ -1,5 +1,9 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.player.STREAM_SOURCE_MODE_FIRST
import com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL
import com.fluxa.app.player.STREAM_SOURCE_MODE_REGEX
import com.fluxa.app.data.remote.Stream
import org.junit.Assert.assertEquals
import org.junit.Test

View file

@ -24,14 +24,14 @@ val rustStreamingHostLibraryName = when {
else -> "libfluxa_streaming_engine.so"
}
val rustCoreDelegateFiles = mapOf(
"data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonUrls.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonUrls.kt" to listOf(
"FluxaCoreNative.normalizeManifestUrl",
"FluxaCoreNative.identity",
"FluxaCoreNative.manifestCandidates",
"FluxaCoreNative.baseUrl",
"FluxaCoreNative.preferHttpsAssetUrl"
),
"data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonProtocol.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonProtocol.kt" to listOf(
"FluxaCoreNative.supportsResource"
),
"app/src/main/java/com/fluxa/app/core/StremioId.kt" to listOf(
@ -41,7 +41,7 @@ val rustCoreDelegateFiles = mapOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/StreamPlaybackResolver.android.kt" to listOf(
"FluxaCoreNative.streamPlaybackInfo"
),
"player/src/main/java/com/fluxa/app/player/TorrentStreamManager.kt" to listOf(
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt" to listOf(
"TorrentCorePolicy.plan",
"TorrentCorePolicy.statusInfo"
),
@ -50,35 +50,35 @@ val rustCoreDelegateFiles = mapOf(
"FluxaCoreNative.streamPlaybackInfo",
"FluxaCoreNative.isTorrentPlaybackUrl"
),
"data/src/main/java/com/fluxa/app/data/repository/StremioAddonManifestClient.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioAddonManifestClient.kt" to listOf(
"FluxaCoreNative.buildResourceUrl",
"FluxaCoreNative.manifestFetchPlan",
"FluxaCoreNative.parseManifestJson",
"FluxaCoreNative.resolveManifestAssets",
"FluxaCoreNative.mergeLiveManifest"
),
"data/src/main/java/com/fluxa/app/data/repository/StremioAddonResourceClient.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioAddonResourceClient.kt" to listOf(
"FluxaCoreNative.parseAddonResourceResult",
"FluxaCoreNative.parseExtraArgs"
),
"player/src/main/java/com/fluxa/app/player/TorrentServerEngine.kt" to listOf(
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentServerEngine.kt" to listOf(
"FluxaStreamingNative.startTorrentServer",
"FluxaStreamingNative.stopTorrentServer"
),
"player/src/main/java/com/fluxa/app/player/TorrentCorePolicy.kt" to listOf(
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentCorePolicy.kt" to listOf(
"FluxaCoreNative.torrentRuntimeInfo",
"FluxaCoreNative.torrentStatusInfo"
),
"player/src/main/java/com/fluxa/app/player/MediaPlayerController.kt" to listOf(
"player/src/androidMain/kotlin/com/fluxa/app/player/MediaPlayerController.kt" to listOf(
"FluxaStreamingNative.dvRewriteSegmentBytes"
),
"app/src/main/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicy.kt" to listOf(
"app/src/main/java/com/fluxa/app/ui/catalog/AndroidStreamSourceSelectionPolicy.kt" to listOf(
"FluxaCoreNative.selectStreamIndex"
),
"app/src/main/java/com/fluxa/app/ui/catalog/ContinueWatchingListMerger.kt" to listOf(
"FluxaCoreNative.mergeContinueWatchingDuplicates"
),
"data/src/main/java/com/fluxa/app/domain/discovery/DiscoverCatalogContentLoader.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/DiscoverCatalogContentLoader.kt" to listOf(
"FluxaCoreNative.filterDiscoverResults",
"FluxaCoreNative.discoverCatalogCacheKey",
"FluxaCoreNative.providerSearchTerms"
@ -86,7 +86,7 @@ val rustCoreDelegateFiles = mapOf(
"app/src/main/java/com/fluxa/app/domain/discovery/StreamDiscovery.kt" to listOf(
"FluxaCoreNative.streamDiscoveryExecutionPolicy"
),
"data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/MetadataFeeds.kt" to listOf(
"FluxaCoreNative.normalizeContentType",
"FluxaCoreNative.stableFeedPart",
"FluxaCoreNative.effectiveMetadataFeedSelection",
@ -95,7 +95,7 @@ val rustCoreDelegateFiles = mapOf(
"FluxaCoreNative.orderedMetadataFeedKeys",
"FluxaCoreNative.moveMetadataFeedOrder"
),
"data/src/main/java/com/fluxa/app/domain/ContentIdentity.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/domain/ContentIdentity.kt" to listOf(
"FluxaCoreNative.contentTraktKey",
"FluxaCoreNative.contentMergeKeys",
"FluxaCoreNative.contentWatchedKeysBatch"
@ -110,7 +110,7 @@ val rustCoreDelegateFiles = mapOf(
"FluxaHeadlessEngine",
"HeadlessPlatformEnvironment"
),
"data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt" to listOf(
"NativeCoreCapabilitySet",
"coreCapabilitiesJsonNative"
),
@ -132,7 +132,7 @@ val rustCoreDelegateFiles = mapOf(
"FluxaCoreNative.subtitleLanguageMatches",
"preferredSubtitleIndex"
),
"data/src/main/java/com/fluxa/app/data/repository/TraktIntegration.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/TraktIntegration.kt" to listOf(
"FluxaCoreNative.traktHasClient",
"FluxaCoreNative.traktBearer",
"FluxaCoreNative.traktScrobbleUrl",
@ -145,7 +145,7 @@ val rustCoreDelegateFiles = mapOf(
"FluxaCoreNative.traktScrobbleMediaId",
"FluxaCoreNative.traktHistoryRequest"
),
"data/src/main/java/com/fluxa/app/data/repository/StremioRepository.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt" to listOf(
"FluxaCoreNative.libraryContinueWatchingItems",
"FluxaCoreNative.watchedVideoIds",
"FluxaCoreNative.playbackProgressItem",
@ -153,11 +153,11 @@ val rustCoreDelegateFiles = mapOf(
"FluxaCoreNative.watchedStateItems",
"FluxaCoreNative.traktHistoryRequest"
),
"data/src/main/java/com/fluxa/app/data/local/ProfileManager.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/local/ProfileManager.kt" to listOf(
"FluxaCoreNative.sanitizeProfile",
"FluxaCoreNative.profileLocalAddonsKey"
),
"data/src/main/java/com/fluxa/app/data/local/UserProfileSafePrefs.kt" to listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/local/UserProfileSafePrefs.kt" to listOf(
"FluxaCoreNative.safePlayerBufferCacheMb",
"FluxaCoreNative.safeStreamSourceSelectionMode"
),
@ -269,6 +269,86 @@ tasks.register("checkKmpCommonBoundary") {
}
}
tasks.register("checkSharedTransportModels") {
group = "verification"
description = "Fails when portable account and streaming models return to Android-only source sets."
doLast {
val forbiddenAndroidModels = listOf(
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioModels.kt",
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/StremioModels.kt",
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/TraktModels.kt",
"data/src/androidMain/kotlin/com/fluxa/app/player/TorrentModels.kt"
)
val requiredCommonModels = listOf(
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/NuvioModels.kt",
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/StremioModels.kt",
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/TraktModels.kt",
"data/src/commonMain/kotlin/com/fluxa/app/player/TorrentModels.kt"
)
val violations = forbiddenAndroidModels.filter { rootProject.file(it).exists() }
.map { "$it must not exist" } +
requiredCommonModels.filterNot { rootProject.file(it).exists() }
.map { "$it is required" }
if (violations.isNotEmpty()) {
throw GradleException(violations.joinToString("\n"))
}
}
}
tasks.register("checkLegacySourceSets") {
group = "verification"
description = "Fails when legacy Android compatibility source trees or mappings return."
doLast {
val violations = mutableListOf<String>()
listOf("data", "player").forEach { module ->
val legacyRoot = rootProject.file("$module/src/main/java")
if (legacyRoot.exists() && legacyRoot.walkTopDown().any { it.isFile }) {
violations += "$module/src/main/java must remain empty"
}
val buildFile = rootProject.file("$module/build.gradle.kts").readText()
if (buildFile.contains("src/main/java")) {
violations += "$module/build.gradle.kts must not map src/main/java"
}
}
if (violations.isNotEmpty()) {
throw GradleException(violations.joinToString("\n"))
}
}
}
tasks.register("checkNoDesktopTargets") {
group = "verification"
description = "Fails when desktop application targets or desktop source sets return."
doLast {
val sourceSetViolations = listOf("core", "data", "player", "shared")
.map { module -> rootProject.file("$module/src/desktop" + "Main") }
.filter { sourceSet -> sourceSet.exists() }
.map { sourceSet -> "${sourceSet.relativeTo(rootDir)} must not exist" }
val buildFiles = fileTree(rootDir) {
include("**/*.gradle.kts", "**/*.gradle")
exclude("**/build/**", ".gradle/**")
}
val forbiddenTokens = listOf(
"jvm(\"desktop\")",
"desktop" + "Main",
"compose.desktop" + ".application"
)
val buildViolations = buildFiles.files.flatMap { file ->
val text = file.readText()
forbiddenTokens.filter(text::contains).map { token ->
"${file.relativeTo(rootDir)} must not declare $token"
}
}
val violations = sourceSetViolations + buildViolations
if (violations.isNotEmpty()) {
throw GradleException(violations.joinToString("\n"))
}
}
}
tasks.register("checkAppleTypedCatalogBridge") {
group = "verification"
description = "Fails when the Apple catalog bridge falls back to JSON or notification handoffs."
@ -424,7 +504,7 @@ tasks.register("checkRustCoreBoundary") {
.map { call -> "$relativePath must delegate to $call" }
}
val urlFacade = rootProject.file("data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonUrls.kt")
val urlFacade = rootProject.file("data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonUrls.kt")
val duplicatedUrlLogic = if (urlFacade.exists()) {
val text = urlFacade.readText()
listOf("http://", "https://", "stremio://", "manifest.json", "Regex(")
@ -485,7 +565,7 @@ tasks.register("checkFluxaCoreJniSymbols") {
dependsOn("buildFluxaCoreHost")
doLast {
val nativeFile = rootProject.file("data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt")
val nativeFile = rootProject.file("data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt")
val libraryFile = rustCoreProjectDir.resolve("target/debug/$rustHostLibraryName")
if (!nativeFile.exists()) {
throw GradleException("${nativeFile.relativeTo(rootDir)} is missing")
@ -537,8 +617,8 @@ tasks.register("checkFluxaStreamingJniSymbols") {
dependsOn("buildFluxaStreamingEngineHost")
doLast {
val nativeFile = rootProject.file("player/src/main/java/com/fluxa/app/core/rust/FluxaStreamingNative.kt")
val libraryFile = rustCoreProjectDir.resolve("fluxa-streaming-engine/target/debug/$rustStreamingHostLibraryName")
val nativeFile = rootProject.file("player/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaStreamingNative.kt")
val libraryFile = rustCoreProjectDir.resolve("target/debug/$rustStreamingHostLibraryName")
if (!nativeFile.exists()) {
throw GradleException("${nativeFile.relativeTo(rootDir)} is missing")
}
@ -588,7 +668,13 @@ tasks.register("qualityCheck") {
dependsOn(
"checkKotlinFileSize",
"checkSharedUiBoundary",
"checkKmpCommonBoundary",
"checkSharedTransportModels",
"checkLegacySourceSets",
"checkNoDesktopTargets",
"checkAppleTypedCatalogBridge",
"checkAppleTvosKmpBoundary",
"checkSharedPlayerBoundary",
"checkRustCoreBoundary",
"checkFluxaCoreJniSymbols",
"checkFluxaStreamingJniSymbols",

View file

@ -26,14 +26,22 @@ class FluxaHeadlessEffectRunner(
private suspend fun drain(initial: NativeHeadlessEngineResult): NativeHeadlessEngineResult {
var current = initial
val patches = mutableListOf(initial)
val pending = ArrayDeque<NativeHeadlessEffect>()
val queuedIds = mutableSetOf<String>()
initial.effects.forEach { effect ->
if (queuedIds.add(effect.id)) pending.addLast(effect)
}
var remaining = maxEffectsPerDispatch
while (current.effects.isNotEmpty() && remaining > 0) {
current = engine.completeEffect(environment.execute(current.effects.first()))
while (pending.isNotEmpty() && remaining > 0) {
current = engine.completeEffect(environment.execute(pending.removeFirst()))
patches += current
current.effects.forEach { effect ->
if (queuedIds.add(effect.id)) pending.addLast(effect)
}
remaining--
}
return NativeHeadlessEngineResult(
effects = current.effects,
effects = pending.toList(),
stateProvider = { patches.fold(emptyMap<String, Any?>()) { state, patch -> state + patch.state } }
)
}

View file

@ -124,6 +124,7 @@ data class NativeProfileSafePrefs(
val forceSoftwareAudio: Boolean = false,
val preferredPlayer: String = "exoplayer",
val cardLayout: String = "vertical",
val detailEpisodeViewMode: String = "modern",
val continueWatchingLayout: String = "horizontal",
val continueWatchingArtwork: String = "episode",
val continueWatchingEnabled: Boolean = true,

View file

@ -0,0 +1,15 @@
package com.fluxa.app.player
data class NativeTorrentRuntimeInfo(
val normalizedLink: String = "",
val selectedFileIdx: Int? = null,
val selectedReason: String? = null,
val fallbackFileIndexes: List<Int> = emptyList(),
val streamUrl: String = ""
)
data class NativeTorrentStatusInfo(
val bufferProgress: Int = 0,
val isPlayableEnough: Boolean = false,
val statusKey: String = ""
)

View file

@ -67,7 +67,6 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
androidMain {
kotlin.srcDir("src/main/java")
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.bundles.coroutines)

View file

@ -311,83 +311,6 @@ data class NativeOfflineDownloadPlan(
val streamTitle: String? = null
)
data class NativeProfileSafePrefs(
val language: String = "en",
val subtitleSizePercent: Float = 100f,
val subtitleSize: Float = 20f,
val subtitleColor: Int = 0xFFFFFFFF.toInt(),
val subtitleBackgroundColor: Int = 0x80000000.toInt(),
val subtitleOutlineColor: Int = 0xFF000000.toInt(),
val subtitleTextOpacity: Float = 1f,
val subtitleBackgroundOpacity: Float = 0.5f,
val subtitleOutlineOpacity: Float = 1f,
val preferredSubtitleLanguage: String = "none",
val preferredAudioLanguage: String = "none",
val secondarySubtitleLanguage: String = "none",
val secondaryAudioLanguage: String = "none",
val ambientLight: Boolean = true,
val forceSoftwareAudio: Boolean = false,
val preferredPlayer: String = "exoplayer",
val cardLayout: String = "vertical",
val continueWatchingLayout: String = "horizontal",
val continueWatchingArtwork: String = "episode",
val continueWatchingEnabled: Boolean = true,
val resolvedContinueWatchingLayout: String = "horizontal",
val subtitleShadow: Boolean = true,
val autoEnableSubtitles: Boolean = true,
val autoSkipIntro: Boolean = false,
val autoPlayNextEpisode: Boolean = true,
val nextEpisodeThresholdPercent: Float = 90f,
val watchedThresholdPercent: Float = 80f,
val seekForwardSeconds: Long = 10L,
val seekBackwardSeconds: Long = 10L,
val playerBufferCacheMb: Int = 100,
val playerForwardBufferSeconds: Long = 120L,
val playerBackBufferSeconds: Long = 30L,
val timezoneConversionEnabled: Boolean = true,
val torrentWifiOnly: Boolean = false,
val torrentMaxConnections: Long = 60L,
val torrentSpeedPreset: String = "default",
val torrentCachePreset: String = "auto",
val appTheme: String = "dark",
val accentColorArgb: Int = 0xFFFFFFFF.toInt(),
val cardCornerPreset: String = "medium",
val interfaceDensity: String = "medium",
val amoledMode: Boolean = false,
val posterWidthPreset: String = "medium",
val posterLandscapeMode: Boolean = false,
val posterHideTitles: Boolean = false,
val animationsEnabled: Boolean = true,
val reduceMotion: Boolean = false,
val startPage: String = "home",
val notificationsEnabled: Boolean = true,
val alertNewEpisodes: Boolean = true,
val automaticUpdates: Boolean = true,
val backgroundPlayback: Boolean = false,
val pictureInPicture: Boolean = true,
val playbackSpeed: Float = 1f,
val holdToSpeedEnabled: Boolean = true,
val holdSpeed: Float = 2f,
val dolbyVisionFallbackMode: String = "auto",
val tunneledPlayback: Boolean = false,
val useIntroDb: Boolean = true,
val useAniSkip: Boolean = true,
val defaultQuality: String = "1080p",
val mobileDataUsage: String = "medium",
val hdrPlayback: Boolean = true,
val resumePlayback: Boolean = true,
val autoplayMode: String = "next_episode",
val streamSourceSelectionMode: String = "manual",
val streamSourceRegexPattern: String = "",
val tryBingeGroup: Boolean = false,
val showHeroSection: Boolean = true,
val traktTokenExpiresAt: Long = 0L,
val traktLastSyncAt: Long = 0L,
val traktLastSyncedItems: Long = 0L,
val traktLastContinueWatchingCount: Long = 0L,
val traktLastWatchlistCount: Long = 0L
)
class FluxaHeadlessEngineHandle internal constructor(
private val handle: Long,
private val gson: Gson

View file

@ -25,44 +25,8 @@ import okhttp3.Request
import java.io.File
import java.util.UUID
data class OfflineSubtitleOption(
val label: String,
val language: String?,
val url: String
)
data class OfflineDownloadItem(
val id: String,
val profileId: String?,
val language: String? = null,
val metaId: String,
val metaType: String,
val title: String,
val episodeTitle: String?,
val videoId: String?,
val poster: String?,
val background: String?,
val logo: String? = null,
val localPosterPath: String? = null,
val localBackgroundPath: String? = null,
val localLogoPath: String? = null,
val streamTitle: String? = null,
val videoPath: String = "",
val subtitlePath: String? = null,
val subtitleLabel: String? = null,
val subtitleLanguage: String? = null,
val downloadId: Long = 0L,
val createdAt: Long = System.currentTimeMillis(),
val status: String = "queued",
val progress: Int = 0,
val downloadedBytes: Long = 0L,
val totalBytes: Long = 0L,
val speedBytesPerSecond: Long = 0L,
val etaSeconds: Long = -1L,
val error: String? = null
) {
val isPlayable: Boolean get() = status == "downloaded" && File(videoPath).exists()
}
val OfflineDownloadItem.isPlayable: Boolean
get() = status == "downloaded" && File(videoPath).exists()
class OfflineDownloadManager private constructor(private val context: Context) {
private val appContext = context.applicationContext
@ -133,7 +97,8 @@ class OfflineDownloadManager private constructor(private val context: Context) {
subtitlePath = downloadedSubtitle?.absolutePath,
subtitleLabel = subtitle?.label,
subtitleLanguage = subtitle?.language,
downloadId = 0L
downloadId = 0L,
createdAt = System.currentTimeMillis()
)
upsert(item)
val intent = Intent(appContext, OfflineDownloadService::class.java)

View file

@ -2,20 +2,25 @@ package com.fluxa.app.data.remote
import com.google.gson.annotations.SerializedName
data class NuvioCredentials(val email: String, val password: String)
data class NuvioRefreshRequest(@SerializedName("refresh_token") val refreshToken: String)
data class NuvioHealth(val status: String? = null)
data class NuvioRefreshRequestDto(@SerializedName("refresh_token") val refreshToken: String)
data class NuvioUser(val id: String, val email: String)
fun NuvioRefreshRequest.toDto(): NuvioRefreshRequestDto = NuvioRefreshRequestDto(refreshToken)
data class NuvioSession(
data class NuvioSessionDto(
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("expires_in") val expiresIn: Long?,
val user: NuvioUser?
)
) {
fun toDomain(): NuvioSession = NuvioSession(
accessToken = accessToken,
refreshToken = refreshToken,
expiresIn = expiresIn,
user = user
)
}
data class NuvioProfile(
data class NuvioProfileDto(
val id: String,
@SerializedName("user_id") val userId: String? = null,
@SerializedName("profile_index") val profileIndex: Int,
@ -23,9 +28,11 @@ data class NuvioProfile(
@SerializedName("avatar_color_hex") val avatarColorHex: String?,
@SerializedName("avatar_id") val avatarId: String?,
@SerializedName("avatar_url") val avatarUrl: String?
)
) {
fun toDomain(): NuvioProfile = NuvioProfile(id, userId, profileIndex, name, avatarColorHex, avatarId, avatarUrl)
}
data class NuvioAddon(
data class NuvioAddonDto(
val id: String? = null,
@SerializedName("user_id") val userId: String? = null,
@SerializedName("profile_id") val profileId: Int? = null,
@ -33,9 +40,11 @@ data class NuvioAddon(
val name: String?,
val enabled: Boolean,
@SerializedName("sort_order") val sortOrder: Int
)
) {
fun toDomain(): NuvioAddon = NuvioAddon(id, userId, profileId, url, name, enabled, sortOrder)
}
data class NuvioLibraryItem(
data class NuvioLibraryItemDto(
@SerializedName("content_id") val contentId: String,
@SerializedName("content_type") val contentType: String,
val name: String,
@ -48,9 +57,14 @@ data class NuvioLibraryItem(
@SerializedName("poster_shape") val posterShape: String? = null,
@SerializedName("addon_base_url") val addonBaseUrl: String? = null,
@SerializedName("added_at") val addedAt: Long? = null
)
) {
fun toDomain(): NuvioLibraryItem = NuvioLibraryItem(
contentId, contentType, name, poster, background, description, releaseInfo, imdbRating,
genres, posterShape, addonBaseUrl, addedAt
)
}
data class NuvioWatchProgress(
data class NuvioWatchProgressDto(
@SerializedName("content_id") val contentId: String,
@SerializedName("content_type") val contentType: String,
@SerializedName("video_id") val videoId: String,
@ -60,18 +74,24 @@ data class NuvioWatchProgress(
val duration: Long,
@SerializedName("last_watched") val lastWatched: Long,
@SerializedName("progress_key") val progressKey: String? = null
)
) {
fun toDomain(): NuvioWatchProgress = NuvioWatchProgress(
contentId, contentType, videoId, season, episode, position, duration, lastWatched, progressKey
)
}
data class NuvioWatchedItem(
data class NuvioWatchedItemDto(
@SerializedName("content_id") val contentId: String,
@SerializedName("content_type") val contentType: String,
val title: String? = null,
val season: Int?,
val episode: Int?,
@SerializedName("watched_at") val watchedAt: Long? = null
)
) {
fun toDomain(): NuvioWatchedItem = NuvioWatchedItem(contentId, contentType, title, season, episode, watchedAt)
}
data class NuvioCollectionFolderSource(
data class NuvioCollectionFolderSourceDto(
val provider: String? = null,
@SerializedName("addonId") val addonId: String?,
@SerializedName("catalogId") val catalogId: String?,
@ -85,9 +105,14 @@ data class NuvioCollectionFolderSource(
@SerializedName("sortBy") val sortBy: String? = null,
@SerializedName("sortHow") val sortHow: String? = null,
val filters: Map<String, Any?>? = null
)
) {
fun toDomain(): NuvioCollectionFolderSource = NuvioCollectionFolderSource(
provider, addonId, catalogId, type, genre, title, mediaType, traktListId,
tmdbSourceType, tmdbId, sortBy, sortHow, filters
)
}
data class NuvioCollectionFolder(
data class NuvioCollectionFolderDto(
val id: String?,
val title: String?,
@SerializedName("coverImageUrl") val coverImageUrl: String?,
@ -99,10 +124,15 @@ data class NuvioCollectionFolder(
@SerializedName("heroVideoUrl") val heroVideoUrl: String? = null,
@SerializedName("tileShape") val tileShape: String?,
@SerializedName("hideTitle") val hideTitle: Boolean?,
@SerializedName(value = "catalogSources", alternate = ["sources"]) val catalogSources: List<NuvioCollectionFolderSource>?
)
@SerializedName(value = "catalogSources", alternate = ["sources"]) val catalogSources: List<NuvioCollectionFolderSourceDto>?
) {
fun toDomain(): NuvioCollectionFolder = NuvioCollectionFolder(
id, title, coverImageUrl, coverEmoji, focusGifUrl, focusGifEnabled, titleLogoUrl,
heroBackdropUrl, heroVideoUrl, tileShape, hideTitle, catalogSources?.map { it.toDomain() }
)
}
data class NuvioCollection(
data class NuvioCollectionDto(
val id: String?,
val title: String?,
@SerializedName("backdropImageUrl") val backdropImageUrl: String?,
@ -111,19 +141,30 @@ data class NuvioCollection(
@SerializedName("viewMode") val viewMode: String?,
@SerializedName("showAllTab") val showAllTab: Boolean?,
@SerializedName("focusGlowEnabled") val focusGlowEnabled: Boolean? = null,
val folders: List<NuvioCollectionFolder>?,
val folders: List<NuvioCollectionFolderDto>?,
val community: Map<String, Any?>? = null
)
) {
fun toDomain(): NuvioCollection = NuvioCollection(
id, title, backdropImageUrl, pinToTop, showOnHome, viewMode, showAllTab,
focusGlowEnabled, folders?.map { it.toDomain() }, community
)
}
data class NuvioCollectionRow(
@SerializedName("collections_json") val collectionsJson: List<NuvioCollection>?
)
data class NuvioCollectionRowDto(
@SerializedName("collections_json") val collectionsJson: List<NuvioCollectionDto>?
) {
fun toDomain(): NuvioCollectionRow = NuvioCollectionRow(collectionsJson?.map { it.toDomain() })
}
data class NuvioProfileSettingsRow(
data class NuvioProfileSettingsRowDto(
@SerializedName("settings_json") val settingsJson: Map<String, Any?>?
)
) {
fun toDomain(): NuvioProfileSettingsRow = NuvioProfileSettingsRow(settingsJson)
}
data class NuvioAvatar(
data class NuvioAvatarDto(
val id: String,
@SerializedName("storage_path") val storagePath: String?
)
) {
fun toDomain(): NuvioAvatar = NuvioAvatar(id, storagePath)
}

View file

@ -14,19 +14,19 @@ interface NuvioService {
suspend fun healthCheck(): Response<NuvioHealth>
@POST("auth/v1/signup")
suspend fun signUp(@Body credentials: NuvioCredentials): Response<NuvioSession>
suspend fun signUp(@Body credentials: NuvioCredentials): Response<NuvioSessionDto>
@POST("auth/v1/token")
suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response<NuvioSession>
suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response<NuvioSessionDto>
@POST("auth/v1/token")
suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequest): Response<NuvioSession>
suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequestDto): Response<NuvioSessionDto>
@GET("auth/v1/user")
suspend fun getUser(@Header("Authorization") authorization: String): Response<NuvioUser>
@POST("rest/v1/rpc/sync_pull_profiles")
suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, String> = emptyMap()): Response<List<NuvioProfile>>
suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, String> = emptyMap()): Response<List<NuvioProfileDto>>
@GET("rest/v1/addons")
suspend fun pullAddons(
@ -34,7 +34,7 @@ interface NuvioService {
@Query("select") select: String = "*",
@Query("profile_id") profileId: String,
@Query("order") order: String = "sort_order"
): Response<List<NuvioAddon>>
): Response<List<NuvioAddonDto>>
@POST("rest/v1/rpc/sync_push_profiles")
suspend fun pushProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any?>): Response<Unit>
@ -79,20 +79,20 @@ interface NuvioService {
suspend fun pushProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any?>): Response<Unit>
@POST("rest/v1/rpc/sync_pull_library")
suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioLibraryItem>>
suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioLibraryItemDto>>
@POST("rest/v1/rpc/sync_pull_watch_progress")
suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchProgress>>
suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchProgressDto>>
@POST("rest/v1/rpc/sync_pull_watched_items")
suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchedItem>>
suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchedItemDto>>
@POST("rest/v1/rpc/sync_pull_collections")
suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioCollectionRow>>
suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioCollectionRowDto>>
@POST("rest/v1/rpc/sync_pull_profile_settings_blob")
suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioProfileSettingsRow>>
suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioProfileSettingsRowDto>>
@POST("rest/v1/rpc/get_avatar_catalog")
suspend fun listAvatars(@Body body: Map<String, String> = emptyMap()): Response<List<NuvioAvatar>>
suspend fun listAvatars(@Body body: Map<String, String> = emptyMap()): Response<List<NuvioAvatarDto>>
}

View file

@ -12,17 +12,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Fans mark-watched / watchlist actions out to whichever of Trakt, Simkl, and MAL
* the active profile has connected. Mirrors fluxa-desktop's pushMarkWatchedExternal /
* pushWatchlistExternal (src/core/externalSync.ts) each provider's push is independent
* and failures are swallowed so one provider never blocks another or the local action.
*
* Each provider push only counts as successful when the HTTP response actually says so
* a 401 clears (Simkl) or refreshes-then-retries (MAL, which has a refresh token) the
* stored credentials instead of failing silently forever. A null response means "there
* was nothing to push" (e.g. no IMDB id) and is skipped without stamping a sync time.
*/
class ExternalSyncPushCoordinator @Inject constructor(
private val api: TraktApi,
private val repository: StremioRepository,
@ -30,8 +19,8 @@ class ExternalSyncPushCoordinator @Inject constructor(
private val nuvioSyncCoordinator: NuvioSyncCoordinator
) {
suspend fun pushMarkWatched(profile: UserProfile, meta: Meta, episodes: List<Video>, watched: Boolean) = coroutineScope {
if (!profile.traktAccessToken.isNullOrBlank()) {
launch { runCatching { pushTraktMarkWatched(profile.traktAccessToken!!, meta, episodes, watched) } }
profile.traktAccessToken?.takeIf(String::isNotBlank)?.let { token ->
launch { runCatching { pushTraktMarkWatched(token, meta, episodes, watched) } }
}
if (!profile.simklAccessToken.isNullOrBlank()) {
launch { pushSimklWithTokenHandling(profile) { token -> pushSimklMarkWatched(token, meta, episodes, watched) } }
@ -45,8 +34,8 @@ class ExternalSyncPushCoordinator @Inject constructor(
}
suspend fun pushWatchlist(profile: UserProfile, meta: Meta, isInWatchlist: Boolean) = coroutineScope {
if (!profile.traktAccessToken.isNullOrBlank()) {
launch { runCatching { pushTraktWatchlist(profile.traktAccessToken!!, meta, isInWatchlist) } }
profile.traktAccessToken?.takeIf(String::isNotBlank)?.let { token ->
launch { runCatching { pushTraktWatchlist(token, meta, isInWatchlist) } }
}
if (isInWatchlist && !profile.simklAccessToken.isNullOrBlank()) {
launch { pushSimklWithTokenHandling(profile) { token -> pushSimklWatchlist(token, meta) } }
@ -68,9 +57,9 @@ class ExternalSyncPushCoordinator @Inject constructor(
private suspend fun pushSimklWithTokenHandling(profile: UserProfile, call: suspend (String) -> Response<Unit>?) {
val token = profile.simklAccessToken?.takeIf { it.isNotBlank() } ?: return
val response = runCatching { call(token) }.getOrNull() ?: return
when {
response.isSuccessful -> profileManager.saveProfile(profile.copy(simklLastSyncAt = System.currentTimeMillis()))
response.code() == 401 -> profileManager.saveProfile(profile.copy(simklAccessToken = null))
when (ExternalSyncPolicy.afterResponse(ExternalSyncProvider.SIMKL, response.code())) {
ExternalSyncAction.STAMP_SUCCESS -> profileManager.saveProfile(profile.copy(simklLastSyncAt = System.currentTimeMillis()))
ExternalSyncAction.CLEAR_CREDENTIALS -> profileManager.saveProfile(profile.copy(simklAccessToken = null))
else -> Unit
}
}
@ -78,11 +67,14 @@ class ExternalSyncPushCoordinator @Inject constructor(
private suspend fun pushMalWithTokenHandling(profile: UserProfile, call: suspend (String) -> Response<Unit>?) {
val token = profile.malAccessToken?.takeIf { it.isNotBlank() } ?: return
val firstResponse = runCatching { call(token) }.getOrNull() ?: return
if (firstResponse.isSuccessful) {
profileManager.saveProfile(profile.copy(malLastSyncAt = System.currentTimeMillis()))
return
when (ExternalSyncPolicy.afterResponse(ExternalSyncProvider.MAL, firstResponse.code())) {
ExternalSyncAction.STAMP_SUCCESS -> {
profileManager.saveProfile(profile.copy(malLastSyncAt = System.currentTimeMillis()))
return
}
ExternalSyncAction.REFRESH_CREDENTIALS -> Unit
else -> return
}
if (firstResponse.code() != 401) return
val refreshToken = profile.malRefreshToken?.takeIf { it.isNotBlank() } ?: return
val refreshed = runCatching { repository.refreshMalToken(refreshToken) }.getOrNull() ?: return
@ -93,11 +85,11 @@ class ExternalSyncPushCoordinator @Inject constructor(
)
val retryResponse = runCatching { call(refreshed.accessToken) }.getOrNull()
when {
retryResponse?.isSuccessful == true -> {
when (ExternalSyncPolicy.afterRefreshRetry(retryResponse?.code())) {
ExternalSyncAction.STAMP_SUCCESS -> {
profileManager.saveProfile(refreshedProfile.copy(malLastSyncAt = System.currentTimeMillis()))
}
retryResponse?.code() == 401 -> {
ExternalSyncAction.CLEAR_CREDENTIALS -> {
profileManager.saveProfile(refreshedProfile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null))
}
else -> profileManager.saveProfile(refreshedProfile)
@ -151,30 +143,21 @@ class ExternalSyncPushCoordinator @Inject constructor(
}
private suspend fun pushMalMarkWatched(token: String, meta: Meta, episodes: List<Video>): Response<Unit>? {
if (meta.type != "series") return null
val malId = MAL_ID_REGEX.find(meta.id)?.groupValues?.get(1)?.toIntOrNull() ?: return null
val highestEpisode = episodes.mapNotNull { it.number }.maxOrNull() ?: return null
val totalEpisodes = meta.episodesCount
val status = if (totalEpisodes != null && highestEpisode >= totalEpisodes) "completed" else "watching"
val update = ExternalSyncPolicy.malWatchedUpdate(meta, episodes) ?: return null
return api.malUpdateListStatus(
malId = malId,
malId = update.malId,
token = "Bearer $token",
numWatchedEpisodes = highestEpisode,
status = status
numWatchedEpisodes = update.watchedEpisodes,
status = update.status
)
}
private suspend fun pushMalWatchlist(token: String, meta: Meta): Response<Unit>? {
if (meta.type != "series") return null
val malId = MAL_ID_REGEX.find(meta.id)?.groupValues?.get(1)?.toIntOrNull() ?: return null
val update = ExternalSyncPolicy.malWatchlistUpdate(meta) ?: return null
return api.malUpdateListStatus(
malId = malId,
malId = update.malId,
token = "Bearer $token",
status = "plan_to_watch"
status = update.status
)
}
companion object {
private val MAL_ID_REGEX = Regex("^mal:(\\d+)$")
}
}

View file

@ -12,6 +12,8 @@ import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.NuvioCredentials
import com.fluxa.app.data.remote.NuvioService
import com.fluxa.app.data.remote.NuvioSession
import com.fluxa.app.data.remote.NuvioSessionDto
import com.fluxa.app.data.remote.toDto
import retrofit2.Response
enum class NuvioImportStep { PROFILE, ADDONS, LIBRARY, PROGRESS, HISTORY, COLLECTIONS }
@ -27,7 +29,7 @@ class NuvioAccountImportCoordinator(
val refreshToken = profile.nuvioRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
val expiresAt = profile.nuvioTokenExpiresAt ?: 0L
if (!profile.nuvioAccessToken.isNullOrBlank() && expiresAt > System.currentTimeMillis() + 60_000L) return profile
val session = nuvioService.refreshToken(request = com.fluxa.app.data.remote.NuvioRefreshRequest(refreshToken)).requireBody()
val session = nuvioService.refreshToken(request = com.fluxa.app.data.remote.NuvioRefreshRequest(refreshToken).toDto()).requireBody().toDomain()
val refreshed = profile.copy(
nuvioAccessToken = session.accessToken,
nuvioRefreshToken = session.refreshToken.ifBlank { refreshToken },
@ -65,10 +67,10 @@ class NuvioAccountImportCoordinator(
return import(refreshedProfile, session, onStep)
}
private suspend inline fun authenticate(call: suspend () -> retrofit2.Response<NuvioSession>): Result<NuvioSession> {
private suspend inline fun authenticate(call: suspend () -> retrofit2.Response<NuvioSessionDto>): Result<NuvioSession> {
return try {
val response = call()
val session = response.body()
val session = response.body()?.toDomain()
if (response.isSuccessful && session != null) {
Result.success(session)
} else {
@ -99,10 +101,10 @@ class NuvioAccountImportCoordinator(
var profile = connectedProfile
val profiles = importOrDefault(NuvioImportStep.PROFILE, emptyList()) {
nuvioService.pullProfiles(token).requireBody()
nuvioService.pullProfiles(token).requireBody().map { it.toDomain() }
}
val avatars = importOrDefault(NuvioImportStep.PROFILE, emptyList()) {
nuvioService.listAvatars().requireBody()
nuvioService.listAvatars().requireBody().map { it.toDomain() }
}
val primary = profiles.firstOrNull { it.profileIndex == connectedProfile.nuvioProfileIndex }
?: profiles.minByOrNull { it.profileIndex }
@ -153,16 +155,16 @@ class NuvioAccountImportCoordinator(
onStep(NuvioImportStep.PROFILE)
val addons = try {
nuvioService.pullAddons(token, profileId = "eq.$profileIndex").requireBody()
nuvioService.pullAddons(token, profileId = "eq.$profileIndex").requireBody().map { it.toDomain() }
} catch (error: Exception) {
Log.w("NuvioImport", "Import step ${NuvioImportStep.ADDONS} failed; continuing without it", error)
null
}
if (addons != null) {
val orderedAddons = addons.sortedBy { it.sortOrder }
val addonState = NuvioImportPolicy.addonState(addons)
profile = profile.copy(
localAddons = orderedAddons.map { it.url }.distinct(),
disabledLocalAddons = orderedAddons.filterNot { it.enabled }.map { it.url }.distinct()
localAddons = addonState.installedUrls,
disabledLocalAddons = addonState.disabledUrls
)
}
onStep(NuvioImportStep.ADDONS)
@ -173,45 +175,23 @@ class NuvioAccountImportCoordinator(
Log.w("NuvioImport", "Import step ${NuvioImportStep.LIBRARY} failed; continuing without it", error)
null
}
val metaById = libraryItems.orEmpty().associate { item ->
item.contentId to Meta(
id = item.contentId,
name = item.name,
type = item.contentType,
poster = item.poster,
background = item.background,
description = item.description,
releaseInfo = item.releaseInfo,
imdbRating = item.imdbRating?.toString(),
genres = item.genres
)
}
val metaById = NuvioImportPolicy.libraryMetas(libraryItems.orEmpty())
if (libraryItems != null) {
watchlistManager.replaceWatchlist(metaById.values.toList())
}
onStep(NuvioImportStep.LIBRARY)
val watchProgress = try {
nuvioService.pullWatchProgress(token, mapOf("p_profile_id" to profileIndex)).requireBody()
nuvioService.pullWatchProgress(token, mapOf("p_profile_id" to profileIndex)).requireBody().map { it.toDomain() }
} catch (error: Exception) {
Log.w("NuvioImport", "Import step ${NuvioImportStep.PROGRESS} failed; keeping existing playback progress", error)
null
}
val importedProgressVideoIds = watchProgress.orEmpty().mapTo(mutableSetOf()) { entry ->
if (entry.season != null && entry.episode != null) {
"${entry.contentId}:${entry.season}:${entry.episode}"
} else {
entry.videoId.ifBlank { entry.contentId }
}
}
val importedProgressVideoIds = watchProgress.orEmpty().mapTo(mutableSetOf(), NuvioImportPolicy::progressVideoId)
if (watchProgress != null) {
watchlistManager.clearAllPlaybackProgress()
}
val latestProgressByContent = watchProgress.orEmpty()
.groupBy { it.contentId }
.values
.mapNotNull { entries -> entries.maxWithOrNull(compareBy({ it.lastWatched }, { it.position })) }
.sortedByDescending { it.lastWatched }
val latestProgressByContent = NuvioImportPolicy.latestProgressByContent(watchProgress.orEmpty())
for (entry in latestProgressByContent) {
val detail = if (
entry.contentType == "series" ||
@ -247,11 +227,7 @@ class NuvioAccountImportCoordinator(
continueWatchingBackground = episode?.thumbnail
)
}
val videoId = if (entry.season != null && entry.episode != null) {
"${entry.contentId}:${entry.season}:${entry.episode}"
} else {
entry.videoId.ifBlank { entry.contentId }
}
val videoId = NuvioImportPolicy.progressVideoId(entry)
val isUpNextPlaceholder = entry.position <= NUVIO_UP_NEXT_POSITION_MS &&
entry.duration == NUVIO_UP_NEXT_DURATION_MS
watchlistManager.savePlaybackProgress(
@ -277,79 +253,16 @@ class NuvioAccountImportCoordinator(
if (watchedItems != null) {
watchlistManager.clearAllWatchedEpisodes()
}
val watchedBySeries = mutableMapOf<String, MutableSet<String>>()
for (item in watchedItems.orEmpty()) {
val videoId = if (item.season != null && item.episode != null) {
"${item.contentId}:${item.season}:${item.episode}"
} else {
item.contentId
}
if (videoId !in importedProgressVideoIds) {
watchedBySeries.getOrPut(item.contentId) { mutableSetOf() }.add(videoId)
}
}
val watchedBySeries = NuvioImportPolicy.watchedVideoIds(watchedItems.orEmpty(), importedProgressVideoIds)
for ((seriesId, videoIds) in watchedBySeries) {
watchlistManager.markEpisodesWatched(seriesId, videoIds)
}
onStep(NuvioImportStep.HISTORY)
val collectionRows = importOrDefault(NuvioImportStep.COLLECTIONS, emptyList()) {
nuvioService.pullCollections(token, mapOf("p_profile_id" to profileIndex)).requireBody()
}
val collections = collectionRows.flatMap { it.collectionsJson.orEmpty() }.map { collection ->
LibraryUserCollection(
id = collection.id ?: java.util.UUID.randomUUID().toString(),
title = collection.title ?: "",
imageUrl = collection.backdropImageUrl,
showOnHome = collection.showOnHome ?: true,
pinToTop = collection.pinToTop,
viewMode = collection.viewMode,
showAllTab = collection.showAllTab,
focusGlowEnabled = collection.focusGlowEnabled ?: true,
community = collection.community,
folders = collection.folders?.map { folder ->
LibraryUserCollectionFolder(
id = folder.id ?: java.util.UUID.randomUUID().toString(),
title = folder.title ?: "",
coverImageUrl = folder.coverImageUrl,
coverEmoji = folder.coverEmoji,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled ?: true,
titleLogoUrl = folder.titleLogoUrl,
heroBackdropUrl = folder.heroBackdropUrl,
heroVideoUrl = folder.heroVideoUrl,
shape = folder.tileShape.toNuvioFolderShape(),
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources?.map { source ->
LibraryCatalogSource(
addonId = source.addonId,
catalogId = source.catalogId ?: "",
type = source.type ?: "movie",
genre = source.genre
)
}?.filter { it.catalogId.isNotBlank() }
,
sources = folder.catalogSources?.map { source ->
LibraryRemoteSource(
provider = source.provider ?: "addon",
title = source.title,
mediaType = source.mediaType,
traktListId = source.traktListId,
tmdbSourceType = source.tmdbSourceType,
tmdbId = source.tmdbId,
sortBy = source.sortBy,
sortHow = source.sortHow,
filters = source.filters,
addonId = source.addonId,
catalogId = source.catalogId,
type = source.type,
genre = source.genre
)
}
)
}
)
nuvioService.pullCollections(token, mapOf("p_profile_id" to profileIndex)).requireBody().map { it.toDomain() }
}
val collections = NuvioImportPolicy.collections(collectionRows) { java.util.UUID.randomUUID().toString() }
if (collectionRows.isNotEmpty()) {
profile = profile.copy(libraryCollections = collections)
}
@ -379,7 +292,7 @@ class NuvioAccountImportCoordinator(
val page = nuvioService.pullLibrary(
token,
mapOf("p_profile_id" to profileIndex, "p_limit" to NUVIO_PAGE_SIZE, "p_offset" to offset)
).requireBody()
).requireBody().map { it.toDomain() }
items += page
offset += page.size
} while (page.size == NUVIO_PAGE_SIZE)
@ -393,7 +306,7 @@ class NuvioAccountImportCoordinator(
val page = nuvioService.pullWatchedItems(
token,
mapOf("p_profile_id" to profileIndex, "p_page" to pageNumber, "p_page_size" to NUVIO_PAGE_SIZE)
).requireBody()
).requireBody().map { it.toDomain() }
items += page
pageNumber += 1
} while (page.size == NUVIO_PAGE_SIZE)
@ -407,12 +320,6 @@ class NuvioAccountImportCoordinator(
}
}
private fun String?.toNuvioFolderShape(): String = when (this?.trim()?.lowercase()) {
"landscape", "wide" -> "wide"
"square" -> "square"
else -> "poster"
}
private fun stableNuvioProfileId(profile: UserProfile, profileIndex: Int): String {
val identity = profile.nuvioUserId ?: profile.nuvioEmail ?: profile.email
return java.util.UUID.nameUUIDFromBytes("nuvio:$identity:$profileIndex".toByteArray()).toString()

View file

@ -6,6 +6,7 @@ import com.fluxa.app.data.local.safeDisabledLocalAddonIds
import com.fluxa.app.data.local.LibraryUserCollection
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.NuvioRefreshRequest
import com.fluxa.app.data.remote.toDto
import com.fluxa.app.data.remote.NuvioService
import com.fluxa.app.data.remote.Video
import retrofit2.Response
@ -27,7 +28,7 @@ class NuvioSyncCoordinator @Inject constructor(
"Bearer $token",
mapOf(
"p_profile_id" to index,
"p_collections_json" to current.safeLibraryCollections.map(LibraryUserCollection::toNuvioCollection)
"p_collections_json" to current.safeLibraryCollections.map(NuvioSyncRequests::collection)
)
).requireSuccess()
profileManager.saveProfile(current.copy(nuvioLastSyncAt = System.currentTimeMillis()))
@ -47,7 +48,7 @@ class NuvioSyncCoordinator @Inject constructor(
)
}
val authorization = "Bearer $token"
val existing = nuvioService.pullAddons(authorization, profileId = "eq.$index").body()
val existing = nuvioService.pullAddons(authorization, profileId = "eq.$index").body()?.map { it.toDomain() }
?: throw IllegalStateException("Unable to load Nuvio add-ons")
val desiredByUrl = desired.associateBy { it["url"] as String }
nuvioService.pushAddons(
@ -75,13 +76,13 @@ class NuvioSyncCoordinator @Inject constructor(
val current = freshProfile(profile) ?: return
val token = current.nuvioAccessToken ?: return
val index = current.nuvioProfileIndex ?: return
val remote = nuvioService.pullLibrary("Bearer $token", mapOf("p_profile_id" to index, "p_limit" to 500, "p_offset" to 0)).bodyOrNull()
?.map { item -> item.toNuvioLibraryItem() }
val remote = nuvioService.pullLibrary("Bearer $token", mapOf("p_profile_id" to index, "p_limit" to 500, "p_offset" to 0)).bodyOrNull()?.map { it.toDomain() }
?.map(NuvioSyncRequests::libraryItem)
?.toMutableList()
?: return
val existingIndex = remote.indexOfFirst { it["content_id"] == meta.id && it["content_type"] == meta.type }
if (isInWatchlist && existingIndex >= 0) remote.removeAt(existingIndex)
if (isInWatchlist) remote.add(meta.toNuvioLibraryItem())
if (isInWatchlist) remote.add(NuvioSyncRequests.libraryItem(meta, System.currentTimeMillis()))
else if (existingIndex >= 0) remote.removeAt(existingIndex)
nuvioService.pushLibrary(
"Bearer $token",
@ -97,15 +98,7 @@ class NuvioSyncCoordinator @Inject constructor(
val current = freshProfile(profile) ?: return
val token = current.nuvioAccessToken ?: return
val index = current.nuvioProfileIndex ?: return
val items = if (meta.type == "movie") {
listOf(mapOf("content_id" to meta.id, "content_type" to "movie", "title" to meta.name, "watched_at" to System.currentTimeMillis()))
} else {
episodes.mapNotNull { episode ->
val season = episode.season ?: return@mapNotNull null
val number = episode.number ?: return@mapNotNull null
mapOf("content_id" to meta.id, "content_type" to meta.type, "title" to meta.name, "season" to season, "episode" to number, "watched_at" to System.currentTimeMillis())
}
}
val items = NuvioSyncRequests.watchedItems(meta, episodes, System.currentTimeMillis())
if (items.isEmpty()) return
val keys = items.map { item ->
mapOf(
@ -127,31 +120,12 @@ class NuvioSyncCoordinator @Inject constructor(
val current = freshProfile(profile) ?: return
val token = current.nuvioAccessToken ?: return
val index = current.nuvioProfileIndex ?: return
val episode = videoId?.split(':')?.takeIf { it.size == 3 }
val season = episode?.getOrNull(1)?.toIntOrNull()
val episodeNumber = episode?.getOrNull(2)?.toIntOrNull()
val progressKey = if (season != null && episodeNumber != null) {
"${meta.id}_s${season}e$episodeNumber"
} else {
meta.id
}
val entry = NuvioSyncRequests.playbackProgress(meta, videoId, position, duration, System.currentTimeMillis())
nuvioService.pushWatchProgress(
"Bearer $token",
mapOf(
"p_profile_id" to index,
"p_entries" to listOf(
mapOf(
"content_id" to meta.id,
"content_type" to meta.type,
"video_id" to (videoId ?: meta.id),
"position" to position,
"duration" to duration,
"last_watched" to System.currentTimeMillis(),
"season" to season,
"episode" to episodeNumber,
"progress_key" to progressKey
)
)
"p_entries" to listOf(entry)
)
).requireSuccess()
profileManager.saveProfile(current.copy(nuvioLastSyncAt = System.currentTimeMillis()))
@ -162,7 +136,7 @@ class NuvioSyncCoordinator @Inject constructor(
val expiresAt = profile.nuvioTokenExpiresAt ?: 0L
if (expiresAt > System.currentTimeMillis() + 60_000L) return profile
val refreshToken = profile.nuvioRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
val session = nuvioService.refreshToken(request = NuvioRefreshRequest(refreshToken)).bodyOrNull() ?: return null
val session = nuvioService.refreshToken(request = NuvioRefreshRequest(refreshToken).toDto()).bodyOrNull()?.toDomain() ?: return null
return profile.copy(
nuvioAccessToken = session.accessToken.ifBlank { accessToken },
nuvioRefreshToken = session.refreshToken.ifBlank { refreshToken },
@ -173,69 +147,6 @@ class NuvioSyncCoordinator @Inject constructor(
}
}
private fun LibraryUserCollection.toNuvioCollection(): Map<String, Any?> = mapOf(
"id" to id,
"title" to title,
"backdropImageUrl" to imageUrl,
"showOnHome" to showOnHome,
"pinToTop" to pinToTop,
"viewMode" to viewMode,
"showAllTab" to showAllTab,
"focusGlowEnabled" to focusGlowEnabled,
"community" to community,
"folders" to folders.orEmpty().map { folder ->
mapOf(
"id" to folder.id,
"title" to folder.title,
"coverImageUrl" to (folder.coverImageUrl ?: folder.imageUrl),
"coverEmoji" to folder.coverEmoji,
"focusGifUrl" to folder.focusGifUrl,
"focusGifEnabled" to folder.focusGifEnabled,
"titleLogoUrl" to folder.titleLogoUrl,
"heroBackdropUrl" to folder.heroBackdropUrl,
"heroVideoUrl" to folder.heroVideoUrl,
"tileShape" to folder.shape,
"hideTitle" to folder.hideTitle,
"sources" to (folder.sources.orEmpty().ifEmpty {
folder.catalogSources.orEmpty().map { source ->
com.fluxa.app.data.local.LibraryRemoteSource(provider = "addon", addonId = source.addonId, catalogId = source.catalogId, type = source.type, genre = source.genre)
}
}).map { source ->
mapOf("provider" to source.provider, "title" to source.title, "mediaType" to source.mediaType, "traktListId" to source.traktListId, "tmdbSourceType" to source.tmdbSourceType, "tmdbId" to source.tmdbId, "sortBy" to source.sortBy, "sortHow" to source.sortHow, "filters" to source.filters, "addonId" to source.addonId, "catalogId" to source.catalogId, "type" to source.type, "genre" to source.genre)
}
)
}
)
private fun Meta.toNuvioLibraryItem(): Map<String, Any?> = mapOf(
"content_id" to id,
"content_type" to type,
"name" to name,
"poster" to poster,
"background" to background,
"description" to description,
"release_info" to releaseInfo,
"imdb_rating" to imdbRating?.toDoubleOrNull(),
"genres" to genres,
"poster_shape" to "POSTER",
"added_at" to System.currentTimeMillis()
)
private fun com.fluxa.app.data.remote.NuvioLibraryItem.toNuvioLibraryItem(): Map<String, Any?> = mapOf(
"content_id" to contentId,
"content_type" to contentType,
"name" to name,
"poster" to poster,
"background" to background,
"description" to description,
"release_info" to releaseInfo,
"imdb_rating" to imdbRating,
"genres" to genres,
"poster_shape" to (posterShape ?: "POSTER"),
"addon_base_url" to addonBaseUrl,
"added_at" to addedAt
)
private fun <T> Response<T>.bodyOrNull(): T? = if (isSuccessful) body() else null
private fun Response<*>.requireSuccess() {

View file

@ -31,26 +31,11 @@ object SimklIntegration {
return FluxaCoreNative.simklScrobbleBody(idsJson, isEpisode, season.toLong(), episode.toLong(), timePosSec, durationSec)
}
/** Builds the {movies:[...]} or {shows:[{ids, seasons:[{number, episodes:[{number}]}]}]} body Simkl's
* /sync/history (and /sync/history/remove) endpoints expect. */
fun historyBody(imdbId: String, isSeries: Boolean, episodesBySeasonNumber: Map<Int, List<Int>>): Map<String, Any> {
val ids = mapOf("imdb" to imdbId)
return if (!isSeries) {
mapOf("movies" to listOf(mapOf("ids" to ids)))
} else {
val seasons = episodesBySeasonNumber.map { (season, episodes) ->
mapOf("number" to season, "episodes" to episodes.map { mapOf("number" to it) })
}
mapOf("shows" to listOf(mapOf("ids" to ids, "seasons" to seasons)))
}
return SimklSyncRequests.history(imdbId, isSeries, episodesBySeasonNumber)
}
fun watchlistBody(imdbId: String, isSeries: Boolean): Map<String, Any> {
val ids = mapOf("imdb" to imdbId)
return if (isSeries) {
mapOf("shows" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
} else {
mapOf("movies" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
}
return SimklSyncRequests.watchlist(imdbId, isSeries)
}
}

View file

@ -9,6 +9,7 @@ import com.fluxa.app.data.remote.AuthRequest
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.MetaDetail
import com.fluxa.app.data.remote.MetaDetailResponse
import com.fluxa.app.data.remote.decodeMetaDetailPayload
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.StreamResponse
import com.fluxa.app.data.remote.StremioService
@ -307,11 +308,6 @@ class StremioAddonResourceClient @Inject constructor(
}
}
private fun decodeMetaDetailPayload(json: String): MetaDetail? {
return runCatching { stremioJson.decodeFromString<MetaDetailResponse>(json) }.getOrNull()?.meta
?: runCatching { stremioJson.decodeFromString<MetaDetail>(json) }.getOrNull()
}
private fun MetaDetail.applyAppExtras(): MetaDetail {
val extras = appExtras ?: return this
var updated = this
@ -456,7 +452,7 @@ class StremioAddonResourceClient @Inject constructor(
}
}
internal fun mapAppExtraSeasonPosters(
fun mapAppExtraSeasonPosters(
videos: List<Video>,
seasonsCount: Int?,
posters: List<String?>

View file

@ -1,20 +1,9 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.TraktSyncItem
internal fun TraktSyncItem.toMeta(type: String, unknownName: () -> String): Meta? {
val traktRes = movie ?: show ?: return null
val id = TraktIntegration.contentIdFrom(traktRes.ids) ?: return null
return Meta(
id = id,
name = traktRes.title ?: unknownName(),
type = type,
poster = null,
releaseInfo = traktRes.year?.toString(),
released = traktRes.year?.let { "$it-01-01" }
)
}
internal fun TraktSyncItem.toMeta(type: String, unknownName: () -> String) =
TraktSyncMapper.toMeta(this, type, unknownName, TraktIntegration::contentIdFrom)
internal suspend fun fetchTraktSyncPages(
request: suspend (page: Int, limit: Int) -> retrofit2.Response<List<TraktSyncItem>>

View file

@ -7,31 +7,6 @@ import com.fluxa.app.domain.discovery.*
import com.fluxa.app.core.rust.FluxaCoreNative
import java.util.Locale
data class MetadataFeedOption(
val key: String,
val label: String,
val transportUrl: String,
val type: String,
val id: String,
val genre: String? = null
)
data class DiscoverCatalogOption(
val key: String,
val label: String,
val transportUrl: String,
val type: String,
val id: String,
val genres: List<String>,
val requiresGenre: Boolean = false
)
data class Cs3CatalogFeedDescriptor(
val pluginName: String,
val catalogName: String,
val catalogIndex: Int
)
fun buildMetadataFeedOptions(addons: List<AddonDescriptor>, language: String? = "en"): List<MetadataFeedOption> {
val addonFeeds = addons
.flatMap { addon -> addon.toMetadataFeedOptions() }

View file

@ -2,7 +2,7 @@ package com.fluxa.app.player
import com.google.gson.annotations.SerializedName
data class TorrentStatus(
data class AndroidTorrentStatus(
@SerializedName("hash") val hash: String,
@SerializedName("title") val title: String,
@SerializedName("download_speed") val downloadSpeed: Double,
@ -14,25 +14,28 @@ data class TorrentStatus(
@SerializedName("preload") val preload: Int = 0,
@SerializedName("loaded_size") val loadedSize: Long = 0,
@SerializedName("preload_size") val preloadSize: Long = 0,
@SerializedName("file_stats") val fileStats: List<TorrentFileStat>?
)
@SerializedName("file_stats") val fileStats: List<AndroidTorrentFileStat>?
) {
fun toShared(): TorrentStatus = TorrentStatus(
hash = hash,
title = title,
downloadSpeed = downloadSpeed,
activePeers = activePeers,
totalPeers = totalPeers,
progress = progress,
stat = stat,
statString = statString,
preload = preload,
loadedSize = loadedSize,
preloadSize = preloadSize,
fileStats = fileStats?.map(AndroidTorrentFileStat::toShared)
)
}
data class TorrentFileStat(
data class AndroidTorrentFileStat(
@SerializedName("id") val id: Int,
@SerializedName("path") val path: String,
@SerializedName("length") val length: Long
)
data class NativeTorrentRuntimeInfo(
val normalizedLink: String = "",
val selectedFileIdx: Int? = null,
val selectedReason: String? = null,
val fallbackFileIndexes: List<Int> = emptyList(),
val streamUrl: String = ""
)
data class NativeTorrentStatusInfo(
val bufferProgress: Int = 0,
val isPlayableEnough: Boolean = false,
val statusKey: String = ""
)
) {
fun toShared(): TorrentFileStat = TorrentFileStat(id = id, path = path, length = length)
}

View file

@ -0,0 +1,38 @@
package com.fluxa.app.data.local
data class OfflineSubtitleOption(
val label: String,
val language: String?,
val url: String
)
data class OfflineDownloadItem(
val id: String,
val profileId: String?,
val language: String? = null,
val metaId: String,
val metaType: String,
val title: String,
val episodeTitle: String?,
val videoId: String?,
val poster: String?,
val background: String?,
val logo: String? = null,
val localPosterPath: String? = null,
val localBackgroundPath: String? = null,
val localLogoPath: String? = null,
val streamTitle: String? = null,
val videoPath: String = "",
val subtitlePath: String? = null,
val subtitleLabel: String? = null,
val subtitleLanguage: String? = null,
val downloadId: Long = 0L,
val createdAt: Long = 0L,
val status: String = "queued",
val progress: Int = 0,
val downloadedBytes: Long = 0L,
val totalBytes: Long = 0L,
val speedBytesPerSecond: Long = 0L,
val etaSeconds: Long = -1L,
val error: String? = null
)

View file

@ -8,6 +8,8 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
@ -66,6 +68,13 @@ data class DetailTrailer(
@Serializable
data class MetaDetailResponse(val meta: MetaDetail? = null)
private val metaPayloadJson = Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }
fun decodeMetaDetailPayload(payload: String): MetaDetail? {
return runCatching { metaPayloadJson.decodeFromString<MetaDetailResponse>(payload) }.getOrNull()?.meta
?: runCatching { metaPayloadJson.decodeFromString<MetaDetail>(payload) }.getOrNull()
}
@Serializable(with = MetaDetailSerializer::class)
data class MetaDetail(
val id: String,

View file

@ -0,0 +1,136 @@
package com.fluxa.app.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
@Serializable
data class NuvioCredentials(val email: String, val password: String)
@Serializable
data class NuvioRefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@Serializable
data class NuvioHealth(val status: String? = null)
@Serializable
data class NuvioUser(val id: String, val email: String)
@Serializable
data class NuvioSession(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long?,
val user: NuvioUser?
)
@Serializable
data class NuvioProfile(
val id: String,
val userId: String? = null,
val profileIndex: Int,
val name: String?,
val avatarColorHex: String?,
val avatarId: String?,
val avatarUrl: String?
)
@Serializable
data class NuvioAddon(
val id: String? = null,
val userId: String? = null,
val profileId: Int? = null,
val url: String,
val name: String?,
val enabled: Boolean,
val sortOrder: Int
)
@Serializable
data class NuvioAvatar(val id: String, val storagePath: String?)
@Serializable
data class NuvioLibraryItem(
val contentId: String,
val contentType: String,
val name: String,
val poster: String?,
val background: String?,
val description: String?,
val releaseInfo: String?,
val imdbRating: Double?,
val genres: List<String>?,
val posterShape: String? = null,
val addonBaseUrl: String? = null,
val addedAt: Long? = null
)
@Serializable
data class NuvioWatchProgress(
val contentId: String,
val contentType: String,
val videoId: String,
val season: Int?,
val episode: Int?,
val position: Long,
val duration: Long,
val lastWatched: Long,
val progressKey: String? = null
)
@Serializable
data class NuvioWatchedItem(
val contentId: String,
val contentType: String,
val title: String? = null,
val season: Int?,
val episode: Int?,
val watchedAt: Long? = null
)
data class NuvioCollectionFolderSource(
val provider: String? = null,
val addonId: String?,
val catalogId: String?,
val type: String?,
val genre: String? = null,
val title: String? = null,
val mediaType: String? = null,
val traktListId: Long? = null,
val tmdbSourceType: String? = null,
val tmdbId: Long? = null,
val sortBy: String? = null,
val sortHow: String? = null,
val filters: Map<String, Any?>? = null
)
data class NuvioCollectionFolder(
val id: String?,
val title: String?,
val coverImageUrl: String?,
val coverEmoji: String?,
val focusGifUrl: String?,
val focusGifEnabled: Boolean? = null,
val titleLogoUrl: String?,
val heroBackdropUrl: String? = null,
val heroVideoUrl: String? = null,
val tileShape: String?,
val hideTitle: Boolean?,
val catalogSources: List<NuvioCollectionFolderSource>?
)
data class NuvioCollection(
val id: String?,
val title: String?,
val backdropImageUrl: String?,
val pinToTop: Boolean?,
val showOnHome: Boolean? = null,
val viewMode: String?,
val showAllTab: Boolean?,
val focusGlowEnabled: Boolean? = null,
val folders: List<NuvioCollectionFolder>?,
val community: Map<String, Any?>? = null
)
data class NuvioCollectionRow(val collectionsJson: List<NuvioCollection>?)
data class NuvioProfileSettingsRow(val settingsJson: Map<String, Any?>?)

View file

@ -1,13 +1,19 @@
package com.fluxa.app.data.remote
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class DatastoreRequest(val authKey: String, val collection: String)
@Serializable
data class DatastoreResponse(val result: List<LibraryItem>)
@Serializable
data class DatastorePutRequest(val authKey: String, val collection: String, val items: List<LibraryItem>)
@Serializable
data class LibraryItem(
@SerializedName("_id") val id: String,
val _id: String,
val name: String,
val type: String,
val poster: String?,
@ -15,8 +21,11 @@ data class LibraryItem(
val logo: String? = null,
val state: LibraryItemState? = null,
val lastWatched: String? = null
)
) {
val id: String get() = _id
}
@Serializable
data class LibraryItemState(
val lastWatched: String? = null,
val timeOffset: Long? = 0,
@ -26,7 +35,9 @@ data class LibraryItemState(
val flaggedWatched: Int? = 0
)
@Serializable
data class CatalogResponse(val metas: List<Meta>? = null)
data class StreamResponse(val streams: List<Stream>? = null)
data class SubtitleResponse(val subtitles: List<SubtitleData>? = null) // Stremio format
data class SubtitleResponse(val subtitles: List<SubtitleData>? = null)

View file

@ -1,7 +1,8 @@
package com.fluxa.app.data.remote
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class TraktEpisode(
val season: Int,
val number: Int,
@ -10,20 +11,16 @@ data class TraktEpisode(
val ids: TraktIds? = null
)
data class TraktTranslation(
val title: String?,
val overview: String?,
val language: String?
)
@Serializable
data class TraktTranslation(val title: String?, val overview: String?, val language: String?)
data class TraktSearchResult(
val show: TraktShow?
)
@Serializable
data class TraktSearchResult(val show: TraktShow?)
data class TraktShow(
val ids: TraktIds
)
@Serializable
data class TraktShow(val ids: TraktIds)
@Serializable
data class TraktIds(
val trakt: Int? = null,
val imdb: String? = null,
@ -32,6 +29,7 @@ data class TraktIds(
val tvdb: Int? = null
)
@Serializable
data class TraktSyncItem(
val rank: Int? = null,
val id: Long? = null,
@ -40,23 +38,26 @@ data class TraktSyncItem(
val seasons: List<TraktWatchedSeason>? = null
)
data class TraktListItem(
val movie: TraktSummary? = null,
val show: TraktSummary? = null
)
@Serializable
data class TraktWatchedSeason(
val number: Int? = null,
val episodes: List<TraktWatchedEpisode>? = null
)
@Serializable
data class TraktWatchedEpisode(
val number: Int? = null,
val runtime: Int? = null,
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
val last_watched_at: String? = null,
val plays: Int? = null
)
) {
val lastWatchedAt: String? get() = last_watched_at
}
@Serializable
data class TraktListItem(val movie: TraktSummary? = null, val show: TraktSummary? = null)
@Serializable
data class TraktPlaybackItem(
val id: Long? = null,
val progress: Float? = null,
@ -67,13 +68,10 @@ data class TraktPlaybackItem(
val episode: TraktEpisode? = null
)
data class TraktSummary(
val title: String?,
val year: Int?,
val ids: TraktIds,
val runtime: Int? = null
)
@Serializable
data class TraktSummary(val title: String?, val year: Int?, val ids: TraktIds, val runtime: Int? = null)
@Serializable
data class TraktHistoryItem(
val id: Long?,
val watched_at: String?,
@ -84,13 +82,15 @@ data class TraktHistoryItem(
val episode: TraktEpisode? = null
)
@Serializable
data class TraktScrobbleRequest(
val movie: TraktSummary? = null,
val show: TraktSummary? = null,
val episode: TraktScrobbleEpisode? = null,
val progress: Float // 0 to 100
val progress: Float
)
@Serializable
data class TraktScrobbleEpisode(
val ids: TraktIds? = null,
val season: Int? = null,
@ -98,6 +98,7 @@ data class TraktScrobbleEpisode(
val title: String? = null
)
@Serializable
data class TraktScrobbleResponse(
val action: String? = null,
val progress: Float? = null,
@ -108,50 +109,55 @@ data class TraktScrobbleResponse(
val episode: TraktEpisode? = null
)
@Serializable
data class TraktHistorySyncRequest(
val movies: List<TraktHistoryMovie>? = null,
val shows: List<TraktHistoryShow>? = null
)
data class TraktHistoryMovie(
val ids: TraktIds
)
@Serializable
data class TraktHistoryMovie(val ids: TraktIds)
data class TraktHistoryShow(
val ids: TraktIds,
val seasons: List<TraktHistorySeason>? = null
)
@Serializable
data class TraktHistoryShow(val ids: TraktIds, val seasons: List<TraktHistorySeason>? = null)
data class TraktHistorySeason(
val number: Int,
val episodes: List<TraktHistoryEpisode>? = null
)
@Serializable
data class TraktHistorySeason(val number: Int, val episodes: List<TraktHistoryEpisode>? = null)
data class TraktHistoryEpisode(
val number: Int
)
@Serializable
data class TraktHistoryEpisode(val number: Int)
@Serializable
data class TraktTrendingItem(
val watchers: Int? = null,
val movie: TraktSummary? = null,
val show: TraktSummary? = null
)
@Serializable
data class TraktAnticipatedItem(
val list_count: Int? = null,
val movie: TraktSummary? = null,
val show: TraktSummary? = null
)
@Serializable
data class TraktTokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("expires_in") val expiresIn: Long,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("scope") val scope: String,
@SerializedName("created_at") val createdAt: Long
)
val access_token: String,
val token_type: String,
val expires_in: Long,
val refresh_token: String,
val scope: String,
val created_at: Long
) {
val accessToken: String get() = access_token
val tokenType: String get() = token_type
val expiresIn: Long get() = expires_in
val refreshToken: String get() = refresh_token
val createdAt: Long get() = created_at
}
@Serializable
data class TraktTokenRequest(
val code: String,
val client_id: String,
@ -160,6 +166,7 @@ data class TraktTokenRequest(
val grant_type: String = "authorization_code"
)
@Serializable
data class TraktRefreshTokenRequest(
val refresh_token: String,
val client_id: String,
@ -168,31 +175,40 @@ data class TraktRefreshTokenRequest(
val grant_type: String = "refresh_token"
)
data class TraktDeviceCodeRequest(
val client_id: String
)
@Serializable
data class TraktDeviceCodeRequest(val client_id: String)
@Serializable
data class TraktDeviceCodeResponse(
@SerializedName("device_code") val deviceCode: String,
@SerializedName("user_code") val userCode: String,
@SerializedName("verification_url") val verificationUrl: String,
@SerializedName("expires_in") val expiresIn: Int,
val device_code: String,
val user_code: String,
val verification_url: String,
val expires_in: Int,
val interval: Int
)
) {
val deviceCode: String get() = device_code
val userCode: String get() = user_code
val verificationUrl: String get() = verification_url
val expiresIn: Int get() = expires_in
}
data class TraktDeviceTokenRequest(
val code: String,
val client_id: String,
val client_secret: String
)
@Serializable
data class TraktDeviceTokenRequest(val code: String, val client_id: String, val client_secret: String)
@Serializable
data class ExternalOAuthTokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String? = null,
@SerializedName("token_type") val tokenType: String? = null,
@SerializedName("expires_in") val expiresIn: Long? = null
)
val access_token: String,
val refresh_token: String? = null,
val token_type: String? = null,
val expires_in: Long? = null
) {
val accessToken: String get() = access_token
val refreshToken: String? get() = refresh_token
val tokenType: String? get() = token_type
val expiresIn: Long? get() = expires_in
}
@Serializable
data class AnilistTokenRequest(
val grant_type: String = "authorization_code",
val client_id: String,
@ -201,48 +217,58 @@ data class AnilistTokenRequest(
val code: String
)
data class MalAnimeListResponse(
val data: List<MalAnimeListEntry> = emptyList()
)
@Serializable
data class MalMainPicture(val medium: String? = null, val large: String? = null)
data class MalAnimeListEntry(
val node: MalAnimeNode,
@SerializedName("list_status") val listStatus: MalListStatus? = null
)
@Serializable
data class MalAnimeListResponse(val data: List<MalAnimeListEntry> = emptyList())
@Serializable
data class MalAnimeListEntry(val node: MalAnimeNode, val list_status: MalListStatus? = null) {
val listStatus: MalListStatus? get() = list_status
}
@Serializable
data class MalAnimeNode(
val id: Int,
val title: String,
@SerializedName("main_picture") val mainPicture: MalMainPicture? = null,
@SerializedName("num_episodes") val numEpisodes: Int? = null
)
data class MalMainPicture(
val medium: String? = null,
val large: String? = null
)
val main_picture: MalMainPicture? = null,
val num_episodes: Int? = null
) {
val mainPicture: MalMainPicture? get() = main_picture
val numEpisodes: Int? get() = num_episodes
}
@Serializable
data class MalListStatus(
val status: String? = null,
@SerializedName("num_episodes_watched") val numEpisodesWatched: Int? = null,
@SerializedName("updated_at") val updatedAt: String? = null
)
val num_episodes_watched: Int? = null,
val updated_at: String? = null
) {
val numEpisodesWatched: Int? get() = num_episodes_watched
val updatedAt: String? get() = updated_at
}
@Serializable
data class SimklAllItemsResponse(
val movies: List<SimklItem> = emptyList(),
val shows: List<SimklItem> = emptyList(),
val anime: List<SimklItem> = emptyList()
)
@Serializable
data class SimklItem(
val title: String? = null,
val year: Int? = null,
val ids: SimklIds? = null,
val status: String? = null,
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
val last_watched_at: String? = null,
val seasons: List<SimklSeason>? = null
)
) {
val lastWatchedAt: String? get() = last_watched_at
}
@Serializable
data class SimklIds(
val imdb: String? = null,
val tmdb: String? = null,
@ -250,21 +276,18 @@ data class SimklIds(
val simkl: Int? = null
)
data class SimklSeason(
val number: Int? = null,
val episodes: List<SimklEpisode>? = null
)
@Serializable
data class SimklSeason(val number: Int? = null, val episodes: List<SimklEpisode>? = null)
data class SimklEpisode(
val number: Int? = null,
@SerializedName("watched_at") val watchedAt: String? = null
)
@Serializable
data class SimklEpisode(val number: Int? = null, val watched_at: String? = null) {
val watchedAt: String? get() = watched_at
}
data class SimklSearchResult(
val type: String? = null,
val ids: SimklIds? = null
)
@Serializable
data class SimklSearchResult(val type: String? = null, val ids: SimklIds? = null)
@Serializable
data class SimklEpisodeInfo(
val season: Int? = null,
val episode: Int? = null,

View file

@ -0,0 +1,40 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.Video
enum class ExternalSyncProvider { SIMKL, MAL }
enum class ExternalSyncAction { STAMP_SUCCESS, CLEAR_CREDENTIALS, REFRESH_CREDENTIALS, KEEP_CREDENTIALS }
data class MalListUpdate(val malId: Int, val watchedEpisodes: Int?, val status: String)
object ExternalSyncPolicy {
fun afterResponse(provider: ExternalSyncProvider, statusCode: Int): ExternalSyncAction = when {
statusCode in 200..299 -> ExternalSyncAction.STAMP_SUCCESS
statusCode == 401 && provider == ExternalSyncProvider.MAL -> ExternalSyncAction.REFRESH_CREDENTIALS
statusCode == 401 -> ExternalSyncAction.CLEAR_CREDENTIALS
else -> ExternalSyncAction.KEEP_CREDENTIALS
}
fun afterRefreshRetry(statusCode: Int?): ExternalSyncAction = when {
statusCode != null && statusCode in 200..299 -> ExternalSyncAction.STAMP_SUCCESS
statusCode == 401 -> ExternalSyncAction.CLEAR_CREDENTIALS
else -> ExternalSyncAction.KEEP_CREDENTIALS
}
fun malWatchedUpdate(meta: Meta, episodes: List<Video>): MalListUpdate? {
if (meta.type != "series") return null
val malId = malId(meta.id) ?: return null
val highestEpisode = episodes.mapNotNull(Video::number).maxOrNull() ?: return null
val status = if (meta.episodesCount != null && highestEpisode >= meta.episodesCount) "completed" else "watching"
return MalListUpdate(malId, highestEpisode, status)
}
fun malWatchlistUpdate(meta: Meta): MalListUpdate? {
if (meta.type != "series") return null
return MalListUpdate(malId(meta.id) ?: return null, null, "plan_to_watch")
}
private fun malId(contentId: String): Int? = Regex("^mal:(\\d+)$").find(contentId)?.groupValues?.get(1)?.toIntOrNull()
}

View file

@ -0,0 +1,123 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.local.LibraryCatalogSource
import com.fluxa.app.data.local.LibraryRemoteSource
import com.fluxa.app.data.local.LibraryUserCollection
import com.fluxa.app.data.local.LibraryUserCollectionFolder
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.NuvioAddon
import com.fluxa.app.data.remote.NuvioCollectionRow
import com.fluxa.app.data.remote.NuvioLibraryItem
import com.fluxa.app.data.remote.NuvioWatchProgress
import com.fluxa.app.data.remote.NuvioWatchedItem
data class NuvioAddonState(val installedUrls: List<String>, val disabledUrls: List<String>)
object NuvioImportPolicy {
fun addonState(addons: List<NuvioAddon>): NuvioAddonState {
val ordered = addons.sortedBy(NuvioAddon::sortOrder)
return NuvioAddonState(
installedUrls = ordered.map(NuvioAddon::url).distinct(),
disabledUrls = ordered.filterNot(NuvioAddon::enabled).map(NuvioAddon::url).distinct()
)
}
fun libraryMetas(items: List<NuvioLibraryItem>): Map<String, Meta> = items.associate { item ->
item.contentId to Meta(
id = item.contentId,
name = item.name,
type = item.contentType,
poster = item.poster,
background = item.background,
description = item.description,
releaseInfo = item.releaseInfo,
imdbRating = item.imdbRating?.toString(),
genres = item.genres
)
}
fun progressVideoId(entry: NuvioWatchProgress): String =
if (entry.season != null && entry.episode != null) {
"${entry.contentId}:${entry.season}:${entry.episode}"
} else {
entry.videoId.ifBlank { entry.contentId }
}
fun latestProgressByContent(entries: List<NuvioWatchProgress>): List<NuvioWatchProgress> = entries
.groupBy(NuvioWatchProgress::contentId)
.values
.mapNotNull { values -> values.maxWithOrNull(compareBy(NuvioWatchProgress::lastWatched, NuvioWatchProgress::position)) }
.sortedByDescending(NuvioWatchProgress::lastWatched)
fun watchedVideoIds(
items: List<NuvioWatchedItem>,
progressVideoIds: Set<String>
): Map<String, Set<String>> {
val watched = mutableMapOf<String, MutableSet<String>>()
items.forEach { item ->
val videoId = if (item.season != null && item.episode != null) {
"${item.contentId}:${item.season}:${item.episode}"
} else {
item.contentId
}
if (videoId !in progressVideoIds) watched.getOrPut(item.contentId, ::mutableSetOf).add(videoId)
}
return watched
}
fun collections(rows: List<NuvioCollectionRow>, nextId: () -> String): List<LibraryUserCollection> =
rows.flatMap { it.collectionsJson.orEmpty() }.map { collection ->
LibraryUserCollection(
id = collection.id ?: nextId(),
title = collection.title.orEmpty(),
imageUrl = collection.backdropImageUrl,
showOnHome = collection.showOnHome ?: true,
pinToTop = collection.pinToTop,
viewMode = collection.viewMode,
showAllTab = collection.showAllTab,
focusGlowEnabled = collection.focusGlowEnabled ?: true,
community = collection.community,
folders = collection.folders?.map { folder ->
LibraryUserCollectionFolder(
id = folder.id ?: nextId(),
title = folder.title.orEmpty(),
coverImageUrl = folder.coverImageUrl,
coverEmoji = folder.coverEmoji,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled ?: true,
titleLogoUrl = folder.titleLogoUrl,
heroBackdropUrl = folder.heroBackdropUrl,
heroVideoUrl = folder.heroVideoUrl,
shape = folderShape(folder.tileShape),
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources?.map { source ->
LibraryCatalogSource(source.addonId, source.catalogId.orEmpty(), source.type ?: "movie", source.genre)
}?.filter { it.catalogId.isNotBlank() },
sources = folder.catalogSources?.map { source ->
LibraryRemoteSource(
provider = source.provider ?: "addon",
title = source.title,
mediaType = source.mediaType,
traktListId = source.traktListId,
tmdbSourceType = source.tmdbSourceType,
tmdbId = source.tmdbId,
sortBy = source.sortBy,
sortHow = source.sortHow,
filters = source.filters,
addonId = source.addonId,
catalogId = source.catalogId,
type = source.type,
genre = source.genre
)
}
)
}
)
}
private fun folderShape(value: String?): String = when (value?.trim()?.lowercase()) {
"landscape", "wide" -> "wide"
"square" -> "square"
else -> "poster"
}
}

View file

@ -0,0 +1,123 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.local.LibraryRemoteSource
import com.fluxa.app.data.local.LibraryUserCollection
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.NuvioLibraryItem
import com.fluxa.app.data.remote.Video
object NuvioSyncRequests {
fun collection(collection: LibraryUserCollection): Map<String, Any?> = mapOf(
"id" to collection.id,
"title" to collection.title,
"backdropImageUrl" to collection.imageUrl,
"showOnHome" to collection.showOnHome,
"pinToTop" to collection.pinToTop,
"viewMode" to collection.viewMode,
"showAllTab" to collection.showAllTab,
"focusGlowEnabled" to collection.focusGlowEnabled,
"community" to collection.community,
"folders" to collection.folders.orEmpty().map { folder ->
val sources = folder.sources.orEmpty().ifEmpty {
folder.catalogSources.orEmpty().map { source ->
LibraryRemoteSource("addon", addonId = source.addonId, catalogId = source.catalogId, type = source.type, genre = source.genre)
}
}
mapOf(
"id" to folder.id,
"title" to folder.title,
"coverImageUrl" to (folder.coverImageUrl ?: folder.imageUrl),
"coverEmoji" to folder.coverEmoji,
"focusGifUrl" to folder.focusGifUrl,
"focusGifEnabled" to folder.focusGifEnabled,
"titleLogoUrl" to folder.titleLogoUrl,
"heroBackdropUrl" to folder.heroBackdropUrl,
"heroVideoUrl" to folder.heroVideoUrl,
"tileShape" to folder.shape,
"hideTitle" to folder.hideTitle,
"sources" to sources.map(::remoteSource)
)
}
)
fun libraryItem(meta: Meta, addedAt: Long): Map<String, Any?> = mapOf(
"content_id" to meta.id,
"content_type" to meta.type,
"name" to meta.name,
"poster" to meta.poster,
"background" to meta.background,
"description" to meta.description,
"release_info" to meta.releaseInfo,
"imdb_rating" to meta.imdbRating?.toDoubleOrNull(),
"genres" to meta.genres,
"poster_shape" to "POSTER",
"added_at" to addedAt
)
fun libraryItem(item: NuvioLibraryItem): Map<String, Any?> = mapOf(
"content_id" to item.contentId,
"content_type" to item.contentType,
"name" to item.name,
"poster" to item.poster,
"background" to item.background,
"description" to item.description,
"release_info" to item.releaseInfo,
"imdb_rating" to item.imdbRating,
"genres" to item.genres,
"poster_shape" to (item.posterShape ?: "POSTER"),
"addon_base_url" to item.addonBaseUrl,
"added_at" to item.addedAt
)
fun watchedItems(meta: Meta, episodes: List<Video>, watchedAt: Long): List<Map<String, Any?>> {
if (meta.type == "movie") {
return listOf(mapOf("content_id" to meta.id, "content_type" to "movie", "title" to meta.name, "watched_at" to watchedAt))
}
return episodes.mapNotNull { episode ->
val season = episode.season ?: return@mapNotNull null
val number = episode.number ?: return@mapNotNull null
mapOf(
"content_id" to meta.id,
"content_type" to meta.type,
"title" to meta.name,
"season" to season,
"episode" to number,
"watched_at" to watchedAt
)
}
}
fun playbackProgress(meta: Meta, videoId: String?, position: Long, duration: Long, watchedAt: Long): Map<String, Any?> {
val parts = videoId?.split(':')?.takeIf { it.size == 3 }
val season = parts?.getOrNull(1)?.toIntOrNull()
val episode = parts?.getOrNull(2)?.toIntOrNull()
val progressKey = if (season != null && episode != null) "${meta.id}_s${season}e$episode" else meta.id
return mapOf(
"content_id" to meta.id,
"content_type" to meta.type,
"video_id" to (videoId ?: meta.id),
"position" to position,
"duration" to duration,
"last_watched" to watchedAt,
"season" to season,
"episode" to episode,
"progress_key" to progressKey
)
}
private fun remoteSource(source: LibraryRemoteSource): Map<String, Any?> = mapOf(
"provider" to source.provider,
"title" to source.title,
"mediaType" to source.mediaType,
"traktListId" to source.traktListId,
"tmdbSourceType" to source.tmdbSourceType,
"tmdbId" to source.tmdbId,
"sortBy" to source.sortBy,
"sortHow" to source.sortHow,
"filters" to source.filters,
"addonId" to source.addonId,
"catalogId" to source.catalogId,
"type" to source.type,
"genre" to source.genre
)
}

View file

@ -0,0 +1,21 @@
package com.fluxa.app.data.repository
object SimklSyncRequests {
fun history(imdbId: String, isSeries: Boolean, episodesBySeasonNumber: Map<Int, List<Int>>): Map<String, Any> {
val ids = mapOf("imdb" to imdbId)
if (!isSeries) return mapOf("movies" to listOf(mapOf("ids" to ids)))
val seasons = episodesBySeasonNumber.map { (season, episodes) ->
mapOf("number" to season, "episodes" to episodes.map { mapOf("number" to it) })
}
return mapOf("shows" to listOf(mapOf("ids" to ids, "seasons" to seasons)))
}
fun watchlist(imdbId: String, isSeries: Boolean): Map<String, Any> {
val ids = mapOf("imdb" to imdbId)
return if (isSeries) {
mapOf("shows" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
} else {
mapOf("movies" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
}
}
}

View file

@ -0,0 +1,25 @@
package com.fluxa.app.data.repository
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.TraktIds
import com.fluxa.app.data.remote.TraktSyncItem
object TraktSyncMapper {
fun toMeta(
item: TraktSyncItem,
type: String,
unknownName: () -> String,
resolveContentId: (TraktIds) -> String?
): Meta? {
val summary = item.movie ?: item.show ?: return null
val id = resolveContentId(summary.ids) ?: return null
return Meta(
id = id,
name = summary.title ?: unknownName(),
type = type,
poster = null,
releaseInfo = summary.year?.toString(),
released = summary.year?.let { "$it-01-01" }
)
}
}

View file

@ -0,0 +1,26 @@
package com.fluxa.app.domain.discovery
data class MetadataFeedOption(
val key: String,
val label: String,
val transportUrl: String,
val type: String,
val id: String,
val genre: String? = null
)
data class DiscoverCatalogOption(
val key: String,
val label: String,
val transportUrl: String,
val type: String,
val id: String,
val genres: List<String>,
val requiresGenre: Boolean = false
)
data class Cs3CatalogFeedDescriptor(
val pluginName: String,
val catalogName: String,
val catalogIndex: Int
)

View file

@ -0,0 +1,22 @@
package com.fluxa.app.player
data class TorrentStatus(
val hash: String,
val title: String,
val downloadSpeed: Double,
val activePeers: Int,
val totalPeers: Int,
val progress: Double,
val stat: Int = 0,
val statString: String = "",
val preload: Int = 0,
val loadedSize: Long = 0,
val preloadSize: Long = 0,
val fileStats: List<TorrentFileStat>?
)
data class TorrentFileStat(
val id: Int,
val path: String,
val length: Long
)

View file

@ -0,0 +1,12 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.remote.AddonManifest
fun addonManifestCapabilityRows(manifest: AddonManifest, language: String?): List<String> {
val resourceOrder = manifest.resources.orEmpty().distinct()
val types = (manifest.types.orEmpty() + manifest.catalogs.orEmpty().mapNotNull { it.type })
.filter { it.isNotBlank() }
.distinct()
if (resourceOrder.isEmpty() || types.isEmpty()) return emptyList()
return listOf("${types.joinToString(", ")} - ${resourceOrder.joinToString(", ")}")
}

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