LibraryItem never declared removed/_mtime so those fields were
silently dropped by Gson before Rust ever saw them, even though
library_state.rs already worked on raw JSON and could have used them.
Adds the fields, StremioRepository.getLibraryWatchlistWithTimestamps,
and wires it into the same mergeWatchlist reconciliation the other
providers use -- but pull-only (reconcileWatchlistPullOnly), not the
full push-capable path.
Deliberately not pushing local watchlist changes to Stremio: that
would need a "toggle membership" write that preserves whatever
progress/state fields already exist on the remote item (a
read-modify-write), which isn't built and isn't safe to guess at
without being able to verify Stremio's actual datastore PUT semantics
(full-replace vs partial-patch) against a live account.
pushWatchlist's AniList branch only ever called SaveMediaListEntry
(add/update) -- removing locally never propagated, since AniList's
DeleteMediaListEntry mutation needs the list-entry id, not the media
id, and nothing looked it up. Adds a lookup query (Media(id) {
mediaListEntry { id } }, which resolves against the authenticated
viewer implicitly, no separate viewer-id round trip needed) followed
by the delete mutation, and branches pushWatchlist's AniList call on
isInWatchlist same as the other providers.
Un-marking a single episode as watched is left out of this pass --
AniList tracks a show-level episode-progress counter, not discrete
per-episode watched flags, so "unwatch this one episode" doesn't map
cleanly onto its data model without risking corrupting progress.
The AniList GraphQL query only ever fetched status: CURRENT, so the
watchlist-add push added in the AniList phase had nothing to
reconcile against on the next sync -- pushed items would just get
re-pushed or silently diverge. Drops the CURRENT-only filter (Rust's
anilist_entries_to_sync already builds a timestamped watchlist array
from PLANNING entries, just needed the query to actually fetch them)
and wires it through the same mergeWatchlist reconciliation Trakt
uses. Generalizes reconcileTraktWatchlist -> reconcileWatchlist since
the logic is fully provider-agnostic.
Simkl mark-watched push existed but nothing was ever pulled back --
marking an episode watched on simkl.com never reached Fluxa. Simkl's
sync/all-items already returns per-episode watched_at when fetched
with extended=full (already the default), just unused until now.
Adds getSimklWatchedEpisodesWithTimestamps (watching+completed status,
shows+anime types) and generalizes the Trakt watched-episode
reconciliation (reconcileTraktWatched -> reconcileWatchedEpisodes)
since the merge logic and push path are provider-agnostic -- both
Trakt and Simkl now call the same function.
Trakt's watched-state pull only ever wrote into the external mirror
table for display; local watched_episodes and Trakt's remote state
were never compared or reconciled, so un-marking an episode locally
never propagated and remote-only watched episodes never landed
locally. Trakt's watched-shows response already carries per-episode
last_watched_at, just unused until now.
Adds getWatchedEpisodesWithTimestamps (parses last_watched_at),
global (not per-series) local watched-episode/removal snapshot
queries, and reuses the mergeWatched core_invoke method (Phase 1) to
decide push vs. apply per composite episode id, mirroring the
watchlist reconciliation pattern.
AniList integration was structurally pull-only — no mutation was ever
sent, so adding to the watchlist or marking something watched in
Fluxa never propagated back, even though the stored OAuth token
already has full read/write access (AniList's OAuth has no scope
parameter). Wires pushWatchlist/pushMarkWatched to call the new
anilistSaveMediaListEntryVariables core_invoke method and POST a
SaveMediaListEntry mutation through the existing generic
anilistGraphQl endpoint.
Watchlist removal isn't covered: AniList's delete mutation needs the
list-entry id (not the media id), which requires an extra lookup
query — left as follow-up rather than scope creep here. Same for
un-marking watched.
pushWatchlist's Simkl branch was guarded by `if (isInWatchlist && ...)`
with no else — removing an item locally never removed it on Simkl,
since only sync/add-to-list was ever called. Adds the mirroring
sync/remove-from-list endpoint and routes the push based on
isInWatchlist, matching how the Trakt branch already handles both
directions.
Durability (WorkManager-backed) for watchlist push in general —
Trakt's branch has the same gap — is left as follow-up; this only
fixes the missing Simkl remove call.
StremioRepository.savePlaybackProgress was fire-and-forget on the
calling coroutine at both call sites (HomePlaybackController and the
writePlaybackProgress headless effect) — an app kill mid-request
silently dropped the push with no retry, unlike Trakt/Simkl scrobble
and Nuvio's progress push which are all worker-backed.
Adds StremioPlaybackProgressPushWorker on the ProviderSyncPushWorker
base, keyed by profileId+contentId same as Nuvio's so rapid saves
coalesce via ExistingWorkPolicy.REPLACE. savePlaybackProgress now
returns Boolean instead of swallowing failure, since the worker needs
to know whether to retry.
Trakt's watchlist pull previously fed only a live, un-timestamped,
display-only union in HomeLibraryCoordinator/AndroidLibraryDataSource
(distinctBy id, recomputed on every emission, never persisted or
pushed back). This wires it into the Phase 0/1 groundwork instead:
- TraktSyncItem now parses listed_at, exposed via
TraktSyncClient.getWatchlistWithListedAt / TraktRepository
- ExternalSyncMergeBridge wraps the mergeWatchlistTimestamped
core_invoke call for Kotlin callers
- WatchlistManager gains a local membership snapshot (active entries +
removal tombstones) and applyRemoteWatchlistAdd
- HomeLibraryCoordinator.load() now reconciles Trakt's remote
watchlist against the local one after each fetch: items the merge
says are locally newer get pushed via the existing
ExternalSyncPushCoordinator.pushWatchlist, items remote is newer on
get applied into the local watchlist_entries table
Simkl and Stremio are follow-up work — their fetch shapes differ
(Simkl has no true watchlist endpoint, Stremio's is a unified
datastore blob) and need separate integration passes.
Lays the groundwork for timestamp-based conflict resolution against
Trakt/Simkl/Stremio (matching the pattern Nuvio already uses), by
giving watchlist entries and watched episodes an updatedAt column and
a soft-delete tombstone table so removals carry a comparable
timestamp instead of vanishing on hard delete. Progress already had
updatedAt; this brings watchlist/watched up to the same level.
Adds a per-profile continue-watching source preference (Fluxa/Stremio/
Nuvio/Trakt/Simkl/AniList), a background worker to push playback
progress to Nuvio instead of pushing inline, dedup of mark-watched
pushes against already-watched episodes, and general Nuvio account
import/merge fixes.
Every stateless method on FluxaCoreNative now calls the corresponding
core_invoke route via FluxaCoreUniFfi instead of a hand-written
external fun; only the handle-lifecycle functions (headless engine,
app core state) keep their dedicated JNI bindings. NuvioCoreBridge and
ExternalLibraryClient's generic coreInvoke calls move onto the same
UniFFI path. Public method signatures are unchanged, so no other call
site needed to change.
FluxaCoreUniFfi.kt and the UniFFI-generated com.fluxa.core.uniffi
bindings only existed in the app Gradle module, but FluxaCoreNative.kt
(data module) needs to call into them and data cannot depend on app.
Moves the generateFluxaCoreUniFfiBindings task and the Kotlin facade
into data, which app already depends on.
Relocates skip-segment/transient overlays, the sidebar shell + track
list primitives, mark-segment/settings/track sidebars, and the
playerInputControls gesture modifier from app-module Android files
into shared/commonMain. Promotes coil3/coil3-compose to commonMain
(genuinely multiplatform; only coil3-network-okhttp stays
Android-only) so the overlay cards using AsyncImage can move too, and
promotes TorrentStreamStatus to player/commonMain as a plain data
holder.
Where a composable used to reach into UserProfile's JNI-backed
`safeXxx` properties (holdSpeed, holdToSpeedEnabled, language) or an
Android-only Locale API, it now takes the resolved primitive as a
parameter instead, with the app-side caller supplying it - keeping
the commonMain/androidMain boundary honest rather than papering over
it.
EpisodeSidebar (Hilt ViewModel-bound) and TrackSelectionState (direct
FluxaCoreNative JNI calls) stay Android-only, along with the actual
video surface, playback effects, and setup effects - those are the
next sub-steps in the player-extraction migration.
Sweeps addon repositories, watchlist/profile storage, Trakt/Nuvio
sync, discovery models, and player policy classes out of Android-only
packages into data/commonMain and player/commonMain, with thin
androidMain wrappers where platform APIs are still required.
Continue the shared CMP/KMP migration: remove the legacy app/ui/catalog
Screen.kt files now superseded by shared/commonMain equivalents (Detail,
Search, Settings, Login, Profile, Watchlist, AddonStore, and the full
legacy Tv* screen set), and thread a DeviceType through FluxaApp/
FluxaAppHost so Android TV routes Home/Search/Discover/Calendar/Library
through shared destinations instead of falling into the removed legacy
composables (which previously hit an unreachable error() case).
Adds TvCatalogHomeScreen wiring plus new TvSearchScreen/TvDiscoverScreen/
TvCalendarScreen/TvLibraryScreen as thin TV-safe wrappers around the
existing shared screens. Removes the orphaned shared player/PlayerScreen.kt,
which had no call sites since the player stays platform-native by design.
Both were blocked solely on Meta living in the Android-only app module,
which the previous commit fixed. Split ContentIdentity's two pure functions
(billboardKey, normalizedBillboardTitle) into a new shared
ContentIdentityTitle.kt, since the rest of ContentIdentity still calls
FluxaCoreNative (JNI-only, not yet exposed to shared code).
Gson is JVM-only and can't live in shared commonMain, which was the real
reason Meta/MetaDetail/Video/CastMember/DetailTrailer were stuck in the
Android-only app module. Ports the existing JsonElement-tree-based custom
parsing (flexible alternate-key extraction, free-form cast/trailer JSON
tolerance) to kotlinx.serialization's equivalent JsonObject/JsonElement API,
using @JsonNames for simple alternate keys and custom KSerializers for
CastMember/DetailTrailer/MetaDetail where the existing Gson deserializers did
manual extraction.
StremioAddonResourceClient now parses addon catalog/meta responses via
kotlinx.serialization (stremioJson) instead of Gson for these types; Gson
stays in place for Stream/SubtitleData, which are out of scope here.
StremioService.getMetaDetail now returns the raw response body instead of
relying on Retrofit's automatic (Gson-based) conversion, since the models it
returns no longer carry Gson annotations.
Verified on-device: addon catalog list parsing (Home rows), billboard, and
meta detail (title/year/runtime/description) all render correctly against
real addon responses.
getAddonCatalogResult had no caching at all, unlike getUserAddons which
already uses RepositoryMemoryCache. Every Discover filter change (type/
catalog/genre) or revisit re-fetched from the addon over the network
with zero speedup, even for a combination already fetched moments
earlier. Reuses the same RepositoryMemoryCache (10min TTL, already
injected into this class) keyed by transportUrl+type+id+skip+genre+
search. Doesn't fix a slow addon's first-load latency, but repeat
visits to the same catalog+genre combo are now instant.
Diagnosed against NuvioMobile's open-source discover implementation
(same addons, more catalogs visible there). Two real gaps:
1. buildDiscoverCatalogOptions() excluded any catalog requiring an
extra parameter beyond genre. Nuvio's equivalent (supportsDiscover())
explicitly whitelists "skip" (pagination) as never blocking a
catalog even when required — Fluxa's filter didn't, silently
dropping every catalog that declares skip as required, which is a
common Stremio addon SDK convention. Added "skip" to the allowed set.
2. The Type filter (movie/series) was hardcoded in the shared Discover
UI instead of derived from what addons actually expose. Nuvio builds
its type list from distinct catalog types across installed addons,
which is why "anime" shows up there. Added a new
buildDiscoverContentTypes() helper (same extras filter, minus the
"all"/type restriction), threaded contentTypes through the
readDiscoverCatalogFilters effect, Rust's DiscoverState (new field,
needed since the effect completion flows back through the engine's
own state, not directly to Kotlin), HomeViewModel, and the shared
DiscoverUiState/DiscoverScreen, with a movie/series fallback so the
dropdown isn't empty before the first fetch completes.
- Use Chapters for Skipping: gates the existing chapter-derived skip
segment fallback (already computed by deriveSkipSegmentsFromChapters)
behind a new toggle instead of applying it unconditionally. Also
brought TV's Skip Segments section up to parity with mobile (IntroDB +
API key, AniSkip, chapter toggle, auto-skip), since TV was missing all
but auto-skip.
- Japanese audio for anime: overrides the resolved preferred audio
language to "ja" for anime-genre content when enabled, at the single
entry point both audio and subtitle track resolution already share.
- Autoplay countdown: the next-episode card now counts down and fires
automatically, instead of only advancing on click.
- Auto-retry next source: extends the existing Cloudstream-only
auto-fallback-on-error path to also apply generally when enabled.
Mirrors the existing Trakt/MyAnimeList/Simkl connection pattern: a
fluxa://oauth/anilist deep link, authorize-URL launch, token exchange
through a new ExternalOAuthClient.exchangeAnilistCode call, and profile
fields (anilistAccessToken/RefreshToken/TokenExpiresAt) merged via the
existing authExchangeRequested headless effect. Adds a matching
AuthProvider::Anilist arm in fluxa_core's token_merge_plan_json so the
shared Rust merge path recognizes the new provider too.
Connect/disconnect tiles added to both TV and mobile Settings, folded
into the existing multi-provider "Disconnect" action.
Note: app/build.gradle.kts also carries a small pre-existing uncommitted
fix (rustCrateDir path) unrelated to this change, included here only
because it was already sitting in the working tree on the same file.
- Add Cs3CatalogFeedDescriptor and per-catalog feed keys (cs3CatalogFeedKey) so
each CS3 catalog gets its own home row instead of one per plugin
- Build CS3 metadata feed options from per-catalog descriptors in buildCs3MetadataFeedOptions
- HomeBillboardLoader now fetches CS3 feeds alongside stremio addon feeds for
billboard pool population
- Stream.kt: send Cloudstream referer header lowercase ("referer") to match how
Media3 passes custom request properties
- HomeContentFormatters: skip cs3: video IDs in parseShortVideoId to avoid
incorrect episode line parsing
- HomeLayoutHelpers: include poster as fallback in mobileHeroArtworkCandidates
mpv:
- Set ao=audiotrack explicitly so the AudioTrack output is guaranteed
(avoids openSLES which lacks encoded passthrough)
- Add audio-channels=7.1,5.1,stereo so mpv negotiates multichannel
instead of defaulting to auto-safe stereo downmix
- Build audio-spdif at init time by querying AudioManager output device
encodings; only advertises AC3/E-AC3/DTS/DTS-HD/TrueHD formats the
device actually reports
ExoPlayer:
- Guard setVideoEffects(emptyList()) behind a videoEffectsActive flag:
only call it when IptPqc2ToneMapEffect was actually installed, never
on a clean path — the empty-list call triggers the GL VideoFrameProcessor
pipeline in media3 1.10.1 which tone-maps HDR even for an empty effect list
- detectPreferredVideoMimeType now accepts Context and checks display +
decoder DV capability first; on fully DV-capable devices it returns
VIDEO_DOLBY_VISION so DV variant tracks are preferred rather than
deprioritized behind H.265/H.264