diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c342dc6..85ac0eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -301,7 +301,9 @@ tasks.matching { it.name == "preBuild" }.configureEach { tasks.withType().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 diff --git a/app/src/main/java/com/fluxa/app/core/rust/AndroidAuthEffectHandler.kt b/app/src/main/java/com/fluxa/app/core/rust/AndroidAuthEffectHandler.kt new file mode 100644 index 0000000..2a67043 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/core/rust/AndroidAuthEffectHandler.kt @@ -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()) + 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.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)) +} diff --git a/app/src/main/java/com/fluxa/app/core/rust/AndroidCalendarEffectHandler.kt b/app/src/main/java/com/fluxa/app/core/rust/AndroidCalendarEffectHandler.kt new file mode 100644 index 0000000..8f898d4 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/core/rust/AndroidCalendarEffectHandler.kt @@ -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 = + 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)) +} diff --git a/app/src/main/java/com/fluxa/app/core/rust/AndroidCloudStreamRuntime.kt b/app/src/main/java/com/fluxa/app/core/rust/AndroidCloudStreamRuntime.kt new file mode 100644 index 0000000..dddc23b --- /dev/null +++ b/app/src/main/java/com/fluxa/app/core/rust/AndroidCloudStreamRuntime.kt @@ -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 { + 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.toStreams( + apiName: String, + subtitles: List + ): List = 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? { + val links = mutableListOf() + 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) +} diff --git a/app/src/main/java/com/fluxa/app/core/rust/AndroidOfflineEffectHandler.kt b/app/src/main/java/com/fluxa/app/core/rust/AndroidOfflineEffectHandler.kt new file mode 100644 index 0000000..a6437b3 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/core/rust/AndroidOfflineEffectHandler.kt @@ -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")) + ) + } + ) + } +} diff --git a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt index 18563cb..e5b885a 100644 --- a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt +++ b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt @@ -113,6 +113,24 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor( private val _streamProgressFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 32) val streamProgressFlow: SharedFlow = _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()) - 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 = - 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 = + 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? { - val links = mutableListOf() - 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 { - 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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidCatalogUiMappers.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidCatalogUiMappers.kt index fcf1cb5..1cdbf00 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidCatalogUiMappers.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidCatalogUiMappers.kt @@ -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, filter: String = "all"): List { - 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" diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidLibraryDataSource.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidLibraryDataSource.kt index 677c32c..f17c7ea 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidLibraryDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidLibraryDataSource.kt @@ -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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidStreamSourceSelectionPolicy.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidStreamSourceSelectionPolicy.kt new file mode 100644 index 0000000..0254c7b --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidStreamSourceSelectionPolicy.kt @@ -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, + 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 + ) + ) +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/ChoiceOption.kt b/app/src/main/java/com/fluxa/app/ui/catalog/ChoiceOption.kt deleted file mode 100644 index 331aeee..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/ChoiceOption.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt b/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt index 53d4b2a..c5b4ed7 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt @@ -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.* diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeAuthCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeAuthCoordinator.kt index a4e64a5..c40e71d 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeAuthCoordinator.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeAuthCoordinator.kt @@ -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( + (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(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 decode(value: Any?): T? { + if (value == null) return null + return runCatching { gson.fromJson(gson.toJsonTree(value), T::class.java) }.getOrNull() + } } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt index eb27064..9efd38b 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardRuntime.kt @@ -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 diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCalendarController.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCalendarController.kt deleted file mode 100644 index 3a5cee6..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCalendarController.kt +++ /dev/null @@ -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, - val externalItems: List -) - -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>(emptyList()) - val items: StateFlow> = _items.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _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 = emptyList(), - val isLoading: Boolean = false - ) -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogPagingCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogPagingCoordinator.kt new file mode 100644 index 0000000..f39be0d --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogPagingCoordinator.kt @@ -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, + private val setCategories: (List) -> Unit, + private val folderCategories: () -> Map, + private val setFolderCategories: (Map) -> Unit, + private val normalizeItems: suspend (List, String, String, String?) -> List, + private val dispatch: suspend (Any) -> NativeHeadlessEngineResult, + private val decodeItems: suspend (Any?) -> List +) { + private val inFlight = mutableSetOf() + + 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, + remoteSources: List + ) { + 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>>(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 + ): List { + 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 }) + } + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCloudStreamCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCloudStreamCoordinator.kt new file mode 100644 index 0000000..7a50de7 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCloudStreamCoordinator.kt @@ -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, + private val activeProfile: () -> UserProfile?, + private val categories: () -> List, + private val setCategories: (List) -> 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) + } + } + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeDiscoverController.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeDiscoverController.kt deleted file mode 100644 index 37b6dce..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeDiscoverController.kt +++ /dev/null @@ -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, - private val setUserAddons: (List) -> Unit, - private val normalizeCatalogItems: suspend (List, String, String, String?) -> List, - private val coreState: FluxaCoreStateHandle -) { - private val gson = Gson() - private val _results = MutableStateFlow>(emptyList()) - val results: StateFlow> = _results.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() - - private val _genres = MutableStateFlow>(emptyList()) - val genres: StateFlow> = _genres.asStateFlow() - - private val _catalogs = MutableStateFlow>(emptyList()) - val catalogs: StateFlow> = _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()) - } - } - - 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 = emptyList(), - val isLoading: Boolean = false, - val genres: List = emptyList(), - val catalogs: List = emptyList() - ) -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessBrowseCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessBrowseCoordinator.kt new file mode 100644 index 0000000..06496d2 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessBrowseCoordinator.kt @@ -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 +) { + private val metaListType = object : TypeToken>() {}.type + private val calendarItemListType = object : TypeToken>() {}.type + private val catalogListType = object : TypeToken>() {}.type + private val genreListType = object : TypeToken>() {}.type + private val contentTypeListType = object : TypeToken>() {}.type + private val sourceMapType = object : TypeToken>() {}.type + private val results = MutableStateFlow>(emptyList()) + private val resultSources = MutableStateFlow>(emptyMap()) + private val loading = MutableStateFlow(false) + private val genres = MutableStateFlow>(emptyList()) + private val catalogs = MutableStateFlow>(emptyList()) + private val contentTypes = MutableStateFlow>(emptyList()) + private val calendarItems = MutableStateFlow>(emptyList()) + private val calendarLoading = MutableStateFlow(false) + private var discoverJob: Job? = null + + val discoverUiState: StateFlow = 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> = genres.asStateFlow() + + val calendarUiState: StateFlow = 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 = buildDiscoverCatalogOptions(userAddons(), type) + + fun availableContentTypes(): List = 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(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) { + 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) -> 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(state["catalogs"], catalogListType) + catalogs.value = updatedCatalogs + genres.value = decodeList(state["genres"], genreListType) + contentTypes.value = decodeList(state["contentTypes"], contentTypeListType) + onLoaded?.invoke(updatedCatalogs) + } + } + + private suspend fun decodeList(value: Any?, type: java.lang.reflect.Type): List = withContext(Dispatchers.Default) { + if (value == null) emptyList() else runCatching { gson.fromJson>(gson.toJson(value), type) }.getOrDefault(emptyList()) + } + + private suspend fun decodeObject(value: Any?, type: java.lang.reflect.Type): T? = withContext(Dispatchers.Default) { + if (value == null) null else runCatching { gson.fromJson(gson.toJson(value), type) }.getOrNull() + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessSyncCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessSyncCoordinator.kt new file mode 100644 index 0000000..889b5fd --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeHeadlessSyncCoordinator.kt @@ -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) -> Unit, + private val setContinueWatching: (List) -> Unit, + private val setExternalContinueWatching: (List) -> Unit, + private val setLiked: (List) -> Unit, + private val refreshDynamicRows: () -> Unit +) { + private val metaListType = object : TypeToken>() {}.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 = withContext(Dispatchers.Default) { + if (value == null) emptyList() else runCatching { gson.fromJson>(gson.toJsonTree(value), metaListType) }.getOrDefault(emptyList()) + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeRowRankingPolicy.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeRowRankingPolicy.kt index b3ffc84..f18b68e 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeRowRankingPolicy.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeRowRankingPolicy.kt @@ -46,8 +46,7 @@ object HomeRowRankingPolicy { val json = FluxaCoreNative.optimizeHomeRowsJson(gson.toJson(request)) val optimized = gson.fromJson>(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>(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 } } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeSearchCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeSearchCoordinator.kt deleted file mode 100644 index 34a73cc..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeSearchCoordinator.kt +++ /dev/null @@ -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, - val rows: List -) - -internal class HomeSearchCoordinator( - private val addonRepository: AddonRepository, - private val historyStore: SearchHistoryStore -) { - suspend fun search(query: String, profile: UserProfile?, filter: (List) -> List): 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, profile: UserProfile?): List { - 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) } - } -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt index 129cb8c..10cc57c 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt @@ -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>() {}.type private val trailerListType = object : TypeToken>() {}.type private val categoryListType = object : TypeToken>() {}.type - private val calendarItemListType = object : TypeToken>() {}.type private val videoListType = object : TypeToken>() {}.type private val subtitleListType = object : TypeToken>() {}.type private val introTimestampsListType = object : TypeToken>() {}.type - private val discoverCatalogListType = object : TypeToken>() {}.type - private val discoverGenreListType = object : TypeToken>() {}.type - private val discoverContentTypeListType = object : TypeToken>() {}.type private val addonListType = object : TypeToken>() {}.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>(emptyList()) - val categories: StateFlow> = _categories - private val _collectionFolderCategories = MutableStateFlow>(emptyMap()) - val collectionFolderCategories: StateFlow> = _collectionFolderCategories.asStateFlow() + private val categoryState = HomeCategoryStateStore() + val categories: StateFlow> = categoryState.categories + val collectionFolderCategories: StateFlow> = 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> = HashMap() - private var categoriesRenderSignature: Int? = null private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading @@ -140,38 +125,32 @@ class HomeViewModel @Inject constructor( val searchResults: StateFlow> = searchFocusState.searchResults val searchRows: StateFlow> = searchFocusState.searchRows - private val _headlessDiscoverResults = MutableStateFlow>(emptyList()) - private val _headlessDiscoverResultSources = MutableStateFlow>(emptyMap()) - private val _headlessDiscoverLoading = MutableStateFlow(false) - private val _headlessDiscoverGenres = MutableStateFlow>(emptyList()) - private val _headlessDiscoverCatalogs = MutableStateFlow>(emptyList()) - private val _headlessDiscoverContentTypes = MutableStateFlow>(emptyList()) - private val discoverResultSourceMapType = object : TypeToken>() {}.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 get() = browseCoordinator.discoverUiState + val discoverGenres: StateFlow> get() = browseCoordinator.discoverGenres + val calendarUiState: StateFlow get() = browseCoordinator.calendarUiState - val discoverUiState: StateFlow = 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> get() = _headlessDiscoverGenres.asStateFlow() - - private val _headlessCalendarItems = MutableStateFlow>(emptyList()) - private val _headlessCalendarLoading = MutableStateFlow(false) - - val calendarUiState: StateFlow = 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 = billboardState.error @@ -264,7 +243,20 @@ class HomeViewModel @Inject constructor( private var searchJob: Job? = null private val _isSearchLoading = MutableStateFlow(false) val isSearchLoading: StateFlow = _isSearchLoading.asStateFlow() - private val loadMoreInFlight = mutableSetOf() + 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>>(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() - val paging = home["paging"] as? Map<*, *> ?: emptyMap() - fromStateList(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(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 = - buildDiscoverCatalogOptions(_userAddons.value, type) + browseCoordinator.catalogOptions(type) - fun discoverContentTypes(): List = buildDiscoverContentTypes(_userAddons.value) + fun discoverContentTypes(): List = 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(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 = 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) -> 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(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) { @@ -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 { @@ -1439,32 +1091,7 @@ class HomeViewModel @Inject constructor( } private fun setCategoriesState(categories: List) { - 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 { - return withContext(Dispatchers.Default) { - runCatching { - gson.fromJson>(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.toCs3CatalogFeedDescriptors(): List { - 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.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() - } -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelCoordinatorFactory.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelCoordinatorFactory.kt index 12bf8ee..cd62bb1 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelCoordinatorFactory.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelCoordinatorFactory.kt @@ -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, - setUserAddons: (List) -> Unit, - normalizeCatalogItems: suspend (List, String, String, String?) -> List, - 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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelHelpers.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelHelpers.kt index 4d6df60..ec64575 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelHelpers.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModelHelpers.kt @@ -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 { 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.toCs3CatalogFeedDescriptors(): List { + 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() + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/OfflineDownloadGroups.kt b/app/src/main/java/com/fluxa/app/ui/catalog/OfflineDownloadGroups.kt index 4b6b3a2..15bf2be 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/OfflineDownloadGroups.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/OfflineDownloadGroups.kt @@ -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.toOfflineDownloadGroups(): List { - return groupBy { item -> item.metaId.ifBlank { item.title } } - .values - .map { episodes -> - val sorted = episodes.sortedWith(compareBy { 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" - } -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt index c589140..363cd27 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt @@ -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) } } } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt index 8d20a8d..a8a3f73 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt @@ -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) } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicy.kt b/app/src/main/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicy.kt deleted file mode 100644 index ca937da..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicy.kt +++ /dev/null @@ -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, - 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 - ) -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TrackSelectionState.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TrackSelectionState.kt index f92dc8e..c75d98b 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TrackSelectionState.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TrackSelectionState.kt @@ -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 { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt index 891a8d0..5ecd0e5 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TrailerResolver.kt @@ -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, - 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"), diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt index dd4e191..f5f697f 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt @@ -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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControlWidgets.kt b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControlWidgets.kt index 564a578..9b0793a 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControlWidgets.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControlWidgets.kt @@ -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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControls.kt b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControls.kt index ca08afa..5caaa14 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControls.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/mobile/player/MobilePlayerControls.kt @@ -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 diff --git a/app/src/test/java/com/fluxa/app/core/rust/FluxaCoreBenchmarkTest.kt b/app/src/test/java/com/fluxa/app/core/rust/FluxaCoreBenchmarkTest.kt index 9aea943..ca8a7e4 100644 --- a/app/src/test/java/com/fluxa/app/core/rust/FluxaCoreBenchmarkTest.kt +++ b/app/src/test/java/com/fluxa/app/core/rust/FluxaCoreBenchmarkTest.kt @@ -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" }) } diff --git a/app/src/test/java/com/fluxa/app/core/rust/FluxaCorePlatformContractTest.kt b/app/src/test/java/com/fluxa/app/core/rust/FluxaCorePlatformContractTest.kt index 02fc338..5097435 100644 --- a/app/src/test/java/com/fluxa/app/core/rust/FluxaCorePlatformContractTest.kt +++ b/app/src/test/java/com/fluxa/app/core/rust/FluxaCorePlatformContractTest.kt @@ -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) diff --git a/app/src/test/java/com/fluxa/app/data/repository/StremioAddonResourceClientTest.kt b/app/src/test/java/com/fluxa/app/data/repository/StremioAddonResourceClientTest.kt index 47d9125..9210ce4 100644 --- a/app/src/test/java/com/fluxa/app/data/repository/StremioAddonResourceClientTest.kt +++ b/app/src/test/java/com/fluxa/app/data/repository/StremioAddonResourceClientTest.kt @@ -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"), diff --git a/app/src/test/java/com/fluxa/app/player/DolbyVisionShaderMathTest.kt b/app/src/test/java/com/fluxa/app/player/DolbyVisionShaderMathTest.kt index 2a17fd3..7e974b6 100644 --- a/app/src/test/java/com/fluxa/app/player/DolbyVisionShaderMathTest.kt +++ b/app/src/test/java/com/fluxa/app/player/DolbyVisionShaderMathTest.kt @@ -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 diff --git a/app/src/test/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicyTest.kt b/app/src/test/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicyTest.kt index 179f649..75087e2 100644 --- a/app/src/test/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicyTest.kt +++ b/app/src/test/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicyTest.kt @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 873e7d6..0c4c90d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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() + 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", diff --git a/core/src/commonMain/kotlin/com/fluxa/app/core/rust/FluxaHeadlessEffectRunner.kt b/core/src/commonMain/kotlin/com/fluxa/app/core/rust/FluxaHeadlessEffectRunner.kt index 632eabc..97772d0 100644 --- a/core/src/commonMain/kotlin/com/fluxa/app/core/rust/FluxaHeadlessEffectRunner.kt +++ b/core/src/commonMain/kotlin/com/fluxa/app/core/rust/FluxaHeadlessEffectRunner.kt @@ -26,14 +26,22 @@ class FluxaHeadlessEffectRunner( private suspend fun drain(initial: NativeHeadlessEngineResult): NativeHeadlessEngineResult { var current = initial val patches = mutableListOf(initial) + val pending = ArrayDeque() + val queuedIds = mutableSetOf() + 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()) { state, patch -> state + patch.state } } ) } diff --git a/core/src/commonMain/kotlin/com/fluxa/app/core/rust/models/CorePolicyModels.kt b/core/src/commonMain/kotlin/com/fluxa/app/core/rust/models/CorePolicyModels.kt index 86cc3fc..be2bfe2 100644 --- a/core/src/commonMain/kotlin/com/fluxa/app/core/rust/models/CorePolicyModels.kt +++ b/core/src/commonMain/kotlin/com/fluxa/app/core/rust/models/CorePolicyModels.kt @@ -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, diff --git a/core/src/commonMain/kotlin/com/fluxa/app/player/TorrentPolicyModels.kt b/core/src/commonMain/kotlin/com/fluxa/app/player/TorrentPolicyModels.kt new file mode 100644 index 0000000..68d68dd --- /dev/null +++ b/core/src/commonMain/kotlin/com/fluxa/app/player/TorrentPolicyModels.kt @@ -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 = emptyList(), + val streamUrl: String = "" +) + +data class NativeTorrentStatusInfo( + val bufferProgress: Int = 0, + val isPlayableEnough: Boolean = false, + val statusKey: String = "" +) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index abc31e8..9d44b24 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -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) diff --git a/data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt b/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt similarity index 96% rename from data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt rename to data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt index ec09f24..0dd156d 100644 --- a/data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt @@ -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 diff --git a/data/src/main/java/com/fluxa/app/data/local/OfflineDownloadManager.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/OfflineDownloadManager.kt similarity index 88% rename from data/src/main/java/com/fluxa/app/data/local/OfflineDownloadManager.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/OfflineDownloadManager.kt index c46bd1b..6e1fb56 100644 --- a/data/src/main/java/com/fluxa/app/data/local/OfflineDownloadManager.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/local/OfflineDownloadManager.kt @@ -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) diff --git a/data/src/main/java/com/fluxa/app/data/local/OfflineDownloadService.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/OfflineDownloadService.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/OfflineDownloadService.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/OfflineDownloadService.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/ProfileManager.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/ProfileManager.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/ProfileManager.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/ProfileManager.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/UserProfileSafePrefs.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/UserProfileSafePrefs.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/UserProfileSafePrefs.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/UserProfileSafePrefs.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/WatchlistDao.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/WatchlistDao.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDao.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/WatchlistDatabase.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/WatchlistDatabase.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistDatabase.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/WatchlistEntities.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/WatchlistEntities.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistEntities.kt diff --git a/data/src/main/java/com/fluxa/app/data/local/WatchlistManager.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/local/WatchlistManager.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/local/WatchlistManager.kt diff --git a/data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/AuxiliaryServices.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/remote/AuxiliaryServices.kt diff --git a/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioDtos.kt similarity index 61% rename from data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioDtos.kt index fb9add3..3a3caa5 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioDtos.kt @@ -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? = 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? -) + @SerializedName(value = "catalogSources", alternate = ["sources"]) val catalogSources: List? +) { + 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?, + val folders: List?, val community: Map? = 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? -) +data class NuvioCollectionRowDto( + @SerializedName("collections_json") val collectionsJson: List? +) { + fun toDomain(): NuvioCollectionRow = NuvioCollectionRow(collectionsJson?.map { it.toDomain() }) +} -data class NuvioProfileSettingsRow( +data class NuvioProfileSettingsRowDto( @SerializedName("settings_json") val settingsJson: Map? -) +) { + 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) +} diff --git a/data/src/main/java/com/fluxa/app/data/remote/NuvioService.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioService.kt similarity index 91% rename from data/src/main/java/com/fluxa/app/data/remote/NuvioService.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioService.kt index b06481f..94e2cb2 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/NuvioService.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioService.kt @@ -14,19 +14,19 @@ interface NuvioService { suspend fun healthCheck(): Response @POST("auth/v1/signup") - suspend fun signUp(@Body credentials: NuvioCredentials): Response + suspend fun signUp(@Body credentials: NuvioCredentials): Response @POST("auth/v1/token") - suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response + suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response @POST("auth/v1/token") - suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequest): Response + suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequestDto): Response @GET("auth/v1/user") suspend fun getUser(@Header("Authorization") authorization: String): Response @POST("rest/v1/rpc/sync_pull_profiles") - suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map = emptyMap()): Response> + suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map = emptyMap()): Response> @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> + ): Response> @POST("rest/v1/rpc/sync_push_profiles") suspend fun pushProfiles(@Header("Authorization") authorization: String, @Body body: Map): Response @@ -79,20 +79,20 @@ interface NuvioService { suspend fun pushProfileSettings(@Header("Authorization") authorization: String, @Body body: Map): Response @POST("rest/v1/rpc/sync_pull_library") - suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map): Response> + suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map): Response> @POST("rest/v1/rpc/sync_pull_watch_progress") - suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map): Response> + suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map): Response> @POST("rest/v1/rpc/sync_pull_watched_items") - suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map): Response> + suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map): Response> @POST("rest/v1/rpc/sync_pull_collections") - suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map): Response> + suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map): Response> @POST("rest/v1/rpc/sync_pull_profile_settings_blob") - suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map): Response> + suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map): Response> @POST("rest/v1/rpc/get_avatar_catalog") - suspend fun listAvatars(@Body body: Map = emptyMap()): Response> + suspend fun listAvatars(@Body body: Map = emptyMap()): Response> } diff --git a/data/src/main/java/com/fluxa/app/data/remote/StremioService.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/StremioService.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/remote/StremioService.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/remote/StremioService.kt diff --git a/data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/remote/TraktApi.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/remote/TraktApi.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/AddonCatalogSearch.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonCatalogSearch.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/AddonCatalogSearch.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonCatalogSearch.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/AddonPersistentCache.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonPersistentCache.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/AddonPersistentCache.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonPersistentCache.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/AddonRepository.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonRepository.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/AddonRepository.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/AddonRepository.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/DataFailureReporter.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/DataFailureReporter.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/DataFailureReporter.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/DataFailureReporter.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/DataResult.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/DataResult.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/DataResult.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/DataResult.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/ExternalLibraryClient.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/ExternalLibraryClient.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/ExternalOAuthClient.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalOAuthClient.kt similarity index 100% rename from data/src/main/java/com/fluxa/app/data/repository/ExternalOAuthClient.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalOAuthClient.kt diff --git a/data/src/main/java/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt similarity index 72% rename from data/src/main/java/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt rename to data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt index 2d5bdef..0b581a0 100644 --- a/data/src/main/java/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalSyncPushCoordinator.kt @@ -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