Reworks the player's settings sidebar and input controls, adjusts the
sidebar shell layout, and wires the updated sidebars into the playback
surface and screen content.
checkSharedUiBoundary was failing on master because the July 17 move of
player overlays into shared/commonMain kept imports of data/player domain
types (Meta, Stream, Video, IntroTimestamps, UserProfile, Chapter,
MediaTrack, TorrentStreamStatus, TrailerCue). Relocate the plain value
types into the shared-safe com.fluxa.app.shared.feature.player package,
and replace the remaining domain-object usages with small UI models
(StreamSourceUiModel, SkipSegmentUiModel, NextEpisodePreviewUiModel)
mapped at the Android call sites. Also drops the dead, UserProfile-only
QuickSettingsSidebar/TrackSidebar composables that had no callers.
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.
fetchAttachmentsViaSeekHead previously issued a 16-byte header range
request, parsed the EBML element length, then issued a second range
request for the full attachments blob. Now it opens one range request
up to MAX_ATTACHMENTS_BYTES and streams the header plus payload off
the same connection, saving a round trip per playback start.
Adds Cronet (org.chromium.net:cronet-embedded + Google's cronet-okhttp
transport adapter) as an OkHttp network interceptor on the player's two
HTTP clients (cache pre-warm + main ExoPlayer data source). All existing
interceptor logic — header injection, Dolby Vision manifest rewriting,
localhost proxy timeouts — is untouched since Cronet only replaces the
final transport, not the OkHttp API surface.
This mainly benefits direct-URL/debrid streams (real TLS connections),
which can now survive network handoffs (wifi<->cellular) and lossy
connections without a full reconnect/rebuffer. Torrent-proxied streams
stay on loopback where this has no effect either way.
Falls back to plain OkHttp transparently if Cronet engine creation
fails for any reason (unsupported ABI, native lib issue, etc.) so
there's no regression risk.
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.
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.
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).
The sheets opened with an all-caps title, an instruction sentence, a
panel spanning almost half the screen, and two-line rows where every
speed carried a redundant Slower/Faster label. Titles are now title
case (Audio, Subtitles, Playback speed), the instruction line is gone,
the landscape sheet narrows from 46% to 38% width capped at 400dp,
rows tighten to 46dp with single-line speed entries (only 1x keeps a
Normal label), and mono/stereo channel counts render as words instead
of 1.0/2.0.
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.
startTorrentServer's result was logged as success and the watcher was
started unconditionally, even when the Rust call failed to report
readiness (blank result). Gate both on the actual running state.
The always-mounted NativeLibassSubtitleOverlay SurfaceView sits above the
video SurfaceView (setZOrderMediaOverlay). Its canvas was only ever locked
and cleared when an ASS renderer was active, so with no embedded ASS track
selected the overlay buffer kept whatever garbage content it started with.
On some devices (reported on Redmi/MIUI) that composites as opaque black,
hiding the video behind fully-drawn player controls.
Adds a real network-transport check (ConnectivityManager) before starting
a torrent stream when Wi-Fi-only is enabled in Settings, refusing to start
on cellular instead of silently ignoring the toggle.
Note: this file already carried unrelated uncommitted engine-resiliency
work (ensureEngineReady/health-check/restart-on-failure) from before this
change — that part isn't mine, it's included here only because it was
already sitting in the working tree and touches the same file.
The Kotlin innertube client had drifted onto deprecated client configs
(ANDROID_TESTSUITE, TVHTML5_SIMPLY_EMBEDDED_PLAYER) that YouTube no
longer accepts. Route through FluxaStreamingNative's new
resolveYoutubeTrailerJson instead, so there's one maintained resolver
shared with the desktop app rather than two independent copies to keep
working. Public API (TrailerResolveResult/TrailerResult/SubtitleInfo)
is unchanged, so callers need no changes.
Embedded MKV font attachments now reach libass reliably. The font scan
was disabled for the local torrent proxy, which is where all torrent
playback lives, so styled fansub tracks fell back to system fonts.
Font sourcing is now layered: a persistent PrefixCaptureBuffer harvests
attachments from the bytes ExoPlayer already downloads (the old capture
died with each ExtractorInput ExoPlayer recreated per load), and an HTTP
scan locates the Attachments element via the SeekHead and fetches it
exactly instead of guessing with an 8 MB prefix. The scan waits for the
player's own connection to open before touching the proxy, backs off
hard between retries, and aborts once fonts exist from any source.
Fonts that arrive after renderer creation recreate it and replay
buffered events. Truncated attachments are dropped instead of being fed
to FreeType half-parsed.
Two compositing bugs caused the sheared "fake italic" glyphs: coverage
rows are packed 4-byte aligned but ALPHA_8 copyPixelsFromBuffer consumes
bitmap rowBytes, so odd-width glyphs skewed one pixel per row; and the
glyph cache was keyed by the native ASS_Image bitmap pointer, which
libass reuses across frames, serving stale glyphs. Rows are repacked
when strides differ and the cache is keyed by content hash.
Startup cost is now near zero: renderer creation moved off ExoPlayer's
loader thread onto the libass HandlerThread, and XDG_CACHE_HOME points
at app storage so fontconfig's /system/fonts scan (~2 s) is cached and
subsequent inits take ~80 ms. libass diagnostics stream to logcat via
ass_set_message_cb.
Five architectural changes:
1. PTS-driven clock: VideoFrameMetadataListener on ExoPlayer delivers the
exact presentation timestamp of each video frame. LibassRenderThread
drives rendering at that PTS rather than polling currentPosition.
2. Translucent SurfaceView: NativeLibassSubtitleView becomes a SurfaceView
with PixelFormat.TRANSLUCENT and setZOrderMediaOverlay(true). Its surface
is composited by the hardware layer between the video surface and the Compose
window, with zero main-thread involvement during playback.
3. Single HandlerThread ownership: LibassRenderThread owns the renderer handle.
All operations (render, addEvent, clearEvents, surface lifecycle, teardown)
are Handler messages to this thread. The ReentrantLock is gone — no
contention because there is no sharing.
4. ALPHA_8 glyph patches via lockHardwareCanvas: nativeRenderImages returns the
ASS_Image list as flat int/byte arrays instead of blending into a full ARGB
frame. Kotlin draws each glyph as a tiny ALPHA_8 bitmap on the hardware
canvas with a tinted Paint. Glyph bitmaps are cached by libass bitmap pointer,
so a static or moving subtitle re-draws cached textures at new positions and
uploads nothing.
5. detect_change gating: nativeRenderImages returns -1 when detect_change == 0.
Kotlin skips lockHardwareCanvas entirely — zero work when the frame is
unchanged. Paused playback is fully idle.
LibassEventRelay routes setHeader, addEvent, clearEvents through the thread.
relay.activeRenderer remains a StateFlow<NativeLibassRenderer?> for the existing
PlayerPlaybackSurface observer. Tests updated to drain the HandlerThread between
async relay operations and direct render() calls.
- Account for pixelWidthHeightRatio when computing exoVideoAspectRatio so
anamorphic streams fill correctly
- Replace gestureState.pinchBelowFitAccum with a snappedToFillThisGesture flag:
one pinch-out snaps to fill, subsequent movement cycles the zoom overlay
- Clamp fillScale to MIN_MANUAL_FILL_SCALE (1.34) so landscape content always
fully covers the screen before the crop overlay appears
- ExoPlayerEngine.setZoomed now sets VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
vs SCALE_TO_FIT instead of being a no-op
- Wire iptPqc2UseHdr and iptPqc2PreDecide from resolved stream data into the
engine request so the IPT PQ C2 tone-map effect receives its configuration
Multi-track relay (high): relay now tracks selectedTrackId driven by
updateTracks(). LibassInterceptingTrackOutput drops samples for non-selected
tracks and re-primes the header (via headerGeneration counter) when the
selected track switches. NativeLibassSubtitleOverlay gates relayRenderer on
embeddedSubtitle != null so disabling subs actually stops drawing.
Duration inference (high): pendingBody is flushed in onSeekFlush() with
lastInterEventMs capped to 5s, fixing the last-subtitle-never-shown problem.
Duration cap lowered from 60s to 30s to reduce lingering on sparse dialogue.
Font fallback (medium): LibassInjectingExtractorsFactory now accepts fontsDir
and threads it through to setHeader(), matching what the local/external path
already did. C++ nativeCreate uses fonts_dir for ass_set_fonts_dir and probes
for a NotoSans-Regular.ttf default_font instead of passing nullptr.
Render loop (medium): C++ nativeRender returns jint (1=has image, 0=no image).
When detect_change==0 and bitmap dimensions are unchanged it skips memset+blend
entirely and returns the cached last_had_image. Kotlin onDraw uses the int
result to decide whether to draw and switches to postInvalidateDelayed(100)
during subtitle-free periods instead of postInvalidateOnAnimation at 60 fps.
Thread safety (low): ensure_api() replaced bool+flag with std::call_once so
the first-call race between ExoPlayer's loader thread and main is eliminated.
Subtitle matching (low): selectedNativeAssSubtitle now tries label then URL
then (label+language) before falling back to language-only, preventing two
external subs in the same language from selecting the wrong one.
fetchSubtitleBytes switches to OkHttp and forwards StreamRequestPolicy headers
so addon URLs that require Referer/auth don't 403.
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