Fixes#368.
Reopening a Spotify playlist from "recent access" showed no tracks,
while the first view (right after pasting the URL) worked fine.
Root cause traced across two layers:
1. Go: ExtURLHandleResult (the parsed shape of an extension's handleUrl()
return value) never captured a top-level `id` for the handled
resource. Track/album/artist results carry their own id inside their
nested metadata, but a plain playlist result has no such object, so
its id was silently dropped everywhere from the goja parser through
to the JSON the Dart side receives.
2. Dart: TrackState had nowhere to put that id even if it existed
(only playlistName), so recording a "recent" playlist access stored
the playlist's *name* as if it were its id. Reopening it later fed
that name into PlaylistScreen's provider-guessing logic
(legacyProviderIdFromResourceId, which only recognizes legacy
"provider:id" prefixes), which naturally failed and fell through to
a hardcoded Deezer metadata fetch using a Spotify playlist's name as
the resource id - guaranteed to return nothing.
Fix, matching the "generic API, not per-provider checks" architecture
in CONTRIBUTING.md:
- go_backend/extension_provider_wrapper.go: add ExtURLHandleResult.ID.
- go_backend/extension_goja_convert.go: parse it from the handler's
return value.
- go_backend/exports_extensions.go: surface it in the JSON response.
- lib/providers/track_provider.dart: add TrackState.playlistId,
populated from the response's `id` field for playlist results.
- lib/screens/home_tab.dart: record the real playlist id (falling back
to the name only if a provider never supplies one) and pass both the
id and the already-known provider id forward to PlaylistScreen.
- lib/screens/playlist_screen.dart: PlaylistScreen gains a
metadataProviderId param that takes priority over guessing from the
id's shape.
- lib/screens/home_tab_recent.dart: pass the recent-access entry's
stored providerId through when reopening a playlist.
- lib/utils/provider_resource_ids.dart: extract the
known-id-vs-guessed-id preference into a small, directly testable
resolvePreferredMetadataProviderId helper.
Note: this fixes the common case (an extension already reported its
own id as the source provider for the URL it handled). If a provider
never supplies an id for its handleUrl() playlist result, playlistId
stays null and behavior is unchanged from before this fix - no
regression, just not a complete fix for that narrower case, since
that would require changes in extension-side JS code outside this
repo.
Tests:
- go_backend/extension_goja_convert_url_handle_test.go: the new ID
field round-trips from a handler's return value, and stays empty
when the handler doesn't supply one.
- test/provider_resource_ids_test.dart: resolvePreferredMetadataProviderId
prefers a known provider id, falls back to the legacy-prefix guess,
treats a blank known id as unknown, and returns null for the
unprefixed-id-with-no-known-provider case from #368 itself.
Verification: go build/vet/test and flutter analyze/test all green.
Fixes#439, likely also #302.
Reported: on iOS, the download folder silently reverts to the default
SpotiFLAC folder after starting a download (and stays reverted after
closing and reopening the app).
_processQueue ran an iCloud/writable-path shape check against the
persisted downloadDirectory string and, if it looked like iCloud Drive
or failed the structural writable-path check, called
setDownloadDirectory() to reset it to the default folder. That call
passes no iosBookmark argument, so it also wipes the user's
security-scoped bookmark in the persisted settings - permanently, not
just for the current run.
This check ran unconditionally, even when a bookmark was already
present for that folder. A folder picked from Files can legitimately
have a persisted path that looks like iCloud Drive, or that fails this
generic structural check, while the bookmark itself still grants real
write access - the bookmark is the actual source of truth, and the
queue already has a separate, correct bookmark-resolution step right
after this one (StartAccessingIosBookmark, with its own proper
failure handling that fails the queued items with a clear message
instead of destroying the setting). The earlier path-shape check just
never deferred to it.
lib/utils/file_access.dart: extract the run/skip decision into
shouldValidateIosOutputDir(isIOS, isSafMode, outputDir,
downloadDirectoryBookmark) - skips whenever a bookmark is present -
and use it in download_queue_provider.dart in place of the inline
condition. Kept as a plain function taking isIOS as a parameter since
Platform.isIOS itself can't be exercised from a host test run.
test/file_access_ios_test.dart covers: runs for a plain app-folder
path, skips when a bookmark is present (the exact #439 case), skips
off iOS, skips in SAF mode, and skips when there's no output dir yet.
Verification: flutter analyze and flutter test (224 tests, all green,
including the 5 new ones) both clean.
local_album and the queue batch action carried byte-identical ~110-line copies
of the FFmpeg re-enrich apply+SAF-writeback+temp-cleanup flow (differing only in
helper names). Extract one applyFfmpegReEnrichResult() so the SAF temp-file
cleanup — the class of bug fixed in fb7d101 — has a single source. The
track-detail copy stays: it drives snackbars and does not preserve metadata.
- SettingsGroup centers itself at 720dp via its incoming constraint
(not screen width, so groups inside clamped sheets are never
over-inset; unbounded-width contexts guarded); priority scaffold,
bespoke hero cards, and donate page get the same inset - all 18+
settings pages clamp from shared widgets
- queue list-mode, local/downloaded album track lists, favorite
artists, home recent-access, and extension detail sections adopt
wideListInset like the screens fixed earlier
- track metadata content clamped at 720dp, which also tames the
width-derived spectrogram height
- announcement dialog capped at 480dp wide; setup wizard and
tutorial content at 560dp with a 400dp action button
- shell switches to a NavigationRail at >=600dp (same destinations,
badges included); the bottom bar and its blur stay phone-only while
the mini player remains anchored bottom in both modes
- now-playing gains a two-pane landscape layout (artwork left,
metadata/controls right) built from the same widget pieces
- track lists on album/playlist/library-folder screens and search
result sections center at 720dp via wideListInset instead of
stretching across the full tablet width; explore carousels stay
full-bleed intentionally
- _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
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.
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 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.
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.
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.
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.
Extract InLibraryBadge (pasted verbatim in five screens) and
playLocalIfAvailable (duplicated in three) into shared code, and delete
SearchScreen, LibraryPlaylistsScreen, and CollapsingHeader, which are
imported nowhere in lib/.
Port of the explicit-content indicator from SpotiFLAC-Web (issue #456):
- Go: add explicit to ExtTrackMetadata (parsed generically from any
extension via explicit/is_explicit/isExplicit), TrackMetadata, and
AlbumTrackMetadata; built-in Deezer maps explicit_lyrics and
explicit_content_lyrics == 1
- Dart: add explicit to the Track model with a tolerant
parseExplicitFlag helper wired into all backend track parsers
- UI: new ExplicitBadge rendered through buildQualityBadges on album,
playlist, search, and home track rows
Add a platform bridge to finish session grants on Android and iOS with
JSON success validation, let users paste callback URLs from the
clipboard, and auto-dismiss the verification help dialog after grant.
Prefer the in-app browser for signed-session verification challenges,
normalize invalid saved modes to the new default, and keep the help
dialog modal until the user explicitly dismisses it.
Let users choose FFmpeg dithering when reducing bit depth and SoXr or
SWR resampling when changing sample rate across single-track and batch
lossless conversion flows.
Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.