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 four layers of root cause (layer 4 is not limited to plugin pages — the whole app shakes):
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/theme.css, give the embedded case (html.embed)overflow-y: scroll(scrollbar always present, constant width, loop broken), plusscrollbar-width: none+::-webkit-scrollbar { display: none }so it takes no layout width (otherwise100vw-sized elements inside plugins overflow horizontally, #341). Only forced in the embedded case: a plugin page opened directly in a browser can be resized freely and has no such loop. - 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 at the time, now split into theme.css / components.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 every injected common-asset URL (assetVersionsininternal/jsplugin/routes.go); 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. Any new common asset must be registered inassetVersionstoo.
4. Host page (hard rule): <html> must have viewport scrollbars disabled, or the whole page shakes (#439)
Layer 2 only fixed the plugin iframe's own document; the exact same loop also applies to the Flutter host page — symptom: home / library / plugin store / plugin pages all shake, with no plugin involved.
- Mechanism: the engine's
FullPageEmbeddingStrategyonly styles<body>(position:fixed; inset:0; overflow:hidden) and leaves<html>at the defaultoverflow:visible. As soon as anything overflows the document by a few pixels (e.g. an overlay a browser extension injects into<html>— our own DOM all lives inside the fixed body and never contributes), a classic scrollbar appears and eats 15px of the viewport → the viewport-sized body narrows → Flutter relayouts the whole app → the overflow is gone → the scrollbar is removed → flip every frame, i.e. a ~15Hz whole-page shake. - Fix:
clients/player/web/index.htmlsetshtml { overflow: hidden; scrollbar-width: none }+html::-webkit-scrollbar { display: none }. The host page never needs document-level scrolling (the Flutter view is a fixed full-screen element; all scrolling happens inside the app), so disabling it makes the loop structurally impossible — no need to first identify what overflowed the document. - How to diagnose (the visual shake cannot be reproduced where scrollbars are overlay-style, but the mechanism is measurable):
- Frame-diff a screen recording: in a shaking frame pair a large left-hand region is pixel-identical (left-aligned content doesn't move) while right-aligned elements translate by exactly one scrollbar width (~15px for a classic scrollbar), and a scrollbar thumb appears along the right and bottom edges (when the overflow is only a few pixels the thumb nearly fills the track). That combination is the fingerprint of "the viewport lost a slice to a scrollbar", unlike an app animation (which wouldn't leave the left side bit-identical);
- Measure in-page:
innerWidth - document.documentElement.clientWidth(>0 means a scrollbar is taking width) and whetherscrollWidth/scrollHeightexceed the client sizes.
- Don't try to work around it with
scrollbar-gutteror app-sideScrollbarconfig: the former is a no-op while<html>isvisible(same trap as layer 2), and the latter isn't a variable in this loop at all.
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.
The persistent semantics tree is desktop-Web only (on mobile browsers it kills the soft keyboard)
On iOS Safari, tapping the username/password field on the login page does not bring up the keyboard; only after switching to the home screen and back to Safari can you type, and by then the page is zoomed in so you have to type blind (songloft-org/songloft-player#26).
- Root cause: the engine's
HybridTextEditing.strategyislate final— it is fixed once, the first time text editing is used, based onsemanticsEnabledat that moment. CallingensureSemantics()at startup pins the whole session toSemanticsTextEditingStrategy, bypassingIOSTextEditingStrategy— the only one carrying the iOS workarounds (move offscreen before focusing, place 100 ms later, tap listener) plus the iOS 26 "field is blurred right before autofill" mitigation (which only listens forfocusinonflt-text-editing-host, while in semantics mode the input lives inflt-semantics-hostand is out of reach). Long-standing upstream bugs: [flutter/flutter#129324] (open, still reproducible on 3.41), [#123338], [#141975], [#154741] — all "semantics mode + mobile browser → keyboard never shows or closes immediately", and all tied toInputDecoration(label/hint/suffixIcon) plus a scroll container, which is exactly the shape of the login page. - Fix:
WebSemanticsController.enableByDefault()skips mobile browsers (defaultTargetPlatformof iOS / Android); only desktop Web keeps the tree resident. Mobile falls back to the engine's on-demand path (MobileSemanticsEnablerenables semantics once it detects the screen-reader gesture), so AT users are still served. - Zoom fixed as a side effect: in semantics mode the input has
font-size: 13.33pxinside a host scaled byscale(1/dPR), so iOS zooms the page on focus; the non-semantics hidden input isfont-size: 16px— exactly the threshold at which iOS does not zoom. - How to verify: in headless Chrome the engine's OS detection reads
navigator.platform, so changing the UA alone does nothing — useevaluateOnNewDocumentto overrideplatform: 'iPhone'andmaxTouchPoints > 2. Criteria: with the mobile UA there are 0flt-semanticsnodes and aflt-semantics-placeholderis present, and tapping a field puts a focused<input>insideflt-text-editing-host; with the desktop UA the semantics nodes are still there.
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/(theme.css/components.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).
