Songloft Frontend Gotchas
This document captures pitfalls in the Flutter client (songloft-player) that have been diagnosed and fixed, but whose root causes are broadly applicable — mostly in Flutter Web (CanvasKit) rendering, platform views (plugin iframes), and the audio backend. These burned through many rounds of investigation; archiving them here prevents repeating that.
Backend/business-side pitfalls are in the repo-root
AGENTS.mdunder "Business gotchas summary" and "Platform adaptation gotchas".
1. Cover / image rendering (hard rule)
Rule: all covers must go through CoverImage / NetworkCoverImage; never use bare CachedNetworkImage / Image.network
- Unified wrappers:
lib/shared/widgets/cover_image.dart(CoverImage) andlib/shared/widgets/network_cover_image.dart(NetworkCoverImage). On Web they forceimageRenderMethodForWeb: HttpGet+memCacheWidth(decode a thumbnail scaled to the displayed physical pixels). - Every new cover render site must use one of these two components; don't write
CachedNetworkImage(...)orImage.network(...)directly.
Why (the Web CanvasKit GPU-memory trap)
On Web, covers occasionally turn solid black (pure black, not the load-failure placeholder icon) or degrade to a placeholder icon, triggered by in-app navigation / tab switch / filter and back:
- A bare
CachedNetworkImageon Web defaults toImageRenderMethodForWeb.HtmlImage, uploading the image at full resolution (up to ~1.5 MB each) as a GPU texture. - CanvasKit has a single WebGL context (Chrome uses
OffscreenCanvasRasterizer). Platform views like plugin iframes / video each consume GPU resources; full-resolution cover textures accumulate → exhaust GPU memory → the WebGL context is dropped (ImageCodecException: Failed to create image/MakeLazyImageFromTextureSourceWithInforeturns null). After the context dies, reusedui.Imagetextures paint black; new decodes during the dead window can't get a texture and degrade to the placeholder icon. memCacheWidthonly takes effect on theHttpGetpath; on the defaultHtmlImagepath it does not downscale — so you must explicitly setimageRenderMethodForWeb: HttpGet, shrinking each cover from ~1.5 MB to tens/hundreds of KB and drastically reducing memory pressure.
Investigation lessons (avoid the 9-round detour)
- Pure black ≠ load failure: pure black is at the GPU texture layer and
errorWidgetcan't catch it; the placeholder icon is a load/decode failure. Distinguish first. imageCache.evict/ changing the key / attaching aNavigatorObserverto force rebuilds are only probabilistic mitigations and can even worsen the re-decode storm. GPU texture lifetime issues are only truly fixed by: shrinking decode size (HttpGet+memCacheWidth), reducing concurrent platform views, or upgrading the engine.canvasKitForceCpuOnly(CPU software rendering) removes GPU-texture black but is very laggy and covers still get lost at the loading layer — net negative, ruled out.- To fix a class of image problem, grep all render sites (
CachedNetworkImage/Image.network); don't assume fixing one shared component covers everything — cover rendering is spread acrossCoverImageplus several card components. - The decisive breakthrough came from having the user paste real-device console logs (the exact error string +
[Cover] OK yet renders black+ cache not evicted), not from many rounds of pure inference.
2. Web plugin-tab iframe reloading / jitter
The <iframe> (HtmlElementView platform view) hosting a plugin page suffered repeated reloads (the entry page requested 25–40 times/sec) and visual jitter. Fixed; there were three layers of root cause:
1. Widget tree: iframe reload ⟺ its hosting widget element is disposed+rebuilt
On Flutter Web the iframe DOM element is cached once per platform view viewId and projected through a shadow-DOM <slot> — a normal rebuild / relocation won't touch the iframe; only destroying and rebuilding the viewId re-fetches it.
- Rule: in the plugin keep-alive Stack (
shell_layout.dart), anything that disposes+rebuilds thePluginTabPageelement triggers an iframe reload — reordering, reparenting (a localValueKeycan't move across parents), or a provider that momentarily goes empty dropping the active tab. - Fix: (1)
PluginTabPageuses a stableGlobalKeycached by entryPath (moves across the tree instead of rebuilding); (2) keep-alive pruning keys only off the stabletabConfig.pluginTabsand only whentabConfigAsync.hasValue, never off the momentarily-emptyjsPluginsProvidersnapshot. Caching the iframe inside the factory is useless (when the engine rebuilds the viewId it moves the iframe to a new wrapper, and the move still forces a browser reload).
2. CSS: the document-level scrollbar add/remove reflow loop
When content height ≈ viewport, "scrollbar appears → content narrows → reflow → height changes → scrollbar disappears → …" flips every frame.
- Fix: in
internal/jsplugin/assets/common.css, givehtmloverflow-y: scroll(scrollbar always present, constant width, loop broken). - Note:
scrollbar-gutter: stableonly works on scroll containers (overflow:auto/scroll); it's a no-op whenhtmlisvisible— which is why an earlier attempt didn't fix it. Linux/headless with overlay (0-width) scrollbars can never reproduce it; only Windows Chrome (classic ~15px scrollbar) does.
3. Cache layer (hard rule): immutable long-cache assets must use versioned URLs
Even after the CSS was correct, users still saw jitter — the real culprit was cache headers. jsplugin-assets/* (common.css/common.js) previously used a fixed, unversioned URL + Cache-Control: immutable, so browsers didn't even revalidate and cached the old file for a year; the fix could never reach users.
- General rule: any
immutablelong-cache asset must use a content-hash versioned URL (e.g.?v=<first 8 of sha256>), or any later change is unreachable for existing users. - Implementation:
injectHTMLHeadappends?v=<hash>to the injectedcommon.css/jsURLs; the hosting HTML isno-cacheso it always carries the latest version. When content is unchanged the URL is stable and the long cache still applies.
3. Web plugin iframe blocked by semantics nodes (unclickable)
After the Web player bar appears, flt-semantics nodes (pointer-events:auto) cover the plugin iframe and steal clicks.
- Root cause:
main.dartforces a persistent semantics tree on Web (ensureSemantics, an accessibility improvement) and hits a lingering engine bug [flutter/flutter#175119]:ensureSemantics+ go_router + platform-view iframe — after closing a dialog with a barrier, a semantics node stays atpointer-events:autolayered over the platform view. - Fix (option A): temporarily release the semantics handle to close the semantics tree when entering a plugin iframe page, and restore it on leave.
lib/core/a11y/web_semantics_controller.dartis a singleton holding aSemanticsHandle;shell_layout.dartsuspends/resumes on theisPluginTabedge. When a screen reader is active the platform holds its own handle, so releasing ours doesn't close the tree — AT users are unaffected.
4. Web mobile black screen after returning from background (status: accepted, awaiting engine fix)
Android Chrome shows a black screen after returning from background.
- Root cause: while backgrounded the browser discards CanvasKit's WebGL context; the engine sets
_forceNewContextthen passively waits forwebglcontextrestored— which Android Chrome often never fires. Deeper still is engine bug [flutter/flutter#184683]: in the new surface architectureonContextLostfires before alatefield is assigned →LateInitializationError→ render frame crashes → white screen. The fix PR [#185116] is not yet in the latest stable. - Current decision: all platforms (including Web) pin to stable Flutter 3.44.6 with GPU rendering; 3.44.6 lacks #185116, so mobile Web background black screen is accepted as-is, to be truly fixed by upgrading once the fix lands in stable.
- Don't retry these directions: source backport is useless (the web engine is precompiled into
.dill; release dart2js doesn't readlib/_enginesources); CDN vs offline wasm is unrelated to the black screen (it only changes the initial load source, not the GPU context lifetime); pure JS can't force Flutter to produce a frame — recovery must be on the Dart side.
5. Audio playback backend
All native platforms unified on media_kit/libmpv, no kill-switch
- All native platforms (Win/Linux/macOS/Android/iOS) always use media_kit (libmpv); the native ExoPlayer/AVPlayer fallback and the
SONGLOFT_MEDIAKIT_*switch have been removed (AudioBackend.usesMediaKit => !kIsWeb). EQ always goes through mpvaf(MpvEqualizerService). - ⚠️ There is no kill-switch anymore; if media_kit breaks on a platform you can no longer fall back via
--dart-define— you must change code. - Historical pitfalls (
SongloftMediaKitPlayerimplementation notes):- Must override
setAndroidAudioAttributesas a safe no-op — the base class throws by default, just_audio calls it only on Android, otherwise every track on Android crashes right atsetAudioSource(UnimplementedError). - On mobile, do not pre-create a
VideoController— pre-creating makes everyopen()/play()/seek()awaitvideo-texture readiness, and on Android without a Video widget that Future never completes → every track hangs. Build it lazily only when a video source is detected.
- Must override
Self-signed / incomplete-chain HTTPS playback: AudioSource.uri(headers=null) bypasses the local proxy
- Root cause: SSL bypass is only done at the Dart layer (
HttpOverridestrust-all + mpvtls-verify). But whenAudioSource.uriis passedheaders=null, just_audio skips the local plaintext loopback proxy and hands the URL straight to the native player, whose TLS handshake is outsidedart:iowhereHttpOverridescan't reach → self-signed fails. (just_audio only engages the local proxy whenheaders!=null || userAgent!=null.) - Fix: when
insecureTls==true, attach a non-empty header toAudioSource.urito force the local proxy — the native player only connects to127.0.0.1in plaintext while upstream HTTPS is fetched by just_audio's DartHttpClient(trust-all). Mobile HLS with self-signed certs needs a custom HLS-aware trust-all local proxy (lib/core/network/insecure_media_proxy_native.dart) that recursively rewrites m3u8 sub-resources.
Desktop HLS radio failures
- Live HLS segment expiry: desktop mpv has no CORS, but when the user has the global HLS proxy on, timestamped short-window segments have already rolled out of the window after a round trip through the local proxy →
404. Fix: the backendserveRadiosupports?hls=directto force a 302 bypass of the proxy, and the desktop frontend appends it for live playback (desktop carries its own Referer/UA); mobile does not (its native player sends no Referer/UA, so it keeps the proxy). - HE-AAC client decode crash: HE-AAC stations like
streamtheworldcrash after ~3.7s on both Web and mpv withaac: decode_band_types: Input buffer exhausted. Confirmed by testing that the backend is not at fault (both direct ffmpeg and a faithful proxy replica decode cleanly); the real cause is the client decoder failing on HE-AAC. Partial mitigation: backend mapsaudio/aacp→audio/aac, mpv addsnetwork-timeout; a full fix needs backend transcoding fallback (not done).
Windows plugin page requires explicit WebView2 initialization
- The Windows portable build never initializes
WebViewEnvironment; the default user-data folder lands in a read-only directory →Cannot create the InAppWebView instance!, and on failure the controller stays null andreload()is a no-op that never self-heals. - Fix:
core/utils/webview_environment.dartbuilds a global singleton usinggetApplicationSupportDirectory()/webview2as a writableuserDataFolder, passed to bothInAppWebViewsites, with retry changed to rebuild the widget via a newValueKey. - Related: in cleanup that hard-kills the process before exit (
TerminateProcess), tray-icon/resource removal must come before the hard-kill call, ordestroy()becomes dead code (the hard-kill never returns).
6. Playing a single song from a paginated list (hard rule)
Rule: any "tap a single song to play" in a paginated list must playPlaylist(loaded, startIndex) + backfill in the background with the same filter
- Pitfall: a paginated list (playlist >100 songs, category pages, etc.) calling
playPlaylist(songs, startIndex)directly, wheresongsis only the currently-loaded page → the play queue is truncated to the loaded count (e.g. max 100) and only loops within it. - Fix: use
PlayerNotifier.playPlaylistFromLoaded— play immediately with the loaded page + startIndex, and whentotal > loaded countbackfill in the background with the same sort/order/keyword (reusing_loadRemainingSongsById). - Any new "tap single song in a paginated list" page must include the backfill logic; category/facet filter pages must keep the field→getSongs parameter mapping consistent (
categorySongsFilter).
Related module references
- Plugin common assets and theme bridge:
internal/jsplugin/assets/(common.css/common.js),injectHTMLHead. - Play activity / prefetch transcoding: prefetch transcoding must not be canceled by activating the current song via
playactivity.Activate(ActivateskipsCatPrefetch); otherwise the next track's prefetch ffmpeg is SIGKILLed and playback still transcodes in real time. - Sources without duration (e.g. WebDAV):
song.duration=0makes miot speakers not advance tracks (advancing relies on server-side duration); probe and backfill on import (RefreshSongwith bounded concurrency afterAddRemoteSongs).
