The dot indicator relied on a React key remount to restart the CSS
heroIndicatorFill keyframe on each slide change, which WebKitGTK
sometimes fails to pick up as a fresh animation start, leaving the
active dot frozen at 0% while the underlying slide timer kept
advancing normally. Drive the restart imperatively via a ref with a
forced reflow instead.
Extract the header, grid, day dialog, and artwork-fetch hook from
CalendarScreen.tsx into src/screens/calendar/, along with their shared
styles and utils.
ReactPlayerOverlay had grown to cover the whole in-player UI plus a
long tail of behavior hooks (gestures, telemetry, casting, media
session, Anime4K, keyboard shortcuts, track/subtitle controls, window
mode, etc). Break the JSX into PlayerBottomControls, PlayerFeedback,
PlayerMiniMode, PlayerOverlayDecorations/Styles, PlayerSkipPrompt,
PlayerStatsOverlay, PlayerStreamLinksMenu, PlayerSupplementalPanels,
PlayerTrackPanel, and SoftwareVideoCanvas, and move each behavior
concern into its own usePlayer*.ts hook.
The thumbnail container was only rendered once an image loaded, causing
the preview to jump size; keep the box mounted and swap the image in.
Also drop the redundant "Buffered" row from the torrent stats popover
and show downloaded progress with one decimal place.
Pull the embedded-mpv lifecycle, scrobbling, progress persistence,
retry, playback-start, and close-action logic out of usePlayer into
their own hooks, mirroring the existing usePlayerNativeEvents split.
Replace duplicated JS logic for clearing playback progress, merging
continue-watching lists, selecting plugin scrapers, and resolving TMDB
ids/urls with coreInvoke calls into fluxa-core, so the frontend just
executes plans the core computes. Drops tmdbShared.ts now that its
helpers live in fluxa-core.
engine.ts had grown into a mixed bag of engine lifecycle calls, generic
core-invoke plumbing, and content/library-specific core wrappers.
Split those concerns out and re-export them from engine.ts as a barrel
so existing imports keep working.
useHeroTrailer.ts and useModernDetailTrailer.ts were ~90% identical
(resolve/playback/subtitle-sync/stall-timeout/mute/fullscreen logic for a
YouTube trailer). Merged into a single useTrailerPlayback hook taking a
list of candidate trailer ids, an isActive gate, and returning the same
shape both call sites already used. HeroSection now tries all of a meta's
trailers in order (previously only tried the first one), matching what
ModernDetailLayout already did — a strict improvement, not a behavior
change either side relied on.
ModernDetailLayout.tsx dropped from 518 to 369 lines. The background+trailer
hero moves to ModernDetailHero.tsx, the play/watchlist/completed/dropped
button row moves to ModernDetailActionRow.tsx, the ratings/genre/description
block moves to ModernDetailMetaBlock.tsx, the season dropdown + prev-season
dialog moves to ModernDetailSeasonSection.tsx, and the episodes-tab body
joins DetailsTabContent/RelatedTabContent in ModernDetailTabs.tsx as
EpisodesTabContent.
App.tsx dropped from 708 to 544 lines. Sentry-wrapped/lazy screen imports
move to appScreens.tsx, the welcome and profile-selection gate screens
move to AppGateScreens.tsx, detail-navigation state/handlers move to
useDetailNavigation (with a guardedPlayRef indirection to break the
ordering dependency on usePlayer), and nav-layout/UI-scale prefs move to
useAppLayoutPrefs.
LibraryScreen.tsx dropped from 737 to 395 lines. Styles move to
libraryScreenStyles.ts, CircleBtn/TabChip/HistoryTimeline move to
LibraryScreenParts.tsx, bulk-selection state and actions move to
useLibraryBulkSelection, and collections/folder management moves to
useLibraryCollections.
SearchScreen.tsx dropped from 770 to 223 lines. Style objects move to
searchStyles.ts, and the row/chip/card subcomponents move to
SearchScreenParts.tsx. Dropped SearchResultRow, an unused dead component
with no callers in the original file.
SettingsUI.tsx dropped from 782 to a 46-line barrel re-exporting from
settingsIcons.tsx, settingsOptions.ts, SettingsLayout.tsx,
SettingsControls.tsx, and SettingsRows.tsx. Kept as a barrel since 9
other files import from './SettingsUI' directly.
ModernDetailLayout.tsx dropped from 844 to 518 lines. GenreTag/SimilarSourcePicker
move to ModernDetailParts.tsx, the duplicated details/related tab JSX (previously
repeated for series and movies) is deduplicated into ModernDetailTabs.tsx, and the
hero trailer resolve/playback/subtitle state moves to useModernDetailTrailer.
App.tsx dropped from 921 to 708 lines. Static styles/constants move to
appConstants.ts, native-player Tauri event listeners move to
useNativePlayerEvents, and global keyboard shortcuts plus window
fullscreen tracking move to useGlobalShortcuts.
HeroSection.tsx dropped from 997 to 345 lines. Style objects move to
heroStyles.ts, NavArrow/HeroIconBtn/helpers move to HeroSectionParts.tsx,
and all trailer resolve/playback/subtitle state moves to useHeroTrailer.
AccountSection.tsx dropped from 982 to 338 lines. Provider connect/disconnect/sync
state and OAuth handlers move to useIntegrationAccounts, the selected-integration
detail screen moves to AccountIntegrationDetail, and icon/PKCE/form presentational
pieces stay in accountPresentation (already extracted in a prior pass).
Trakt/Simkl/Stremio now call the new importApplyPlan core method
instead of each hand-writing its own wants()/dryRun guards around
mergeExternalWatchlist/mergeExternalWatched; AniList and Nuvio pass
categories/dryRun straight into their existing combined-plan core
calls instead of gating the result afterward by hand. No behavior
change — same merges, same counts, same writes fire as before, now
sourced from one shared decision function per provider shape instead
of duplicated per-file TS conditionals.
crossProviderPush.ts now calls the new pushPlan core method instead
of hand-branching per destination provider/category itself. The
provider/category capability matrix (which push shape applies to
which destination) now lives once in fluxa-core, reusable by any
future host platform through the same core_invoke("pushPlan", ...)
call — no behavior change on desktop, same push calls fire as before.
Replace the always-visible button list with a real trigger+popover
dropdown (reusing the existing Popover/FilterDropdown pattern used
elsewhere in the app), and show each provider's logo next to its name
in both the trigger and the menu.
The native <select> rendered with an unstyleable white background in
the Tauri webview, making the destination text unreadable. Replaced
with a custom button list matching the dialog's dark theme. Also
reworded the label from "Also push to" to "Push to" — it's the only
place data gets pushed, not an addition to something else.
Every provider sync function now supports a dryRun flag that fetches
exactly as before (respecting the categories filter) but skips every
local write, delta-cache update, and provider-library save. The
Import dialog uses this to add a Scan step: pick categories and an
optional destination, hit Scan to see real per-category counts from
the source, then Continue to actually commit the import (and
destination push) — or Back to change the selection with nothing
having been written yet.
The Import dialog now has a destination dropdown (other connected
services, excluding the source) below the category checkboxes.
Default stays local-only; picking a destination pushes the selected
categories' resulting local state to that provider's real account
after the pull, reusing each provider's existing push primitives
(watchlist add, mark-watched, and for Continue Watching, Trakt/Simkl's
scrobble/pause endpoint — newly exported from scrobble.ts — which
already accepts an arbitrary historical position, not just live
playback).
Each provider's detail page (Trakt/Simkl/AniList/Nuvio/Stremio) now has
an Import button alongside Sync Now, opening a checkbox dialog so the
user can choose exactly which categories (watchlist, continue
watching, watched history, plus Nuvio's collections/addons and
Stremio's addons) get pulled into the local library. Sync Now keeps
pulling everything, since omitting categories still means "all" in
every provider's sync function — this also preserves useAppInit's
automatic full sync on startup.
Reuse the existing ConfirmDialog (profile deletion pattern) for bulk
library removal, dropping a Continue Watching item, disconnecting a
sync account, and deleting a downloaded file — all previously fired
immediately with no way back.
Trakt/SIMKL scrobble-close threshold, torrent sibling-subtitle matching,
and subtitle language dedup were duplicated logic living in the desktop
shell; route them through fluxa_core so mobile doesn't have to reimplement
the same policy.
Move the activity-diffing and SIMKL delta-merge logic behind new core
calls (traktActivityDiff, simklResourceSyncPlan, simklMergeDelta) instead
of duplicating that decision logic in TS, so a second platform can reuse
it without reimplementing it. The desktop layer now only owns the actual
fetch/retry/storage I/O.
Trakt was doing a full playback/watchlist/watched pull on every sync,
unlike SIMKL which already skips unchanged resources via its activities
endpoint. This adds the same pattern using Trakt's /sync/last_activities,
reusing the last cached result per resource when its timestamp hasn't
moved since the last sync.
Stamps catalog items (home rows, discover, search, folder paging)
with the addon transportUrl/content type they were fetched from, and
threads that through the detailLoadRequested dispatch. fluxa-core's
resource_fetch_plan_json now pins the metaDetail fetch to that addon
when known, instead of always racing every meta-capable addon by
priority order and discarding which catalog the user actually opened
the item from.
metaDetail and seasonEpisodes requests race every meta-capable addon
in parallel and use Promise.any, which resolves as soon as ANY addon
answers with non-empty data — meaning a fast but sparse addon (e.g. a
torrent-indexing addon that only echoes back a bare-bones meta object)
could win over a slower but far richer one (a metadata addon with full
cast lists and TMDB photo URLs already baked in), purely because it
happened to respond first. Verified directly against a real addon
(AIOMetadata) that Cats (2019) has 10 cast members with photos in
app_extras.cast — data the app was discarding whenever some other
configured addon's race response landed first.
Switched to Promise.allSettled + picking the first fulfilled result in
the user's configured addon order (their priority), so meta selection
is now deterministic and respects addon order instead of arrival
jitter. Trades a small amount of best-case latency (bounded by each
addon's own request timeout, not by whichever is fastest) for
predictable, richer results.
Root cause behind both the flaky cast/crew list and the duplicate
YouTube subtitle fetches (429s): every detail-state update — ratings,
trailers, fanart, whatever — comes from a fresh JSON.parse of the
whole state tree, so nested arrays/objects (displayMeta, displayTrailers)
get new references even when their actual content didn't change. Any
effect keyed on those references re-fires needlessly.
- Cast/crew effect: skip the async rebuild when a content signature
(title id + cast + links) hasn't actually changed since last run.
- trailerVideoIds: return the previous array reference when its
contents are unchanged, so the hero-autoplay effect (and everything
downstream of it, including the subtitle track fetch) stops
re-resolving and re-fetching the same trailer repeatedly.
Confirmed the YouTube auto-translation fallback works (429s are just
transient rate limiting, retried successfully). The overlay itself was
sized/positioned for a shorter hero (13rem from viewport bottom, up to
1.5rem font) and now overlaps the ratings/genre row below the video.
Shrunk font size, padding, and max-width, and moved it down near the
true bottom edge like a normal caption bar.
Hero autoplay only ever attempted the first YouTube URL found in
displayTrailers. If that specific video was region-blocked (common
when a title has multiple trailers from different platforms/regions,
e.g. Netflix uploads that are geo-restricted while a later Crunchyroll
one isn't), resolveYoutubeTrailer failed and autoplay silently gave up
instead of falling through to a working trailer. Now it walks the full
list of extracted YouTube ids in order and uses the first one that
actually resolves to a stream.
Also reverts the cast/crew effect dependency change from the previous
commit — that "fix" was based on a wrong theory and broke cast/crew
entirely (displayMeta only carries the addon summary, no cast data,
until detail.meta finishes loading; keying on id/type alone meant the
effect never re-ran once the full cast data arrived).
The cast-building effect depended on the whole displayMeta object,
which gets a fresh reference on every state snapshot (JSON.parse never
preserves identity) — including the ones that now fire automatically
after the detailSecondaryRequested auto-dispatch fix. Each secondary
data arrival (ratings, trailers, fanart) was cancelling and restarting
the in-flight cast/director fetch, sometimes before TMDB photos or the
full crew list resolved. Cast/crew content only depends on the title
itself, so key the effect on displayMeta.id/type instead, matching the
sibling peopleImages effect right below it.
Replace the recolored generic tomato/popcorn placeholders with RT's
actual fresh/rotten/empty icon artwork (extracted from the live DOM
of rottentomatoes.com/about's score-icon-critics/score-icon-audience
components: full multi-layer SVGs including the tomato's stem and the
popcorn bucket's kernels/highlight stripes, not just the base shape).
tomatoes/popcorn now swap between dedicated fresh/rotten SVG files via
iconForValue instead of CSS-masking a single monochrome icon, so no
color-matching guesswork is needed anymore — the artwork already has
the right colors baked in.
detailSecondaryRequested (trailers/similar items/OMDb/MDBList ratings/
fanart) was only ever dispatched from the "similar titles" source
picker's onChange — never automatically on page load. Added an effect
in DetailScreen that fires it once per title, matching the existing
detailLoadRequested effect.
Rotten Tomatoes and Metacritic logos come from monochrome single-path
SVGs (no embedded brand color, unlike imdb/tmdb/trakt/letterboxd/mal),
so they rendered as illegible black icons. Recolor them via CSS mask
with their brand hex instead. Swap the Rotten Tomatoes audience score
("popcorn") from a recolored tomato icon to lucide-react's actual
Popcorn icon, matching Rotten Tomatoes' own Tomatometer/Audience Score
iconography. Also bumped badge size and added a background chip for
contrast against arbitrary backdrop images.
hydratePluginsFromStorage was throwing (Object.entries on an undefined
scraperOverrides) inside the single top-level try/catch in useAppInit's
boot effect. That silently aborted everything after it in the same
try block — loadPrefs(), the settings.values hydration, and
libraryHydrateRequested — leaving state.settings.values at its default
for the whole session. Every pref read through appPrefs(state)
(detail view mode, MDBList/OMDb/TMDB API keys, etc.) fell back to its
hardcoded default until the user touched Settings and triggered a
live settingsChanged dispatch.
Isolated hydratePluginsFromStorage in its own try/catch so a failure
there can't take down prefs hydration, and hardened
hydratePluginsFromStorage itself against a missing scraperOverrides/
repositoryUrls so this specific crash can't recur either.
fetchMdblistRatings previously required an explicit "tmdb:" prefix to
recognize a TMDB-sourced id, missing the bare-numeric-id convention
resolveTmdbId already uses elsewhere in this file — anime titles in
particular commonly carry bare numeric ids. Also wrap the whole
function in try/catch: an unhandled coreInvoke rejection here (e.g.
while fluxa-core hasn't been rebuilt with the new mdblist methods yet)
was taking down the entire Promise.all in fetchDetailSecondary,
silently breaking trailers/similar-items/OMDb ratings too.
Adds an MDBList API key setting; when configured, the detail screen
(both modern and classic layouts) renders a row of logo+score badges
for every source MDBList returns (IMDb, TMDB, Trakt, Letterboxd,
Rotten Tomatoes critic/audience, Metacritic critic/user, MyAnimeList)
instead of the plain-text IMDb/RT/Metascore line. Falls back to the
existing OMDb-based text rendering when no MDBList key is set.
Adds rottentomatoes.svg and metacritic.svg (simple-icons, matching
the existing trakt.svg/simkl.svg convention) to round out the logo
set alongside the already-present imdb/tmdb/trakt/letterboxd/mal.
useNuvioConnectivity's first health-check-passes branch ran its own
full importNuvioProfileData + home reload on mount, duplicating
useAppInit's syncExternalOnStartup which already does the exact same
full sync at boot for the same profile. Both ran concurrently on
every launch, doubling network cost and forcing two full home
reloads back to back — the source of the several-second wait after
opening the app. The connectivity hook now only acts on actual
down/up transitions during the session, not on its own initial check.