Addons now only manages Stremio addons. CloudStream3 repo/plugin state
and the Android-only Nuvio scraper settings screen both move into a new
shared KMP feature (feature/plugins) with a real Android data source
combining PluginRepositoryManager and PluginManager, and an iOS stub
data source pending a follow-up Swift implementation.
Scrapers are now grouped under their owning repository instead of two
separate flat lists, and the screen reuses AddonStoreScreen's visual
language (icon-circle back button, bordered repo cards, icon buttons)
instead of its own bespoke plain-text styling. Adds a refreshRepository
action reusing the existing re-fetch dispatch.
Wire executePluginScraper's updated scraper_id/scraper_settings_json
params through FluxaCoreUniFfi and PluginRepositoryManager, add
getSettingsLayout() to fetch and parse a scraper's onSettings() field
layout into small UI models, and add a gear-icon-triggered bottom
sheet (PluginScraperSettingsSheet) rendering header/info/text/select/
toggle fields, matching Nuvio's real settings schema.
PluginRepositoryManager's headless engine instance was in-memory only, so
installed plugin repositories and disabled/configured scrapers vanished
on every process restart. Now persists repository URLs and per-scraper
enabled/settings overrides to SharedPreferences, and replays them as
headless actions on startup (relying on the Rust-side fix that makes a
repository re-fetch preserve existing overrides instead of resetting to
manifest defaults).
Also adds updateScraperSettings(), used by the persistence replay and by
the upcoming per-scraper settings UI.
fluxa_core already emitted these for plugin-sourced streams, but Gson
silently dropped them since Stream had no matching properties. Renamed
the existing getHeaders() to resolveHeaders() to avoid a JVM signature
clash with the new headers property, and merged it into the raw
behaviorHints-derived headers.
Implements PluginHttpClient over OkHttp with SSRF guarding (PluginNetGuard),
wires the FetchPluginManifest effect, adds PluginRepositoryManager for
repository/scraper state, folds plugin-scraper streams into
StreamDiscoveryUseCase, and adds a Plugins settings screen.
fluxa-core's plugin-js-engine feature now generates rquickjs FFI
bindings via bindgen instead of relying on prebuilt binding files,
since rquickjs-sys ships none for any Android target. Export
LIBCLANG_PATH (from the NDK's own libclang) and
BINDGEN_EXTRA_CLANG_ARGS (NDK sysroot, matching clang resource-dir,
and the concrete clang target triple) alongside the existing
CC/AR/linker env vars in each buildFluxaCore<Abi> task, so bindgen
resolves Android headers instead of falling back to the host's.
StremioAddonManifestClient, StremioAddonResourceClient, and the trailer
effect in FluxaAndroidHeadlessEnvironment each hand-rolled the same
Request.Builder -> execute -> read status/body -> catch pattern. One
shared executor now backs all three call sites.
New setting under Settings > Appearance > Home Screen: when enabled,
Continue Watching items whose next episode/season hasn't released yet
move into their own Upcoming row instead of sitting in Continue
Watching with nothing to resume.
Classification runs async per up-next series item (no active playback
progress): HomeContinueWatchingCoordinator resolves the specific
lastVideoId episode via the existing getSeasonEpisodes fetch, checks
its release date through the new isEpisodeReleased FFI call, and
caches the result by id:lastVideoId. Once known, it triggers a
dynamic-rows refresh so the split applies without blocking the
initial Home paint.
HomeCatalogFeedCoordinator/HomeDynamicRowsCoordinator partition the
Continue Watching list into continue_watching/upcoming HomeCategory
rows via the new isUpcoming lambda, gated by
UserProfile.upcomingRowEnabled (resolved through the Rust safe-prefs
struct like every other appearance toggle). HomeCategoryPolicy's
isContinueWatchingCategory()-gated behavior (card layout, action-row
treatment, title passthrough) now also covers the upcoming row so it
renders with the same horizontal episode-card treatment.
androidFluxaPlatformServices was only built for DeviceType.Mobile, but
AppRoutesHost always force-unwraps it, so the TV build crashed with an
NPE on every launch.
New opt-in Appearance toggle (default off), mirroring how amoledMode
is wired through UserProfile and both settings data sources. Enabling
it swaps the nav bar's flat fill for a translucent frosted surface
with a soft top-light gradient and a hairline glass-edge border.
Both sources fed the same skip-segments pipeline and were always
toggled together in practice, so collapse them into a single
useSkipSegments flag on SettingsPlaybackUiModel. Both the Android and
Apple settings data sources still write/read the two underlying
useIntroDb/useAniSkip flags in lockstep, so the merge stays UI-level
and existing profiles keep working.
imdbapi.dev moved its base URL; also drops the now-dead
ImdbApiService.create() factory since HomeViewModel already gets its
instance through Hilt DI in NetworkModule.
Adds contentWarningsEnabled to the playback settings model, defaulting
to true, and gates both the parents-guide fetch and the overlay
display in PlayerScreen behind it.
Adds trailerUrl to DetailUiState and DetailUiModel so the detail
screen's data source can pass the resolved trailer through, and
adds the missing HeroPageChanged branch to CatalogHomeStore's
no-op action list.
Home Screen and Detail Screen settings now split into dedicated
sub-pages (Hero Banner, Continue Watching, Navigation for Home;
Hero Banner, Episodes for Detail) instead of one long mixed page,
so a specific setting is reachable by name instead of scrolling.
Also removes the dead "Trailer on Hero" toggle in Detail Screen
settings, which persisted a value but was never read by any
consuming code — it just duplicated the real "Trailer on Detail
Hero" setting and caused exactly this kind of navigation confusion.
MkvChapterFetcher's fixed prefix scan missed Chapters on releases where
it sits past the first Cluster (e.g. large embedded attachments pushing
it ~10MB in on one observed torrent release). Wire up the new
parseMkvChaptersAtOffsetNative bridge and the seekOffset the Rust
parser now reports: when Chapters isn't in the initial head fetch,
follow the SeekHead-derived offset with one small targeted range fetch
instead. Shrink the initial fetch from a 4MB prefix guess down to 64KB
now that it only needs to cover the (always compact, near-the-front)
SeekHead rather than hoping Chapters itself falls within it.
Add unitTests.isReturnDefaultValues so android.util.Log calls in
Robolectric-less JVM unit tests don't throw, and a regression test
covering the SeekHead follow-up path end to end.
- Route YouTube trailer resolution through the shared headless engine
(headless_engine/trailer.rs) instead of a hand-rolled Innertube client
in TrailerResolver, mirroring what fluxa-desktop already does. Wire
the two HTTP effect kinds (fetchYoutubeTrailerWatchConfig/Player) as a
generic passthrough in FluxaAndroidHeadlessEnvironment.
- Wire HeroTrailerVideoSurface's ExoPlayer through
TrailerResolver.mediaDataSourceFactory() so trailer playback actually
gets the browser UA + Range header YouTube's CDN requires — this was
the root cause of the black-screen hero trailer.
- Fix AndroidCatalogHomeDataSource pinning the active billboard movie to
index 0 of heroItems on every index change, which desynced the pager's
page index from item identity and made forward swipes visually snap
back to the previous item.
- Hide synopsis/metadata/play button and shrink the logo while a hero
trailer plays, soften the bottom scrim so it doesn't black out most of
the video, and add trailer subtitles (selection/normalize/parse) via
the same fluxa-core functions fluxa-desktop uses, rendered as a
position-synced overlay above the shrunk logo.
MkvChapterFetcher sent bare stream headers instead of routing through
StreamRequestPolicy (default User-Agent, referer), which many origins
require to respond to a Range request at all — unlike mpv, which always
gets proper headers. Also bail out instead of buffering an unbounded
body when a server ignores the Range header and returns 200 with the
full file.
Add MkvChapterFetcherTest using MockWebServer, exercising the real path
end-to-end (headers, 206 handling, JNI EBML parsing) on the JVM without
an emulator.
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.
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.
Simkl's sync/all-items endpoint has no "added to list" timestamp
(last_watched_at is watch-history, not list-membership, and is null
for plantowatch items) so a real timestamp merge like Trakt's isn't
possible without risking local removals being silently undone by a
fabricated timestamp. Per explicit decision: items on Simkl's
plantowatch list are treated as authoritative and always restored
locally each sync, overriding any local removal tombstone. Removing
something from Simkl's own list doesn't remove it locally -- that
would need a persisted prior-snapshot to detect, out of scope here.
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.
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.
TraktScrobbleWorker, SimklScrobbleWorker, and NuvioPlaybackProgressPushWorker
had hand-copied WorkManager boilerplate: identical Constraints/backoff
policy, enqueueUniqueWork wiring, and profile-lookup/failure-bookkeeping
against ProfileManager. Behavior-preserving refactor onto a common base
so the next durable workers (Stremio, Simkl watchlist, AniList) don't
duplicate it a fourth and fifth time.
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.
Both only ever read id/type/seasonsCount from the 39-field Meta model,
in violation of the leaf-UI-gets-narrow-models rule in CLAUDE.md.
Adds seasonsCount to the existing PlayerContentUiModel (already
computed once via meta.toPlayerContentUiModel() at the top of
PlayerScreenContent) and threads that through instead.
The other Player files that take a Meta parameter (PlayerLoadingEffects,
PlayerTrackMemoryEffects, PlayerPipAndEpisodes, etc.) are LaunchedEffect
wrappers that hand Meta straight to ViewModel/repository calls for
persistence and scrobbling — that's the "effects/coordinators" layer
CLAUDE.md explicitly allows to hold domain objects, not a violation.
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.
When a provider lists the requested season but not the requested
episode, fall through to no-match instead of an episode-only match
that could silently grab a different season's same-numbered episode.
Removes the standalone inline audio/subtitle delay adjust pill and
its state, and adds MediaTrack.subtitleFormatLabel/containerTrackId
so subtitle tracks can be labeled by codec (SRT/ASS/VTT/TTML/PGS/DVB/
VOBSUB).
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.
Season-candidate selection, widget row formatting, and release
notification filtering now call the equivalent Rust calendar_plan
functions through core_invoke instead of duplicating that logic in
Kotlin.
Restores the live seek used to capture preview thumbnails during
scrubbing, but hides its side effect: when a drag starts, the current
surface frame is captured via PixelCopy and drawn full-screen under the
controls until the drag ends. The real player can jump between buckets
for thumbnail capture without the visible video changing; the frame you
started dragging on stays on screen until release commits the seek.
Opacity sliders wrote the profile on every drag frame, which is what
made them stutter; they now hold a local live value that drives the
sample cue and only commit to the profile on release. With the panel
usable during drags, the fade-to-15% behavior is removed and the sample
cue moves lower (20dp from the bottom) instead.
The seek preview thumbnail system seeked the real player whenever a
drag paused over an uncached bucket, so scrubbing caused audible
jumps mid-drag. It now serves cached frames only, keeping the
background capture loop; the actual seek happens solely on seekbar
release, which the seekbar already implemented.
Every player menu previously shared one full-chrome sidebar shell. The
shell now renders a translucent 24dp-radius card (0xE6101418, no
border) that fades in with a short upward drift, centered in landscape,
with no subtitle line and no close button; portrait keeps the bottom
sheet with its drag handle. Headers only render when a title or back
action exists.
Speed is no longer a sheet: it is a compact popup near the controls
that scales and fades in from its corner over a light scrim, listing
plain speed rows with the current one checked.
Audio and subtitles merge into a single Netflix-style two-column card
in landscape (portrait keeps the segmented tabs). Audio delay and
subtitle delay leave the card entirely: selecting them collapses the
menu to a floating pill over the video with -/value/+ controls so the
sync effect is visible live while adjusting. Subtitle styling keeps the
in-card adjust page.
Opening the external player fires ON_PAUSE on MainActivity, and
PlayerLifecycleEffect auto-entered PiP because the player screen was
still active and the profile PiP setting was on. Gate the auto-enter
behind a suppression flag set for the duration of the external launch,
cleared on activity result and ON_RESUME.
Reworks the player overlay chrome, sidebar shell, settings and
source sidebars, mark-segment sidebar, and mobile dialogs, plus
new dimension/icon tokens and i18n strings for the redesign.
Addon catalogs can return overlapping items across paginated skip
offsets, so the discover results list could contain the same
type:id twice. LazyVerticalGrid requires unique keys and crashed
with IllegalArgumentException once a duplicate scrolled into view.
Dedupe results in the coordinator where they're assigned.
Settings:
- Replace the single-level category state with a real back stack
(lifted into FluxaAppState) so back navigation goes up one level
instead of always jumping to the hub, with hardware back wired
through MainActivity.
- Scroll position is now keyed per category instead of shared/leaking
across pages, and nested pages show their own titles.
- Split Notifications and TMDB feature toggles out of Account into
their own pages instead of an accordion and an overloaded
TmdbAccountChanged action bundling unrelated fields.
- Downloads settings page no longer duplicates Library's live download
list; it links out to it instead.
- Add a lightweight settings search (SettingsSearch.kt) over a static
index of rows.
- TV renders Settings as a two-pane list-detail layout.
- Introduce SettingsGroupCard and restructure every settings page
from a flat row list into grouped inset cards, restyle the search
field and choice-picker dialog to match the app's black/white
aesthetic instead of default Material styling, and merge the
redundant avatar/account rows on the hub.
- Add-ons settings page now only launches the real add-ons screen
directly (dropped the redundant "Manage Add-ons" middle page and
the torrent preferences that lived there).
Add-ons store:
- Validate pasted manifest URLs by actually fetching them before
installing, instead of accepting any string that pattern-matched
as a manifest path; show an inline error on failure and a
confirmation dialog on success.
- Replace the bare "+" icon button with a labeled Add button.
Also strips emoji baked into a few i18n strings (CloudStream repo/
plugin headers, premiere notification) per project convention.
Tapping an episode card only updated the selected-episode state without
emitting a navigation event, so nothing visibly happened until Play was
pressed. DetailStore now emits SelectSources on EpisodeSelected, and
selectedEpisodeId is tracked via a StateFlow instead of a plain var so
the selection highlight updates immediately instead of lagging behind
an unrelated state emission.
FluxaAppHost already owns real navigation internally via FluxaAppState
(tab switching, detail selection, category browsing, profile editing,
source selection, notifications) - Screen/AppNavigator/AppRoutesHost's
12-variant sealed class and full backstack were solving a problem
FluxaAppHost had already solved. FluxaNavigationStore/FluxaRoute (the
type originally targeted for this) turned out to be orphaned scaffolding
with no real consumer.
Replaces the Android-side Screen stack with the handful of things
FluxaAppHost actually needs seeded from outside: current FluxaDestination
(+ a one-level previousDestination for the three non-clearing pushes:
search, addon store, connect-account), authStartOnNuvio, and a
PlayerLaunchRequest since Player isn't part of the shared UI yet. Deletes
AppNavigator.kt, AndroidFluxaNavigator.kt (confirmed dead - zero external
references), the Screen sealed class, and ~340 lines of AppRoutesHost's
Screen->FluxaDestination translation plus its dead legacy AnimatedContent
dispatch (Screen.Detail was never actually constructed anywhere outside
that dead navigator, confirming Detail navigation already flows entirely
through FluxaAppHost's internal state).
Verification gap: compiles clean and existing tests pass, but this is
the app's core navigation/back-button/PiP-lifecycle wiring and there is
no emulator available this session to click through it. Needs a manual
pass before shipping: cold launch with/without profiles, Home-Detail-
Player-back, hardware back from every top-level tab, PiP entry, connect-
account auth flow, profile switch, and download-item tap to Player.
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.
Relocates TvPlayerUIContent/MobilePlayerUIContent and their supporting
composables (seekbars, transport buttons, bottom action row) from
app-module Android files into shared/commonMain as
PlayerControlsChrome.kt. Both already consumed only lightweight params
(PlayerContentUiModel, Chapter, primitives, callbacks); the one real
platform coupling was the seek-thumbnail capture (SurfaceView/ExoPlayer
frame grab), which now happens at the PlayerOverlays.kt call site on
the Android side and gets passed down as a plain ImageBitmap instead of
being computed inside the shared composables.
First sub-step of the player-extraction migration step; overlays,
sidebars, and setup/effects follow as separate passes.