After the content fade, popping left an empty dark scaffold traveling
the full screen. The sheet now fades out over the second half of the
pop; opening and dragging keep it fully opaque.
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).
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.
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.
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.
The 1s snapshot poll re-read and re-parsed the whole worker state file
— which retains every completed item's result (history row, possibly
full synced lyrics; 1.5-5 KB each) for the entire run — then
re-serialized it across the channel and re-iterated it on the Dart UI
isolate. Late in a 1000-track batch that is megabytes parsed several
times per second, on the Android main thread.
Pollers now echo back the state_serial of the last snapshot they fully
processed; when the state has not advanced, the native side serves a
cached compact header (no items, no results, no settings) merged with
the small progress delta — steady-state polls are O(1) regardless of
batch size, and the full payload is delivered exactly once per state
transition. The channel handler also reads and parses off the main
thread now, matching its neighbours.
When a provider delivers a lossy/unknown stream, the native finalizer
preserved the container but kept the requested .flac name, so every
metadata/ReplayGain write treated the file as FLAC and failed with
"failed to write FLAC metadata: failed to parse FLAC file: fLaC head
incorrect" — on the first download, on every retry (the file is never
deleted from public storage), on every manual edit attempt, and the
history backfill re-probed the same file on every app launch forever.
- Finalizer now renames a preserved lossy/unknown container away from
its .flac name to match the real content (aac/MP4 -> .m4a, mp3, opus),
mirroring the Dart pipeline, so the correct tag writer is picked
- EditFileMetadata sniffs MP4 content before taking the .flac branch
and reports what is actually wrong (rename to .m4a) instead of the
cryptic parse error
- The history audio-metadata backfill remembers paths whose probe
failed permanently (unparseable content) and stops reselecting them
on every launch; transient failures (missing file, unmounted volume)
still retry
Three-way per-key merge: Crowdin wins for keys it updated, locally
added keys that Crowdin has not exported yet are kept. Also normalizes
@@locale to underscore form (es_ES, pt_PT, zh_CN, zh_TW) and repairs
two translations whose placeholders diverged from the template
(ar extensionsInstallPartialSuccess, es_ES cueSplitSplitting).
The captcha/OAuth flow relied on the OS routing the spotiflac://
callback back into the app. In sideload containers (LiveContainer)
the guest app's URL scheme is never registered with iOS, so after
solving the challenge the redirect went nowhere and the user could
not return to the app (LiveContainer#242/#162 — unresolved upstream).
ASWebAuthenticationSession intercepts the callback scheme in-process,
so no OS-level scheme registration is involved: the session sheet
closes itself on completion and the callback URL is fed into the same
deep-link handler the OS path uses. Verification challenges, the help
dialog's open-browser action, and extension OAuth logins all prefer
the session on iOS, falling back to url_launcher if it fails to start.
Safari's cookie store is shared so captcha providers see an
established browsing context.
Making the button flexible let a tight layout squeeze its label away
entirely. Only the count text needs to give way — it ellipsizes while
the action buttons keep their full labels.
The track/album count header rows sized their text and action buttons
at natural width; on narrow layouts (or long localized labels) the
row overflowed by a few pixels. The count text and the create-playlist
button label now flex and ellipsize instead.
- Verification challenge no longer abandons the batch: batch mates the
worker cancel would mark skipped are requeued and fenced from later
snapshot applications, and the adoption loop restarts the queue for
requeued items when the run ends
- Pause vs cancel race: a cancelled result with no pause flag set waits
up to 1.5s for the pause intent to land on the main looper before
being classified as a permanent skip
- Stale Go cancel flag: new ResetDownloadCancel export (wired through
Android and iOS bridges) drops a pre-registered cancel with no active
download; retryItem/retryAllFailed call it so the first retry of a
cancelled-before-start item no longer aborts instantly
- Reconcile-loop errors now cancel the native worker and clear the run
id before marking items failed, instead of leaving the service
downloading a batch the UI already wrote off
Stale snapshot (queue wedged forever): the service is START_NOT_STICKY,
so a process kill or reboot mid-batch froze the state file at
is_running:true with nothing ever rewriting it. Adoption then set
isProcessing and polled that flag indefinitely; restored items sat at
downloading/queued until the user intervened. Adoption and the
reconcile poll now check DownloadService.isServiceRunning() (three
consecutive dead polls in the loop), reconcile whatever the worker
finished, requeue in-flight items, clear the run id, and hand the batch
back to the Dart queue.
Adoption gate race (duplicate executors): adoption was gated on
_canUseAndroidNativeWorker, which reads extension state that loads
asynchronously after startup and settings toggles the user may have
flipped mid-run. A background batch surviving task removal was then
orphaned while the Dart queue re-downloaded the same items. Adoption
now only requires Android; request contexts that cannot be built yet
(extensions still loading) are retried on each reconcile poll.
Queue replacement race (superseded worker kept running): startNativeWorker
only cancelled the coroutine, which cannot interrupt the blocking
gomobile call, and it reset the shared cancel flag the old loop checks -
so a replaced worker kept downloading its old batch, and its finally
block wrote is_running:false over the new run's snapshot and stopSelf'd
the service underneath it. Replacement now cancels the old run's Go
downloads and FFmpeg work first, and workers carry a generation token:
a superseded generation exits at the next checkpoint and skips the
final snapshot write and service teardown. An invalid replacement
payload no longer stops the service while a run is active.
The Dart queue loop was already built around an activeDownloads map and
per-item progress tracking but was gated to one download at a time. Add
a concurrentDownloads setting (1-3, default 1) exposed in Download
Settings > Performance, and let the scheduler keep up to that many
items in flight. The experimental Android native worker is still
strictly sequential, so it is skipped in favor of the Dart queue while
concurrency is enabled.
Service ids ('deezer', 'tidal', 'qobuz', 'spotify', 'amazon', 'local')
were compared as raw string literals in every dispatch chain. Introduce
MusicServices constants and use them at the comparison/switch sites so
a typo becomes a compile error instead of a silently dead branch.
track_metadata_screen.dart was 5,470 lines, most of it one State class
mixing five surfaces. Move four cohesive method clusters into private
extensions on _TrackMetadataScreenState in new part files: header/info
cards, lyrics + save/re-enrich flows, audio conversion + CUE split, and
file actions. Same library, so private state access is unchanged;
setState goes through a _setState forwarder as in queue_tab. The screen
file is now 1,575 lines.
queue_tab.dart was 7,567 lines with a single 7,300-line State class.
Continue the existing part-file split by moving six cohesive method
clusters into private extensions on _QueueTabState (same library, so
private state access is unchanged): selection mode, navigation/open
actions, collection item builders, filter UI, batch actions, and
queue/library item builders. setState calls in the parts go through a
_setState forwarder since @protected members cannot be called from
extensions. queue_tab.dart is now 2,462 lines.
# Conflicts:
# lib/screens/queue_tab.dart
legacyProviderIdFromResourceId and stripPrefixedResourceId were
copy-pasted in the album, artist, and playlist screens with slightly
different null semantics; the shared nullable variant plus explicit
?? 'spotify' fallbacks makes the difference visible at the call sites.
_AlbumTrackItem and _PlaylistTrackItem were line-for-line identical
except for the leading widget and whether the artist name is clickable;
both are now parameters of a shared TrackListTile.
download_queue_provider.dart (9,525 lines) hosted the entire download
history feature alongside the queue. Move DownloadHistoryItem/State/
Notifier and the history providers to download_history_provider.dart,
and promote the audio format/quality helpers both need into
lib/utils/audio_format_utils.dart. The queue provider re-exports the
history library, so existing imports keep working unchanged.
The two folder-access failure messages are stored on items as fixed
English sentinels so code can match on them; translate the known
sentinels at display time (error dialog and failed-row message) via new
downloadErrorSafPermissionLost / downloadErrorFolderAccessLost keys.
Also replace em dashes in recently added comments.
A track that finished downloading dropped out of the active lead zone
and re-appeared in the history zone below every still-queued sibling,
visually jumping several positions during album downloads. The existing
completion bridge masked this for only 6 seconds.
Keep completion-bridge cells pinned in the lead zone for as long as
other downloads are still active, and hide their history rows while
pinned so tracks don't render twice. Once the queue goes idle the
bridge dismisses and items settle into their normal history position.