The app bar and body slid up as a block while the Hero artwork flew its
own path, so the title/controls looked detached from the cover. Content
now fades in over the last 60% of the transition (and fades out first on
pop/drag), leaving only the sheet surface and the flying cover in motion.
Tab navigators get a HeroController (nested navigators have none, so
Heroes never flew inside tabs). Search result rows, recent-access items,
and artist discography cards tag their covers; Album/Artist/Downloaded
screens accept a heroTag and mount the matching Hero on the header
cover. Tags are scoped per list section to stay unique per page.
Slide-up route with M3 emphasized easing, live finger-tracking dismissal
on the app bar and artwork, and a shared-element Hero on the artwork so
the mini player cover grows into the full player.
Coordinated rename of the nine store-named bridge surfaces: Go
exports (InitExtensionStoreJSON -> InitExtensionRepoJSON etc.),
method-channel strings, the Kotlin and Swift handlers, and the Dart
PlatformBridge methods. Channel strings verified 1:1 across
Dart/Kotlin/Swift; gomobile bindings match the new export names.
Android MediaStore APIs untouched. store_registry_url pref key kept
for existing users. Local Android/iOS builds need the gobackend
AAR/xcframework rebuilt; CI does this in the release workflow.
store_provider.dart -> repo_provider.dart, screens/store/ ->
screens/repo/; Store* classes and storeProvider renamed to Repo*.
Method-channel names in platform_bridge stay unchanged (bridge ABI
matching the Kotlin handler).
extension_store.go -> extension_repo.go, exports_store.go ->
exports_repo.go; unexported types/functions/log tag renamed to match
the user-facing repo terminology. The gomobile-exported function
names (InitExtensionStoreJSON etc.) are the bridge ABI called from
Kotlin/Swift and stay unchanged, as does the store_registry_url pref
key that guards existing users' settings.
The es/pt/zh base locales still shipped the old Store labels, the
backup-restore hint said 'from the store' in every locale (boutique
in fr, store loanword in ko), and the Go store lookup error said
'not found in store'. All user-visible surfaces now say repo;
translator descriptions in the template updated to match.
UpdateChecker now derives the latest release and a releasesBehind
count from one releases-list call. When the installed version is
forceUpdateThreshold (3) or more stable releases behind, the update
dialog becomes mandatory: barrier and back-press are blocked, the
later/don't-remind actions are hidden, backing out of the installer
returns to the dialog, and the check bypasses the user's
check-for-updates opt-out. Prereleases never count toward the
threshold.
CI (every push/PR):
- pub-cache cached keyed on pubspec.lock
- dorny/paths-filter skips the Flutter job for Go-only changes and
vice versa (skipped required checks count as passing)
Release:
- gomobile installed from the go.mod-pinned x/mobile version instead
of @latest (reproducible, covered by the setup-go cache; verified
locally that go.sum already satisfies cmd/gomobile)
- Android bind targets arm/arm64/amd64 only - 386 dropped, amd64 kept
so the universal APK still runs on x86_64 emulators
- NDK r29 cached; docker prune dropped from the free-space step
- Flutter pinned to 3.41.5 (matches .fvmrc/ci.yml) in both build jobs
plus pub-cache caching; xcodeproj gem installed only when missing
- sharedTransport ResponseHeaderTimeout 45s -> 120s: downloads ride
this transport and some providers prepare files server-side before
the first byte; the stall watchdog still bounds dead transfers
- uTLS pool: a request that fails on a pooled (possibly silently
dead) connection now re-dials once when the body is repeatable,
matching the old dial-per-request reliability
- cover singleflight completes via defer with a default error so a
panicking fetch can never strand waiters in (nil, nil) or leave a
permanent in-flight entry
Stability:
- transient timeouts no longer classified as ISP blocking; they fall
through to retry backoff (hard blocks - DNS/RST/cert - still abort)
- connectivity change now closes idle Go connections in every network
mode (debounced), so pooled sockets from the old interface are not
reused after a wifi/cellular handoff
- download body reads get a 60s stall watchdog that cancels and
surfaces a retryable network error instead of hanging; distinct
from user cancellation
- ResponseHeaderTimeout 45s on all transports; IdleConnTimeout 90->60s;
dial timeout 30->10s; retry backoff gains full jitter; Retry-After
honored on 5xx and Deezer 429
Data usage / speed:
- cover downloads deduplicated with singleflight plus a 24MB/15min
in-memory cache keyed by final URL (album batches fetched the same
1800px cover once per track before)
- song.link availability cached (30min positive / 5min negative)
in front of the 9-req/min rate limiter
- uTLS Cloudflare path now pools one HTTP/2 connection per host with
a shared TLS session cache instead of a full handshake per request
Deezer artist track-count N+1 kept: counts feed the discography
download UI and are already amortized by the artist TTL cache.
- _replaceSafFileVia: op now registers produced files via addCleanup
the moment they exist, so converted temp files are deleted even
when a later step throws (old code's outer-variable finally)
- native worker history: recompute format from the final file path
at history-write time and gate bitrate on lossy output again -
the extracted helper was recording pre-conversion format/bitrate
(FLAC conversions logged as m4a with nulled bit depth on SAF)
- drop dead ignore_for_file header in the finalization part
Pure move: paths, native worker, finalization, ReplayGain, and
metadata embedding move to part files as private extensions on
DownloadQueueNotifier; queue orchestration, state, and public API
stay in the main file (7598 -> 4022 lines).
- _buildDownloadRequestPayload/_historyItemFromResult shared by the
inline and native-worker paths; _buildOutputDir now derives from
_buildRelativeOutputDir
- shouldAbortWork extended (deleteFileOnAbort, null-item check) and
used at all ten guard sites; _purgeAlbumRgEntry replaces six copies
- ProgressStreamPoller shared by download queue and local library;
openVerificationAndAwaitGrant shared by queue and track provider
1,303 stale entries dropped across 19 locales (largest groups:
Spotify credentials flow, collection playlists, notification
channels, backup strings); generated localizations rebuilt.
Crowdin sources need a resync after this lands.
Go:
- callExtensionScript generic replaces the ~40-line perf/lock/run/
timeout skeleton in ten wrapper methods; PostProcess V1/V2 share
postProcessCommon (V1 keeps its postProcess-only probe)
- attemptExtensionDownload extracts the duplicated provider download
attempt from the source-extension and priority-loop paths;
overlayStr/overlayInt collapse the enrichment overlay chains;
buildDownloadFilename shared by both output-path builders
Dart (download_queue_provider):
- _replaceSafFileVia wraps the SAF copy/operate/publish/cleanup
roundtrip used at eight sites
- decrypt and external-LRC finalization unified between the inline
and native-worker pipelines (divergences parametrized: AC-4 repair,
per-stage error messages, base-name resolution, extension-state
snapshot); HIGH/container conversion keep separate lenient/strict
policies and share only the SAF mechanics
- album/playlist/library-folder/track-metadata adopt
CollapsingHeaderScrollMixin; artist/library-folder adopt
SelectionModeMixin
- HeaderCircleButton and HeaderMetaRow replace per-screen copies
in album and playlist headers; dead _tallHeader flag removed
- SelectionBottomBar gains optional subtitle labels and backs the
playlist selection bar in queue tab
- single-track convert sheet now reuses BatchConvertSheet
(confirmLabelBuilder + sourceIsLossless params) instead of a
full inline copy
- provider priority page migrated to PrioritySettingsScaffold;
shared showDiscardChangesDialog and ReorderablePriorityItem
replace per-page copies
- home tab search rows unified into one _SearchResultRowItem;
_parseTrack copies rebuilt on Track.fromBackendMap with only
the load-bearing local overrides kept; loading/error scaffold
extracted
- PlayerArtwork widget shared by now-playing and mini player
- LocalOrNetworkCoverImage dispatches file vs network cover in
one place (playlist picker, queue nav, library folder)
Dart:
- notification_service: single _details() builder replaces 13 copies
of the NotificationDetails block
- platform_bridge: _invokeMap() for 34 invoke+decode call sites,
_cachedInvoke() unifies the three TTL/in-flight cache scaffolds
- ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(),
single _writeReplayGainTags() and _convertToLossless() for the
ALAC/FLAC twins
- sqlite_helpers.dart: shared openAppDatabase/path-key/migration
helpers for the three database classes
- library_collections: parametrized wishlist/loved/favorite CRUD
- extension_provider: one predicate-based replacedBuiltIn* lookup
Go:
- extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP
shared by httpGet/httpPost/httpRequest/shortcuts/fetch
- exports_metadata: applyAudioMetadataToResult + successMethodJSON,
APE edit path reuses audioMetadataFromEditFields
- lyrics: lrclibGet() for both LRCLib fetchers
- extension_store: drop hand-rolled strings helpers
- drop unused widgets (AnimatedStateSwitcher, GridSkeleton,
adjacentHorizontalPageRoute, buildRemovalAnimation, SwingIcon,
BottomSheetOptionTile) and empty _buildInfoCard placeholders
- drop test-only Go wrapper methods (GetDownloadURL, MatchTrack,
CheckAvailability 7-arg, CustomSearchForItemID) and their parsers
- drop unused httputil getters/validators and hand-rolled base64
decoder in favor of encoding/base64
- remove unused riverpod_generator dev dependency
Removes the screen's private copy of the backend-map parser; the factory
is a strict superset (broader duration coercion, source/albumType/
itemType, and empty-spotify_id fallback to the native id).
Both carried yet another copy of the collapsing detail header.
AlbumDetailHeader gains appBarTitle (selection-aware toolbar title) and a
leading override for the folder screen's close/back switch. The playlist
header adopts the album layout (bottom-anchored content, 0.85 gradient,
length-adaptive title size, scrim only when blurred) instead of its
drifted centered variant; motion-banner and pill-chip meta are preserved
through the slots.
The local/downloaded album screens had drifted copies of the album
header, selection bottom bar, disc chip, batch convert/ReplayGain trio,
and assorted small helpers. All now delegate to the shared widgets, with
the online album screen's design as the reference, and both album screens
run batch actions through the queue_tab engine via UnifiedLibraryItem
(strict-superset implementation covering both DB writebacks and SAF
paths). Also folds the remaining per-screen helper copies (cover URL,
error card, byte/clock formatting, readPositiveInt) into their shared
homes.
Intentional deltas: selection-bar strings follow queue_tab's l10n keys,
both providers reload after a conversion, and audio-analysis durations
round instead of floor.
The provider and the redownload service each hand-rolled the same
backend-map parsing and duration extraction. The factory ports the richer
provider variant (cover normalization, native-vs-spotify preferredId), so
the redownload service gains those for free. checkAvailability's 30-line
manual reconstruction becomes copyWith, which also stops silently
dropping previewUrl, totalDiscs, and deezerId.
Every settings-style page carried the same 40-line SliverAppBar block;
titles, custom leadings (discard-confirm back handlers), and action
buttons are preserved through the widget's slots.
MainActivity duplicated the whole SAF helper cluster (sanitizeFilename,
truncation, ensureDocumentDir, createOrReuseDocumentFile, ...) and both
classes synchronized on separate locks, so a foreground SAF write racing
a service-worker write into the same tree was never serialized and could
produce duplicate documents. One shared home, one lock. normalizeExt
adopts the trim-first variant; mimeTypeForExt is the union of all three
copies (adds wav/aiff/ogg coverage everywhere).
marshalJSONString collapses 62 identical marshal-and-return blocks in the
gomobile exports; jsError/jsSuccess collapse 135 goja extension-response
maps; the two MusicBrainz fetchers share one request+retry helper; the
extension-HTTP default User-Agent block and fileExists get single homes.
No exported signature changes.
Single homes for logic that was copy-pasted across screens: the settings
collapsing header, album detail header (online screen's design as the
reference), selection pill button + bottom-bar chrome, disc separator
chip, error card, cover URL upgrade, byte/clock formatting, duration
extraction, UnifiedLibraryItem (moved out of the queue_tab library so
other screens can import it), and the batch convert/ReplayGain engine
keyed on it.
EditFileMetadata now answers native_mp3/native_ogg/native_m4a and only
falls back to method=ffmpeg when a native editor rejects the file, so
every editor UI switches off the remux path without Dart changes. The
download embed path does the same for flac/mp3/opus/m4a. Side effect:
ReplayGain for MP3/Opus was silently never written (the old dispatch
answered ffmpeg and the caller ignored it) - it is now written natively.
Covers all three editors with synthetic-file tests, including foreign-tag
preservation and stco offset tracking.
EditOggFields rebuilds only the OpusTags/vorbis comment packet and
repaginates the header pages; audio pages are copied verbatim and only
renumbered (with fresh CRCs) when the header page count changes. Cover art
is written as METADATA_BLOCK_PICTURE from the shared FLAC picture builder.
The field semantics are extracted from EditFlacFields into a shared
applyVorbisFieldEdits so FLAC and Ogg interpret edits identically.
Unsupported layouts (multiplexed streams, unknown codecs) return an error
so callers can fall back.