Response language: Always respond in English.
SimpMusic is a FOSS (Free and Open Source Software) YouTube Music client for Android and Desktop, built with Compose Multiplatform.
- Stream music from YouTube Music and YouTube for free, ad-free, with background playback
- Provide advanced features like Spotify Canvas, AI song suggestions, synced lyrics
- Support both Android and Desktop (Windows, macOS, Linux)
- Package name:
com.maxrave.simpmusic - Primary language: Kotlin
- UI Framework: Jetpack Compose / Compose Multiplatform
- Architecture: Clean Architecture + MVVM
- Build system: Gradle (Kotlin DSL)
┌─────────────────────────────────────┐
│ Presentation Layer (UI) │
│ - Jetpack Compose / Compose MP │
│ - ViewModels (MVVM) │
│ - UI States │
├─────────────────────────────────────┤
│ Domain Layer │
│ - Use Cases │
│ - Domain Models │
│ - Repository Interfaces │
├─────────────────────────────────────┤
│ Data Layer │
│ - Repository Implementations │
│ - Data Sources (Remote/Local) │
│ - Database (Room) │
├─────────────────────────────────────┤
│ Service Layer │
│ - YouTube Music Scraper │
│ - Spotify Service │
│ - AI Service │
│ - Lyrics Service │
│ - Discord RPC (Kizzy) │
└─────────────────────────────────────┘
- Shared Compose Multiplatform module - main module containing shared code
- Supports: Android, Desktop (JVM), iOS (future)
- Contains all UI (Compose) and business logic
- Source sets:
commonMain/: Shared code for all platformsandroidMain/: Android-specific codedesktopMain/: Desktop-specific code
- Can run Desktop app directly from this module
- Android-specific module to build Android app
- Depends on
composeAppas a shared module - Contains Android-specific configuration:
- AndroidManifest.xml
- Android build configuration
- Android resources (if needed)
- Entry point for Android app
Contains core modules organized by functionality:
- Shared utilities
- Extension functions
- Constants
- Helper classes
- Domain models
- Use cases
- Repository interfaces
- Business logic rules
- Repository implementations
- Data sources (Remote & Local)
- Database schemas (Room)
- Data mappers
- media3/: Media3 ExoPlayer integration (includes
CrossfadeExoPlayerAdapterfor DJ-style crossfade on Android, andaudio/EqualizerAudioProcessorfor the ten-band equalizer) - media3-ui/: Media3 UI components
- media-jvm/: JVM media playback (libmpv via JNA — replaced VLCJ, which replaced GStreamer post-1.0.4)
- media-jvm-ui/: JVM media UI components
Service modules:
- kotlinYtmusicScraper/: YouTube Music API scraper
- spotify/: Spotify Web API integration (Canvas, Lyrics)
- aiService/: AI features (OpenAI, Gemini integration)
- autoEqService/: AutoEq headphone correction profiles (index + fixed-band curves)
- lyricsService/: Lyrics fetching (LRCLIB, SimpMusic Lyrics, BetterLyrics)
- listenTogether/: shared listening rooms, wire-compatible with Metrolist (
MetrolistGroup/metroproto) - kizzy/: Discord Rich Presence
- ktorExt/: Ktor extensions for networking
- crashlytics/: Full version with Sentry crash reporting
- crashlytics-empty/: FOSS version without tracking
- lastfm/: direct Last.fm scrobbling for the Full build. KMP (android + jvm + ios), package
org.simpmusic.lastfm. Signsapi_sigwith okio's MD5; talks tows.audioscrobbler.com/2.0/over form-urlencoded POST - lastfm-empty/: FOSS no-op stub with the identical public API —
isLastfmAvailable()returnsfalse, which hides the whole settings block. A FOSS build ships no API secret, so it ships no Last.fm code either - Selected via
isFullBuildincore/data/build.gradle.kts(playback hooks) andcomposeApp/build.gradle.kts(UI); credentials come fromLASTFM_API_KEY/LASTFM_SECRETinlocal.propertiesvia BuildKonfig, and are handed in withconfigLastfm(key, secret)at startup — the same shape asconfigCrashlytics(context, dsn) - Auth is Last.fm's web flow on every platform: open
last.fm/api/auth/?api_key=Xwith no token, the user approves in their own browser, Last.fm redirects to the callback with?token=, thenauth.getSession. The app never sees a password. Do not switch to the desktop flow (auth.getTokenfirst, then open the same URL with&token=on it): that tells Last.fm the app already holds the token, so it renders a "return to the application" page and the callback is never called — which looks exactly like a broken redirect - The callback registered on the API account is
wordbyword://lastfm-auth, handled by an intent-filter on Android and by Conveyorurl-schemes+WindowsProtocolRegistraron Desktop; the login screen also accepts the callback URL pasted by hand, for hosts where no scheme handler exists
- cast/: Google Cast support for the Full build (
media3-cast+play-services-cast-framework,CastOptionsProvider,CastIconButtonCompose wrapper forMediaRouteButton) - cast-empty/: FOSS no-op stub with identical public API (package
org.simpmusic.cast), keeping GMS out of F-Droid builds - Selected via the
isFullBuildGradle property (same pattern as crashlytics) incore/media/media3/build.gradle.ktsandcomposeApp/build.gradle.ktsandroidMain - Playback handoff lives in
core/media/media3(cast/CastHandoffManager.kt+cast/CastStreamResolver.kt): the session player isCastPlayer.Builder().setLocalPlayer(forwardingPlayer).build(); while remote,CrossfadeExoPlayerAdapterroutes transport/getters to the receiver and pushes a resolved-URL queue window (googlevideo URLs resolved up-front viaStreamRepository); crossfade/EQ/precache are force-disabled while casting
- Jetpack Compose: Modern UI toolkit
- Material Design 3: Design system
- Media3 (ExoPlayer): Media playback
- Room: Local database
- Coroutines & Flow: Async programming
- Hilt/Koin: Dependency injection
- Compose for Desktop: UI
- libmpv (mpv's C client API, bound with JNA): audio + video playback. Replaced VLCJ, which had replaced GStreamer post-1.0.4
- libmpv natives are bundled per platform via
./gradlew :composeApp:mpvSetupAllintompv-natives/<os>-<arch>/
- Ktor Client: HTTP client
- Kotlin Serialization: JSON parsing
- YouTube Music hidden API: Data source
- quickjs-kt (
io.github.dokar3:quickjs-kt): runs YouTube's player JS on-device to solve signature/nchallenges - Spotify Web API: Canvas and lyrics
- OpenAI/Gemini API: AI features
- Room Database: Local persistence
- DataStore: Preferences
- Caching: Offline playback support
- SponsorBlock: Skip sponsors
- ReturnYouTubeDislike: Vote information
- LRCLIB: Lyrics provider
- BetterLyrics: Additional lyrics provider (added in v1.0.4)
- Sentry: Crash reporting (Full version only)
- Kotlin coding conventions: Follow Kotlin official guidelines
- Compose best practices: Single source of truth, unidirectional data flow
- Clean Architecture: Strict layer separation, dependency rule
UI Layer (composeApp)
↓
Domain Layer (core/domain)
↓
Data Layer (core/data)
↓
Service Layer (core/service/*)
↓
Common (core/common)
Dependency Rule: Higher layer modules can only depend on lower layer modules, NOT vice versa.
- Use Jetpack Compose for all new UI
- Follow Material Design 3 guidelines
- State management with StateFlow or State<T>
- Side effects with LaunchedEffect, DisposableEffect
- Repository pattern for all data operations
- Use cases for complex business logic
- Mapping between Data models ↔ Domain models ↔ UI models
- Room for local persistence
- Ktor for network requests
Before implementing code, researching code, or answering technical questions, the AI agent MUST follow this research workflow:
- Use MCP Context7 (
resolve-library-id→query-docs) to fetch up-to-date documentation for any library/framework about to be used - Understand the latest API surface, breaking changes, and recommended usage patterns
- Use WebSearch to research:
- Pros and cons of the library/approach
- Alternative libraries or approaches that solve the same problem
- Known issues, performance concerns, or deprecation notices
- Compare and evaluate whether the chosen library/approach is the best fit for this project
- Use Grep (on GitHub via web search) or WebSearch to find how well-known open-source projects implement similar features
- Verify the approach follows established best practices before adopting it
- Pay attention to patterns used in projects with similar architecture (Clean Architecture, Compose Multiplatform, etc.)
- Only proceed with implementation after completing steps 1-3
- If a library/approach has significant drawbacks or better alternatives exist, recommend the better option to the user before proceeding
- Document the rationale briefly when introducing new dependencies or patterns
This workflow applies to: Adding new libraries, choosing architectural patterns, implementing new features with unfamiliar APIs, answering "how should we do X?" questions, and evaluating technical approaches.
This workflow does NOT apply to: Simple bug fixes in existing code, minor refactoring, or tasks using libraries already well-established in the project.
- Do NOT build the app to verify code changes. Instead, use JetBrains MCP tools (
get_file_problems,getDiagnostics) to check for compile errors and warnings in real-time. - Only run Gradle build when explicitly requested by the user or for final release verification.
- Unit tests for Domain layer (Use cases)
- Repository tests with fake data sources
- UI tests with Compose Testing
Location: composeApp/src/commonMain/kotlin/
- Create Composable function in appropriate package
- Use ViewModel for state management
- Follow Material 3 design patterns
Location: core/service/kotlinYtmusicScraper/
- Implement endpoint in corresponding service
- Create data model for response
- Map to domain model
Location: core/data/src/main/java/.../database/
- Define Entity with Room annotations
- Create DAO interface
- Update Database class
- Create migration if needed
Location: core/domain/src/main/java/.../usecase/
- Create use case class
- Inject repository dependencies
- Implement business logic
- Return Result/Flow
Location: core/media/media3/ (Android) or core/media/media-jvm/ (Desktop)
- Media3/ExoPlayer + CrossfadeExoPlayerAdapter for Android
- libmpv (MpvPlayerAdapter / MpvPlayer / MpvLibrary) for Desktop
- Queue management in
core/data/src/.../mediaservice/ - Playback controls
Location: core/service/lyricsService/
- Implement lyrics fetcher interface
- Add fallback logic
- Handle synced/unsynced lyrics
Location: core/service/aiService/
- OpenAI integration
- Gemini integration
- AI lyrics translation
- Song recommendations
Location: composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/icon/
All icons are Material Symbols Rounded generated as Compose ImageVectors. There is no
material-icons-extended dependency and no XML icon drawable — do not add either back.
Fetch it from Google's own generator (it returns a ready .kt file, gzipped):
curl -sfL --compressed \
"https://fonts.gstatic.com/render/v1/Material+Symbols+Rounded/24dp/<symbol_name>.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,1,0,50" \
-o <PascalName>.ktKeep the axes identical for every icon so the set stays consistent: Rounded, opsz 24, wght 400,
GRAD 0, ROND 50, FILL=1. Use FILL=0 only for the "off" half of a state pair (e.g.
FavoriteBorder, AddCircleOutline, DownloadForOfflineOutlined) — otherwise the empty and
filled states render identically.
Then edit the downloaded file:
package com.example.test→package com.maxrave.simpmusic.ui.iconpublic val <symbol_name>: ImageVector→val SimpIcons.<PascalName>: ImageVector- Rename the backing field
_<symbol_name>→_<PascalName>, andname = "<symbol_name>"→"<PascalName>" - For an icon that must flip in RTL, add
autoMirror = true,toImageVector.Builder
Use it: SimpIcons.PlayArrow — plus a per-icon import, import com.maxrave.simpmusic.ui.icon.PlayArrow.
- Each icon needs its own import.
val SimpIcons.Xis an extension property, so importing theSimpIconsobject alone does not bring it into scope. This is also what lets R8 drop unused icons — do not "simplify" it into a map or awhen, that would ship all of them. ImageVectoris not aPainter.Icon/Imagehave overloads for both, butAsyncImage(placeholder/error), anything drawing inside aDrawScope, and custom composables typedPainterdo not — wrap withrememberVectorPainter(SimpIcons.X)there.- The response is gzipped even when the request asks for
identity; decompress by magic bytes. - Do not replace an icon whose colour carries meaning.
baseline_downloaded.xml(#FF00A0CB),baseline_favorite_24.xml(#D10000),mono.xml,monochrome.xmland theholder*.pngplaceholders stay as resources; a tinted neutral symbol is not equivalent. - Verify a name exists before assuming: the Symbols codepoint list is at
google/material-design-icons→variablefont/MaterialSymbolsRounded[...].codepoints. Legacy names likefavorite_borderandthumb_up_altdo still exist;person_add_alt_1does not.
build.gradle.kts(root): Root build configurationgradle/libs.versions.toml: Version catalog for dependenciessettings.gradle.kts: Module inclusion
composeApp/src/commonMain/kotlin/: Shared Compose codecomposeApp/src/androidMain/kotlin/: Android-specific codecomposeApp/src/desktopMain/kotlin/: Desktop-specific code
core/data/src/main/java/.../database/: Room database schemas- Migrations in Database class
core/service/kotlinYtmusicScraper/: YouTube Music APIcore/service/spotify/: Spotify APIcore/service/ktorExt/: Ktor utilities
composeApp/src/commonMain/composeResources/: Shared resourcescomposeApp/src/androidMain/res/: Android resources- Crowdin integration for translations
- Full: With Sentry crash reporting (module:
crashlytics) - FOSS: No tracking (module:
crashlytics-empty)
- Windows:
.msiinstaller - macOS:
.dmg(ARM and x86-64) - Linux:
.AppImage(DEB and RPM removed post-1.0.4)
- FOSS version: NO tracking
- Full version: Only Sentry crash reporting
- "Send back to Google" feature: Optional, only when user enables
- Min SDK: Check
androidApp/build.gradle.kts - Target SDK: Latest stable
- Android Auto support
- Background playback with MediaSession
- Required Dependencies:
- libmpv: audio + video playback (bundled via
mpvSetupAll; falls back to a system-wide libmpv whenmpv-natives/has not been staged)
- libmpv: audio + video playback (bundled via
- Minimum macOS: 15.0 — raised from 11.0 when VLC was replaced by mpv. mpv's macOS release builds target macOS 15 (96/98 arm64 dylibs declare
minos 15.0; on Intellibmpvitself does), and Conveyor rejects a lowerLSMinimumSystemVersion. No mpv artifact covers both architectures below 15. - Features:
- Deep link support (
simpmusic://andsimpmusic.org) - Mini Player window (always-on-top, resizable, draggable)
- Crash dialog
- Custom title bar (disabled in VM environments)
- Deep link support (
- Limitations:
- No offline playback
- YouTube Music: Hidden/unofficial API (may change anytime)
- Spotify: Requires login for lyrics
- OpenAI/Gemini: User must provide API key
- SponsorBlock: Public API
- LRCLIB: Public lyrics API
Location: core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/
MpvLibrary.kt— JNA binding for libmpv's C client API, hand-mapped against client API 2.x. Struct layouts are read by raw offset, so a MAJOR client-API bump needs them re-verifiedMpvPlayer.kt— one handle per media item;vo=libmpv+ software render contextMpvVideoFrameSource.kt— mpv SW render API → immutableBufferedImagesnapshots published viaStateFlow, drawn by plain ComposeImage(MpvVideoFramesinmedia-jvm-ui); replaced theSwingPanel-embeddedMpvVideoSurfacePanelon 2026-08-01MpvPlayerAdapter.kt— theMediaPlayerInterfaceimplementation; separate YouTube audio/video URLs are merged into ONE source with anedl://...;!new_stream;...URL (mpv's equivalent of Android'sMergingMediaSource)- Natives bundled per platform in
mpv-natives/<os>-<arch>/, staged bympvSetupAll(Linux slice is compiled from source —scripts/mpv-linux/) - Supports crossfade transition with dual-player approach
- Configurable duration: 1-15 seconds (default: 5 seconds)
- Skipped when the NEXT track will play as video (
isVideo()+ watch-video setting on) — same rule as Android since 2026-08-01 - Settings persisted via DataStore
Location: core/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt
- DJ-style crossfade with adjustable duration
- Requires 320kbps stream preference to enable DJ mode
- Auto crossfade mode (like AutoMix)
CrossfadeFilterAudioProcessorfor audio processing- Edge cases: disabled for video, repeat one, last track
See CODE_OF_CONDUCT.md
- Fork and create branch from
dev - Follow coding conventions
- Test thoroughly before submitting
- Update documentation if needed
- PR title: Clear and descriptive
- PR description: Explain changes and reasoning
- Use Crowdin: https://crowdin.com/project/simpmusic
- Don't edit translation files directly
- InnerTune: YouTube Music data extraction inspiration
- SmartTube: YouTube streaming URL extraction
- SponsorBlock: Sponsor skip functionality
- LRCLIB: Lyrics provider
- Compose Multiplatform
- Material Design 3
- Media3 (ExoPlayer)
- Room Database
- Ktor Client
- libmpv client API
- mpv EDL format
- Website: https://simpmusic.org
- Discord: https://discord.gg/Rq5tWVM9Hg
- GitHub Issues: Bug reports and feature requests
When working with this project:
- Always check layer dependencies: Don't violate Clean Architecture rules
- Use existing patterns: Review current code to follow established patterns
- Platform-aware: Code in
commonMainmust work for both Android and Desktop - Test thoroughly: Especially critical for media playback and network code
- Consider privacy: FOSS version must NOT have tracking
- Check external API stability: YouTube Music API may change at any time
- Check Discord server for known issues
- Review recent commits and PRs
- View dependency graph:
asset/dependencies_graph.svg - Test on both Android and Desktop if code is in commonMain
Example: Desktop-only UI settings
if (getPlatform() == Platform.Desktop) {
// Desktop-specific UI or logic
}Example: Android-only features
if (getPlatform() == Platform.Android) {
// Android-specific UI or logic
}- Desktop: GStreamer → VLCJ: Completely replaced GStreamer with VLCJ for desktop audio playback
- DEB/RPM builds removed: Desktop Linux now only ships AppImage
- Android Crossfade & DJ-style transition:
CrossfadeExoPlayerAdapterwith auto mode (like AutoMix) - BetterLyrics provider: Additional lyrics source integrated into lyricsService
- 320kbps audio stream option: Higher quality streaming preference
- Parallel download: Improved download speed
- Character-level animated lyrics: Word-by-word lyrics with spring animations
- SimpMusic Chart: Chart playlists integrated into Library screen
- Favorites: Liked songs feature with UI integration
- Custom OpenAI base URL: Support for compatible API endpoints
- Desktop Mini Player: Always-on-top, resizable, draggable mini player window with volume/like controls
- Analytics/Local Tracking: Track top artists, albums, and tracks locally (no remote tracking)
- Auto Backup: Automatic backup settings
- Custom Title Bar: Desktop window control with transparency support
- SimpMusic Lyrics voting: Vote functionality for community lyrics
-
Icons unified on Material Symbols (2026-08-03):
material-icons-core/material-icons-extendedare gone, and so are the XML icon drawables — every icon is now a generatedImageVectorunderui/icon/, addressed asSimpIcons.<Name>. Two migrations fed into this: 59 icons replacingIcons.*(117 call sites), then 25 more replacingpainterResource(Res.drawable.baseline_*)(167 call sites, 44 XML files deleted).RippleIconButton,LiquidGlassIconButtonandActionButtonchanged from takingDrawableResource/PaintertoImageVector. See Common Tasks → Add a New Icon for how to add one and which traps to avoid. Icons whose colour carries meaning (baseline_downloaded,baseline_favorite_24), the logos (mono,monochrome) and theholder*bitmaps deliberately stay as resources. -
Deep link support:
simpmusic://andsimpmusic.orgURL schemes -
Desktop Crash dialog: Error reporting UI for desktop
-
Playback speed/pitch controls: Redesigned UI with improved animations
-
VM environment detection: Disable transparency and custom titlebar in VMs
-
Google Cast (2026-07, Full build only):
cast/cast-emptymodule pair gated byisFullBuild; unified Media3CastPlayerwraps the sessionForwardingPlayer;CastHandoffManagerpushes resolved-URL queue windows to the receiver with 403/expiry retry; Cast button in Now Playing top bar, "Playing on " pill, crossfade/DJ/EQ settings gray out while casting; FOSS build stays GMS-free -
Windows SMTC (2026-07): System Media Transport Controls on Windows via
jmtc/nowplayingcenter0.0.3 (forked JMTC). The nativeSMTCAdapter.dllwas hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment toleratesRPC_E_CHANGED_MODE,MediaPlayerkept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), andMediaType.Musicis set before display properties so title/artist render (not just the app name). Enabled inJvmMediaPlayerHandlerImplforPlatform.Windows(Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (windows-latest) in the NowPlayingCenter repo. -
VLC removed entirely (2026-07-27):
VlcPlayerAdapter,DefaultVlcDiscoverer,MacOsVlcDiscovererandVlcModuleare deleted;VlcModule.ktbecameDesktopPlayerModule.kt(loadVlcModule()→loadDesktopPlayerModule()). Thevlcjdependency, thevlc-setupGradle plugin, everyvlcSetup*task, thevlc-natives/tree and the VLC Conveyor inputs are all gone.appResourcesRootDirnow points atmpv-natives/. libmpv is the only desktop backend. -
Bundled libmpv (2026-07-27): two entry points, deliberately split.
:composeApp:mpvBundleAllruns on a Mac, once per mpv bump — it turns upstream mpv builds into loadable slices inmpv-natives/<os>-<arch>/, packs them into tarballs and prints their SHA-256. Those are published tomaxrave-dev/simpmusic-files.:composeApp:mpvSetupAllis what CI runs: it downloads those tarballs, verifies them against the digests pinned inmpvNativesChecksums, and unpacks them — no toolchain needed on the runner. Both workflows must call it before Conveyor, which is invoked by its own action and so never triggers the GradledependsOn.- Sources: shinchiro
mpv-dev-*.7z(Windows — the only one shipping a reallibmpv-2.dll), mpv's own release.zip(macOS), and for Linux a from-source container build (see below). On macOS libmpv is statically linked into thempvexecutable; that PIE binary exports the full client API and is renamed tolibmpv.dylib, with load-command paths repointed to@loader_path. - Do NOT lift
IINA.app/Contents/Frameworksinstead: IINA 1.4.4 ships a version-skewed pair (libmpv needs_pl_log_create_349, bundledlibplacebo.338.dylibexports_pl_log_create_338) and that libmpv failsdlopenunder both RTLD_NOW and RTLD_LAZY. - Every
._*sidecar must be stripped after unpacking (mpvSetupAlldoes this). Tarring a slice on macOS writes each file's xattrs out as a companion._name; Conveyor then signs them as ordinary bundle members and seals them in_CodeSignature/CodeResources, but macOS folds._nameback into the xattrs ofnameand deletes the sidecar the moment Finder touches the app — unzipping it or dragging it out of the DMG. The launched bundle is then missing every sidecar the seal expects and Gatekeeper reports "SimpMusic is damaged and can't be opened" (codesign --strict:a sealed resource is missing or invalid). Only macOS is affected: it alone seals the whole app directory and re-checks it at launch. MpvLibrary.bundledLibraryDirs()resolves the staged folder:mpv.bundled.path→compose.application.resources.dir→mpv/found by walking up from the JAR →mpv-natives/<os>-<arch>.
- Sources: shinchiro
-
Linux libmpv built from source (2026-07-28): the AppImage route is gone —
scripts/mpv-linux/Dockerfilenow compiles libplacebo 7.351 + FFmpeg 7.1.1 + mpv 0.41.0 on Ubuntu 22.04, andmpvSetupLinuxCiruns that container and copies/out. This deleted ~186 lines of DwarFS extraction, closure pruning and rpath rewriting fromcomposeApp/build.gradle.kts.- Why the AppImage could never work: every prebuilt Linux mpv targets "run mpv as its own process".
mpv-AppImageships its own glibc +ld-linux, and its "libmpv.so.2" was really thempvPIE executable — glibc refuses todlopena PIE outright (DF_1_PIE), and even patched past that, its glibc 2.43 collides with the one the JVM already mapped. It only ever appeared to work on dev machines because JNA silently fell back to a system-wide libmpv. Always log the resolved path (NativeLibrary.getInstance(name).file) — that is the only thing distinguishing "using the bundle" from "quietly using /usr/lib". - The container build targets glibc 2.34 → runs on Ubuntu 22.04 / Debian 11 and newer. Vulkan/shaderc/glslang/D3D11 are disabled in libplacebo and X11/Wayland/GPU in mpv, since playback goes through the software render API; that also drops
libshaderc/libglslang/libSPIRV-Tools(the bulk of the old bundle) and removes libsixel entirely, which had been aborting the JVM. stage.shdeliberately does not bundlelibc/libm/libstdc++/ld-linux, setsDT_RPATH(notDT_RUNPATH— RUNPATH is not inherited by transitive dependencies), and fails the build unless adlopen+mpv_initializesmoke test passes.- mpv built with
-Dlua=disabledhas noytdl_hook, so theytdloption genuinely does not exist there;MpvPlayerusesoptionalOption()to treatMPV_ERROR_OPTION_NOT_FOUNDas success.
- Why the AppImage could never work: every prebuilt Linux mpv targets "run mpv as its own process".
-
Last.fm scrobbling (2026-07-30, Full build only):
lastfm/lastfm-emptymodule pair gated byisFullBuild, following thecast/crashlyticsshape.LastfmScrobbler(incore/data/.../lastfm/) lives incommonMainand is driven by both player handlers, because Android and Desktop run entirely separate ones. It sendstrack.updateNowPlayingwhere the Discord RPC is updated, andtrack.scrobbleoff the existing 5-second position-persist tick — a track over 30s scrobbles at half its length or 4 minutes, whichever comes first.status="ok"does not mean accepted. Last.fm answers OK while discarding a scrobble and only says so inignoredMessage: code 1 = artist name filtered, 2 = track name filtered, 3/4 = timestamp too far past/future, 5 = daily limit. Codes 1 and 2 are how bad metadata surfaces, so they are logged loudly rather than dropped.- The two auth flows are not interchangeable, and picking the wrong one silently kills the callback. Web flow: send the user to
last.fm/api/auth/?api_key=Xwith no token; Last.fm mints it and redirects to the registered callback with?token=. Desktop flow: callauth.getToken, then open that URL with&token=already on it; Last.fm then shows "return to the application" and never redirects. SimpMusic uses the web flow because it has a registered callback and deep-link handlers on every platform. - The callback token does NOT travel through navigation.
App.kthands it straight toSharedViewModel.completeLastfmLogin(), andLastfmLoginScreencloses itself by watching the stored session key. Navigating to the login screen with the token instead pushes a second copy on top of the one the user opened their browser from, so thenavigateUp()after a successful login only peels off that copy and lands back on a login screen — it looks exactly like "logged in but still stuck on the login screen". The other three login screens never hit this because they embed a WebView and never leave the app; Desktop has no real WebView (Cookies.jvm.ktis a placeholder), which is why Last.fm uses the system browser at all. toSortedMap()does not exist in common Kotlin (it is a JDK collection) — sort the signature parameters withentries.sortedBy { it.key }.formatmust be excluded fromapi_sig. Parameters are sorted by name, concatenated<name><value>, secret appended, MD5'd — but signingformat(orcallback) yields "Invalid method signature supplied" (code 13) on every request.- Error codes worth branching on:
9invalid session key → clear the stored session and make the user log in again;11/16/29→ transient, retryable; everything else is a malformed request. - Two places where Last.fm's own docs contradict themselves, resolved conservatively:
timestampis the time the track started (the method page says started, the scrobbling guide says finished — every scrobbler in the wild sends the start), anddurationis always sent (optional on one page, required on the other). - Responses are parsed as loose
JsonObjects, not@Serializableclasses: Last.fm's JSON is a translation of its XML, so numbers arrive as strings, attributes hide under@attr, and a field is an object with one entry but an array with several.
-
JNA open flags are POSIX-only (2026-07-28):
MpvLibrarypassesOPTION_OPEN_FLAGS = 2(RTLD_NOW without RTLD_GLOBAL) only when not on Windows. JNA forwards the value verbatim toLoadLibraryEx, where2meansLOAD_LIBRARY_AS_DATAFILE: the DLL maps as plain data, imports never resolve, andGetProcAddressreturns nothing — surfacing as the misleadingError looking up function 'mpv_client_api_version': The specified module could not be found. -
Desktop URL schemes were never actually registered (2026-07-31):
url-schemesbelongs at the top level ofappinconveyor.conf. Conveyor binds it onAppConfig, not onMacConfig/WindowsConfig/LinuxConfig— compareMacConfigAccess.getUrlSchemes()(readsappConfig) with thegetFileAssociations()beside it (readsmac). It had been written asmac.url-schemes/windows.url-schemes/linux.url-schemessince May 2026; HOCON accepts unknown keys silently, so all three sat inert and every packaged build shipped with noCFBundleURLTypesat all — macOS never routedsimpmusic://either, not just the Last.fm callback. Proven by diffing twomac-appbuilds that differed only in where the key was written. The same misplacement had parkeddesktop-file.Categories/Comment[en]/StartupWMClassbeside the"Desktop Entry"group instead of inside it, so those never reached the generated.desktopeither. Two more links in the same chain: the argv filter inrunDesktopAppmatched a fixed list (simpmusic://,http://,https://) and therefore discardedwordbyword://lastfm-auth?token=…on Windows and Linux — it now matches anyscheme://— and the AppImage's own.desktop(written bypackageConveyorAppImage, which is what actually reaches users since AppRun installs it into~/.local/share/applications) now declaresx-scheme-handler/wordbywordalongsidesimpmusic. -
Bundled glib disabled
java.awt.Desktopon Linux (2026-07-31):mpv-natives/linux-x64/lib/libglib-2.0.so.0is glib 2.72 (built on Ubuntu 22.04) and is missing fromSYSTEM_LIBSinscripts/mpv-linux/stage.sh, so it ships in the bundle and claims the glib soname the moment JNA loads libmpv at startup. AWT'sXDesktopPeer.init()can then no longer dlopen the systemlibgio-2.0.so.0: on a glib 2.80 host (Ubuntu 24.04) it dies withlibgobject-2.0.so.0: undefined symbol: g_dir_unref, and the JDK reports the whole Desktop API unsupported for the rest of the process. All 23 external-link call sites broke at once —openUrl()was anifwith noelseso it silently did nothing, while Compose'sLocalUriHandlercallsDesktop.getDesktop()on its first line and threwUnsupportedOperationExceptionstraight out of the click handler, crashing the app. Arrived with the from-source Linux mpv build (2026-07-28); before that JNA quietly fell back to a system-wide libmpv, so the system glib was the only one mapped and links worked. Worked around by callingDesktop.isDesktopSupported()at the top ofrunDesktopApp—XDesktopPeercaches that probe, so running it before libmpv loads lets the system gio/gobject win the soname race.OpenUrl.jvm.ktadditionally gained a per-OS launcher fallback (xdg-open→gio open→$BROWSER) and a toast, so it can no longer fail in silence. The actual cure is to stop bundling glib — add it toSYSTEM_LIBS, which needs the Linux tarball rebuilt, republished and re-pinned inmpvNativesChecksums. -
Crossfade skips video tracks (2026-08-01): both
CrossfadeExoPlayerAdapter(Android) andMpvPlayerAdapter(Desktop) skip the crossfade path when the NEXT track will play as video (isVideo()+ watch-video setting on — the same condition that builds a merged audio+video source). The merged two-URL source is error-prone to prepare mid-fade and used to cut the outgoing song short or jump straight to the video at 0:00; such transitions now take the normal (non-crossfade) path. Update 2026-08-05: the CURRENT-track check — removed on Android in commit9da155d7because its old shape ignored the watch-video setting — is back on both platforms asisCurrentTrackVideo()(watchVideoEnabled && isVideo(), symmetric withisNextTrackVideo()), so a video also plays out to its last frame instead of fading out under the incoming song. -
Desktop video renders through Compose, SwingPanel removed (2026-08-01):
MpvVideoSurfacePanel(JPanel +SwingPanelembedding) becameMpvVideoFrameSource— the mpv SW render loop is unchanged, but finished frames are published as immutableBufferedImagesnapshots on aStateFlowand drawn by a plain ComposeImage(MpvVideoFramesinmedia-jvm-ui, converted withtoComposeImageBitmap()off the UI thread; the UI reports its size viasetTargetSize()). This kills the whole SwingPanel bug class: always-on-top z-order, one-frame-late repositioning while scrolling (the flicker that exposed the transparent window), and AWT's single-parent rule that made NowPlaying/Fullscreen/Artist screens fight over the one panel (video "randomly missing until next/prev").MpvPlayerAdapter.currentVideoSurface: StateFlow<Component?>is nowcurrentVideoFrames: StateFlow<MpvVideoFrameSource?>and is set unconditionally during crossfade — the old null-guard kept a dead panel from a released player on screen (the "black video" bug). -
macOS desktop audio moved to
ao=avfoundation(2026-08-01):MpvPlayernow pinsaoto"avfoundation,"whenPlatform.isMac(), becauseao_coreaudioleaks a process-wide CoreAudio listener onto a freedstruct aoand takes the whole JVM down the next time an audio device appears or disappears — Sentry-visible asEXC_BAD_ACCESSon theHALC_ProxyNotification Call Listener Queue, reproduced by simply plugging in headphones. Windows (wasapi) and Linux (pulse/pipewire) are untouched.- The chain, all upstream and still present in mpv master as of 0.41.0:
ao_coreaudio.cinit()registersAudioObjectAddPropertyListener(kAudioObjectSystemObject, …, hotplug_cb, (void *)ao)on the system object, but its failure label is bare (coreaudio_error: return CONTROL_ERROR;). An init that fails any later step (ca_init_chmap,init_audiounit) therefore leaves the listener registered.ao.cthen doesgoto fail→ao_uninit(), andbuffer.c'sao_uninit()callsdriver->uninit()only whendriver_initializedis set — a flagao.csets only after a successful init. Sounregister_hotplug_cb()never runs whiletalloc_free(ao)does, and the orphaned listener outlives the handle for the rest of the process. - Why SimpMusic hits this and plain mpv does not: mpv initialises one ao per session; SimpMusic creates one handle per media item and runs two at once during a crossfade, so a single failed audio init anywhere in a session arms the crash. The crash then waits for an unrelated hotplug event, which is why the process can look healthy for an hour first.
- Diagnosing it: the faulting address decodes as ASCII (
0x65636e6174736e49= "Instance"), the signature of a freed allocation already handed to another object. No thread was tearing an ao down at crash time, which is what ruled out a teardown race and pointed at a listener leaked much earlier. - Accepted trade-offs, neither reproducible in testing on macOS 27: delayed mute (mpv#15014) and audio desync on playback-speed changes (mpv#14483). The trailing comma in
"avfoundation,"keeps mpv's auto-probe as a fallback, so a failure degrades audio instead of silencing it. Remove the whole workaround once upstream frees the listener on the error path. - Related blind spot, still open: nothing calls
mpv_request_log_messages(), so libmpv's own warnings (including failed audio init) never surface anywhere.
- The chain, all upstream and still present in mpv master as of 0.41.0:
-
Sleep timer fade-out, and a second volume line to carry it (2026-08-14, issue #2330): the sleep timer used to end on a bare
player.pause(). It now ramps to silence over 5 s on an equal-power (cosine) curve, then holds silence forsleepFadeTailMs = 800before stopping. The attenuation rides a line of its own, deliberately notvolume: that one is the user's level and is reported back throughonVolumeChanged, so ramping it would drag the UI slider down and — if the process died mid-fade — leave a silent app behind.MediaPlayerInterfacegainedvar sleepFadeFactor: Floatfor it.- Android applies it as a new
SleepFadeAudioProcessorin the Media3 chain (arrayOf(crossfadeFilter, sleepFade)), one instance per ExoPlayer, all reading the same@Volatilefield. Desktop adds a third mpv level:MpvPlayernow blendsmasterPercent × sleepPercentintoao-volumewhilefadePercentkeeps carrying the crossfade on the softwarevolume. Crossfade and sleep fade therefore never share a variable and simply multiply. - Tail exists because gain sits ahead of the sink. AudioTrack buffers 250–750 ms, so pausing the instant the ramp hits zero still cuts at roughly −12 dBFS. Fade + tail are clamped to fit inside
remaining, or the timer overruns the track and pauses inside the next one. - The restore must not be queued by the caller.
pause()is asynchronous on both platforms and suspends partway through (commitIncomingAsCurrentjoins a job), so a single-thread dispatcher does not order "pause then restore" — the suspension releases the thread and the restore runs first, re-opening the mixer over the last of the audio. Each adapter therefore restores the factor itself, in afinallyat the end of its own pause task; the handler only restores on the cancelled path. The Android cast branch returns before that coroutine, so it clears the factor inline — skipping it leaves every sample multiplied by ~0 for the rest of the process.
- Android applies it as a new
-
Crossfade exclusions: short tracks and albums (2026-08-14): crossfade is now skipped when the current track is shorter than
max(20 s, crossfadeDuration × 3)— at the default 5 s fade a 20 s track spent half its length fading. With duration on Auto the bar is computed fromresolveAutoCrossfadeDurationMs()(20–45 s), never a hardcoded default. Separately, an opt-in setting (off by default) skips crossfade between tracks of the same album, so an album sequenced to run continuously still does.- Albums are recognised by a new
PlaylistType.ALBUM(behaves exactly likePLAYLISTelsewhere;AlbumViewModeluses it for play, but not for shuffle — once shuffled the running order is gone). The handler snapshots the album'smediaIds intoMediaPlayerInterface.albumTrackIdsat load time, and crossfade is skipped only when both the current and the next track are in that set — which is exactly what keeps the edges intact: the last album track into the first radio track still fades. A set of ids, not a count, because shuffle reorders the queue including appended radio. - This works only because endless queue appends through paths that write
_queueDatadirectly and never callsetQueueData, so the snapshot stays album-only whilelistTracksgrows. Routing those appends throughsetQueueDatawould swallow the radio tracks into the set and disable crossfade for the whole queue. - Known limitation: the tag does not survive a restart — the queue-restore path hardcodes
PlaylistType.PLAYLISTbecauseQueueEntityhas no column for it.
- Albums are recognised by a new
-
Every crossfade guard belongs on BOTH trigger paths (2026-08-14): crossfade starts from the position-polling job (
timeRemaining in 1..crossfadeDuration + prep, polled every 200 ms), and separately fromhandleTrackEndInternal()on EOF. The EOF path returns early whenisCrossfadingis already set, so a condition added only there is dead code with no symptom — the feature silently does nothing. The existing video checks were on both paths; that is the pattern to follow. -
Desktop: playback settings must reach every live handle (2026-08-14): speed, pitch, volume and the sleep fade all go through
MpvPlayerAdapter.applyPlaybackLevels()andforEachLiveHandle(current + secondary + precached).secondaryPlayeris the easy one to miss — it is removed fromprecachedPlayersbefore being promoted, so it belongs to neither collection, and sinceao-volumeis shared process-wide on Windows a missed handle does not just stay wrong, it undoes the others. Speed used to be applied only tocurrentPlayerand only re-asserted inendCrossfadeAudio(), so changing it and skipping to the next track reverted to 1.0x. -
Desktop pitch re-enabled (2026-08-14): the pitch row was hidden on Desktop with the note "LibVLC doesn't support independent pitch control" — stale since the mpv migration. mpv shifts pitch with its
rubberbandfilter, which the codebase already drove for AutoMix key matching (MpvPlayer.setPitchScale, labelsimpDjPitch). It is applied only while crossfade is off: crossfade owns mpv'safchain and clears it after every transition, so the two cannot both drive it — the UI already locked the control in that case.installCrossfadeChainreturns whether mpv accepted the filter; a build without rubberband logs a warning instead of firingaf-commandat a filter that is not there. -
Seeking mid-crossfade (2026-08-14):
seekTo(positionMs)was the only transport command that did not handleisCrossfading— on both platforms. Two bugs at once: the outgoing track kept playing because nothing cancelled the crossfade, and the seek landed on the wrong track, since position updates during a crossfade are read fromsecondaryPlayerwhile the seek went tocurrentPlayer. Both now commit the incoming track as current first, the waypause()does. -
All mpv property writes run on the player thread (2026-08-14):
volume,sleepFadeFactor,seekTo(positionMs)andplaybackParametersused to write from the caller's thread.MpvPlayer.release()flipsisReleasedsynchronously and then spawnsMpv-Releasetompv_terminate_destroy, so a caller that passed theisReleasedcheck could still be insidempv_set_propertywhen the core died — the same use-after-free already documented in therelease()join comment. Confining every write to the one thread that releases handles closes the window;MpvPlayer.applyVolume()additionally holdsvolumeLock, which is the only way to cover the event pump'sAUDIO_RECONFIGpath (that thread cannot hop). -
Clear listening history sweeps the whole cached library (2026-08-16): one button in Settings → Listening history (its own section, above Storage, since Storage is Android-only) wipes
playback_eventand then everything the app kept only because it happened to render it once. Order is the feature: containers first, songs last — sweeping songs alone deleted 0, because 11 322 of 12 221 were held alive by playlists nothing pruned. The chain is pin-live-queue →playback_event→ artists (followed = 0) → theirnotification+followed_artist_single_and_album→ podcasts (isFavorite = 0, cascades to episodes) → albums → playlists → songs → satellites already orphaned before this ran →checkpoint+VACUUM→ unwind the queue pin. On the owner's 57 MB database: 10 607 songs, 470 artists, 238 playlists, 50 albums, 8 podcasts, 8 818 satellite rows; file down to ~12 MB.- Kept by state, not by reference.
song.likedis the entire Favorites feature andsong.downloadStateis the only link between a downloaded file and its row — neither is a foreign key, so the literal "referenced by nothing" reading deletes both libraries.downloadState = 0also spares a download still in flight. Same idea one level up: albums/artists that back a liked or downloaded song are spared so their pages still render offline. NOT INover a nullable column silently matches nothing.local_playlist.youtubePlaylistIdis nullable and mostly NULL;x NOT IN (…, NULL, …)is NULL, never TRUE, so the playlist sweep deleted 0 rows and reported no error. Every such subquery needsWHERE <col> IS NOT NULL. This is the same failure shape as the crossfade guard that was dead code on one of two trigger paths._is a LIKE wildcard and videoIds are full of it. Ids are matched as quoted tokens inside JSON columns ('%"' || id || '"%'), which is sound against the real shapes — includingList<Map<String,String>>columns, where the id is still a quoted value. But 1 748 videoIds contain_, so every pattern escapes\,_,%via nestedreplace()and declaresESCAPE '\'.- The DELETE re-checks the orphan conditions instead of trusting the precomputed id list: the user keeps liking and browsing while the sweep runs, and
pair_song_local_playlistcascades on song deletion, so a song added to a local playlist mid-sweep would otherwise be pulled straight back out of it. - The live queue is pinned to disk first, deliberately ignoring
saveRecentSongAndQueue. Thequeuetable is only written on pause / track change / exit and only when that setting is on, so the song playing through the speakers looked orphaned. The pin is removed afterwards when the setting is off.
- Kept by state, not by reference.
-
@RawQuerycannot VACUUM — Room routes it to a read-only connection (2026-08-16): KSP generatesraw()asperformSuspending(__db, isReadOnly = true, inTransaction = false), Room takes a reader connection for that, and readers are opened withPRAGMA query_only = 1.VACUUMthere fails with "attempt to write a readonly database" — whilePRAGMA wal_checkpointis accepted on the very same connection, which is exactly why the long-standingDatabaseDao.checkpoint()works and hid this.vacuum()therefore lives onMusicDatabaseasuseWriterConnection { it.execSQL("VACUUM") }, withcheckpoint()immediately before it so the WAL the deletes just filled is folded back in first.execSQLopens no transaction, which SQLite requires for VACUUM. A VACUUM failure must not surface as an error: every delete has already committed. -
Unfollowing an artist now cleans up immediately (2026-08-16):
ArtistRepositoryImpl.updateFollowedStatusonly flippedartist.followed, stranding that artist'snotificationandfollowed_artist_single_and_albumrows forever — and once the artist row itself is swept, those rows lose any way back to an artist. It now deletes them on the unfollow path. The equivalent sweep stays in the clear-history button for rows stranded before this shipped. -
Playback state was published inverted on both platforms (2026-08-16):
onIsLoadingChangedwrote 2–3 times in a row —Loadingunconditionally, maybeReady, thenLoadingagain fromstopBufferedUpdate._simpleMediaStateis aStateFlowcollected from another thread, so it conflates and the UI settled on the last write:Loadingwhen buffering had just finished,Readywhen it had just started. Since the adapters call it withfalseimmediately after announcingSTATE_READY, every track start and every resume ended with a spinner over playing audio. Compounded bystartBufferedUpdate()not cancelling its predecessor, so each track leaked another 500 msLoadingemitter — the siblingstartProgressUpdate()had already been fixed for exactly this (#2152). Desktop additionally comparedbufferedPercentage * duration(percent × ms) againstcurrentPosition(ms), off by ~100×. All of it predates the mpv migration (blames to the VLC-era handler), and Android was affected identically.- Two more, in the same family:
SharedViewModel'sEndedbranch pinnedcurrent = -1L, and the only formatter renders any negative asNA:NA— with theProgressbranch ignoring negatives and theLoadingbranch restoringtotalwithout touchingcurrent, the player showed a correct duration next toNA:NA. Andplay()at end of queue called into a handle parked at EOF, which does nothing, so the button looked ignored; both adapters now rewind first.
- Two more, in the same family:
-
Analytics is a nav tab, gated on local tracking (2026-08-16): previously reachable only from a 24 dp icon in the Library top app bar (with a "NEW" badge on it — the tell that it was buried). Now a fourth tab, between Search and Library, appearing only while local tracking is on. Three navigation components carry the tab list —
AppBottomNavigationBar,AppNavigationRail(tablet/wide) and the Liquid Glass bar, which keeps two lists (bottomNavScreensfor selection,barTabsfor the sliding capsule; Search is a separate FAB) — and all of them must be updated or the tab exists but never renders.BottomNavScreen.ordinalis an identity, not a position:AppNavigationRailcomparedselectedIndex == indexin one place andscreen.ordinalin another, which only worked while the two numbers coincided; reordering exposed it. -
videoTypewas never read from the API (2026-08-16): no parser ever read YouTube's ownmusicVideoType; every call site invented its own label, andResultVideo.toTrack()even smuggled the view count into the column (videoType = this.views), whichArtistScreenthen relied on to render "432K views". Sosong.videoTypeheld"Song","video"or a view count depending on which screen wrote the row — nothing could ask "is this a video?" and get a true answer. The value is now read fromwatchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoTypeacross every parser, carried onSongItem/Track/Content, and written through the existing update paths (updateVideoTypeSongEntity) rather than a migration.Trackgained a realviewsfield so the artist page stops depending on the smuggling.- Every comparison goes through
MusicVideoTypeincore/domain— the one module bothkotlinYtmusicScraperandcore/dataalready depend on, so putting it in either would invert an existing edge. It normalizes first, keeping only realMUSIC_VIDEO_TYPE_*values, because rows written by older builds still hold the invented labels above; comparing those raw would call all of them videos. nullis "YouTube did not say", never "audio".isVideoSongresolves an unknown to false (matching Metrolist), ytmusicapi resolves the same unknown to "video" — callers that care must branch onisKnownfirst instead of assuming either.- The config moved in the 2026 web response: it now rides the overlay play button on search rows and the title column on playlist rows, so
MusicResponsiveListItemRenderer.musicVideoTypereads all three places and must fall through on the value, not the endpoint — falling through on the endpoint stops at the first row carrying a barewatchEndpoint, which is exactly the migrated shape.
- Every comparison goes through
-
Wrapped queue rows were being dropped — 82% of a logged-in radio (2026-08-16):
YouTube.next()readit.playlistPanelVideoRendereronly, so every row YouTube ships as aplaylistPanelVideoWrapperRendererresolved to null and vanished in themapNotNullthat builds the queue. Measured on a live logged-in radio: 161 of 197 rows across four pages were wrapped, so a 50-track first page arrived as 6. ReadingContent.track(bare renderer, elseprimaryRenderer) fixes it.- The wrapper only appears when authenticated. Six anonymous requests eliminated the other variables one at a time — same seed, both client versions (
1.20260304and1.20260811), and both body shapes (plainNextBodyand theisAudioOnly+params=wAEBone) — every one returned zero wrappers, while the authenticated capture returned 48/50. All three radio paths useyouTube.next()withsetLogin = true, so the app has been getting the wrapped shape. Content.counterpartis now parsed too: it holds the other rendition of the same recording, which is what powers the official client's Song/Video switch.nextCustom(setLogin = false, hardcodedRDAMVM$videoId) is dead code — nothing calls it.
- The wrapper only appears when authenticated. Six anonymous requests eliminated the other variables one at a time — same seed, both client versions (
-
Radio queues can be kept audio-only (2026-08-16, issue #2334): an opt-in setting (off by default) that drops video entries from radio queues only — deliberately not a global "hide all video" switch, so a playlist or album the user picked still plays what it contains. Filtering lives in
SongRepositoryImplbecause that is where the three queue sources meet:getRadioFromEndpoint,getRelatedDataandgetContinueTrack.- Substitution is impossible, so entries are dropped. Every video that actually reached the measured radio was
MUSIC_VIDEO_TYPE_UGC— a fan remix or mashup that exists only as a video and ships nocounterpartto swap in. Official music videos never arrive as the primary rendition at all: YouTube already demotes those to the counterpart of the audio track. Rate is roughly 1 entry per 50, so the queue barely shortens. - Each source needs its own radio test, and two of them are not obvious.
getContinueTrackmust also acceptRRDAMVM…— YouTube's other spelling for a single video's radio, whichisRadioQueueIddeliberately does not match because it never appears as a queue's ownplaylistId; and itselsebranch continues a real playlist, so only the!fromPlaylistbranch may filter.getRelatedDatais always radio but not directly:next(videoId)alone answers with just two rows — the song and anautomixPreviewVideoRendererpointing at itsRDAMVM…radio — whichYouTube.nextthen follows and splices in. getRadioFromEndpointis shared with the shuffle button on playlist and artist pages, which passes the playlist's own id —isRadioQueueId()returns false there, so shuffle is untouched.
- Substitution is impossible, so entries are dropped. Every video that actually reached the measured radio was
-
Apple Music header reaches Desktop (2026-08-17): the immersive header on Album, Playlist, LocalPlaylist and Artist was gated behind
isMobilePortrait = getPlatform() == Platform.Android && wDP < hDP, so Desktop only ever saw the old black-and-gradient layout. The gate is now open on all four, and the three list screens carry an Apple Music desktop header: square 280dp artwork on the left, and a right-hand column holding title → subtitle in the app accent (seed, standing in for Apple's brand red) → meta → the existing[Shuffle][Play pill][Download]cluster, re-aligned from centred to left. Everything below the header —DescriptionView, the track-count line, the list — is untouched, and the portrait header is unchanged on phones.- The buttons that floated on the artwork have to move. Back, like, search,
⋯and LocalPlaylist's AI-suggest were overlays on an edge-to-edge image; at 280dp there is nothing to overlay, so they become a plain top row. Their code is moved verbatim rather than rewritten — each is wired to its own view model. aspectRatio(1f)is a phone-only assumption. Artist's header used it, which on a 1400dp-wide window makes a 1400dp-tall block: the artwork — or a playing Spotify canvas — swallows the whole page. It is nowheight((screenInfo.hDP / 2).dp), matching the other three. Two things must change with it or the result is worse than before:ContentScale.FillWidth→Crop(FillWidth scales a square source to the frame's width and shows only its top slice), and the colour scrim measured aswDP * 0.7f→hDP * 0.35f(70% of the frame's own height; on a wide window 70% of the width is taller than the artwork and covers everything).HazeProgressivekills the process on skiko. haze 1.7.2's progressive path callsShaderBrush.createShader(Size), whose mangled signature does not match the Compose this build pins (material3-multiplatform 1.12.0-alpha01/ skiko 0.148.1) —NoSuchMethodErrorthrown inside the draw pass,RenderEffect.skiko.kt:234. It is used in exactly one place (ArtistScreen's bottom fade) and had been latent because Desktop never rendered that branch. Now guarded withgetPlatform() == Platform.Android; Desktop loses only the blur, since the colour scrim is a separate box. PlainhazeEffectis fine on Desktop. Fixing it properly means moving haze or Compose, and Compose is pinned for a reason — alpha02 bumps skiko to 0.148.2, which removesMatrix33.makeTranslateand crashes compottie.- mpv decides the video's fit, not Compose.
MpvVideoFramesreports its box size throughsetTargetSize, and mpv scales and letterboxes each frame into exactly that size — the black bars are already pixels by the time Compose sees them, so noContentScalecan remove them.MediaPlayerView.jvm.kthad therefore been ignoringcropToBoundsoutright, with a comment that Desktop never renders the portrait canvas. Cropping is now mpv'spanscanproperty (0.0letterbox …1.0cover), exposed asMpvPlayer.setPanscan()and applied from aLaunchedEffectso flipping the flag re-scales the running video instead of re-creating the handle.
- The buttons that floated on the artwork have to move. Back, like, search,
-
The header gate is orientation, not platform (2026-08-17): the migration above shipped with
val isMobilePortrait = truehardcoded in all four screens — a "temporarily forced open so the layout can be judged on Desktop" that also replaced the body of the portrait branch with the landscape header. Android portrait therefore rendered the desktop header, and Android landscape/tablet lost the old layout entirely. The gate is nowval isPortrait = screenInfo.wDP < screenInfo.hDPand nothing else: a portrait window (phone upright, or a narrow desktop window) gets the edge-to-edge artwork header, a landscape one gets the side-by-side header.getPlatform()no longer appears in Album/Playlist — the one platform check left in this family is ArtistScreen'sHazeProgressiveguard, which is about skiko, not about layout.- The old black
angledGradientBackgroundlayout is deleted from all four screens, since "not portrait" now means "landscape header" rather than "legacy layout". That also retiredPauseCircle/PlayCircle/Sensors/ElevatedButtonand, in ArtistScreen,CollapsingToolbarParallaxEffectplus 11 other imports. - One boolean was answering two questions — "use the immersive treatment" (palette background, row dividers, blurred top bar) and "which header". That is why forcing it to
truebroke the header while looking harmless: everything else it gated genuinely does apply to both orientations, so those conditions are simply gone. - Artist keeps three values on the orientation: frame
aspectRatio(1f)vsheight(hDP / 2),ContentScale.FillWidthvsCrop, scrimwDP * 0.7fvshDP * 0.35f— both figures are 70% of the frame's own height, which is why they differ.
- The old black
-
Liquid glass reaches Desktop, and its expect/actual is gone (2026-08-17):
io.github.kyant0:backdropis a KMP artifact declared incommonMain.dependencies, so Gradle had been resolvingbackdrop-desktopall along — the JVM side was simply never written.LiquidGlass.jvm.ktreturnedModifierunchanged andLiquidGlassContainer.jvm.ktfell back toclip(shape), so every glass surface on Desktop was a plain rounded box. Both actual pairs are now deleted:PlatformBackdropis atypealiasfor Kyant'sLayerBackdropin commonMain, and the whole effect —drawInteractiveGlass,GlassInteraction, the observe-only press recogniser — is common code. The ~20 call sites keep the same names and did not change.- The expect class was itself the blocker. An
expect classhas no relationship to Kyant'sBackdrop, sodrawBackdrop()could not be called from commonMain and the 200-line effect was stuck in androidMain. Once the library exists on both targets the abstraction has nothing left to abstract, and dropping it is what let the code move. The alias survives only so call sites keep readingPlatformBackdrop. - Press/hold works with a mouse: it rides
pointerInput+awaitFirstDown, not touch-specific APIs. - The landscape headers on Album/Playlist/LocalPlaylist now use glass for the back button and the like/search/
⋯pill, matching portrait. Their buttons sit on the page background rather than on artwork, so the backdrop source is amatchParentSize()box carrying the palette colour — it takes part in no measurement and must stay a sibling of the buttons; nesting them inside the source is the render-feedback loop that crashes the RuntimeShader. The[Shuffle][Play][Download]cluster stays flatWhite @12%in both orientations, as it already was in portrait. - The glass rim is
Highlight, and its default is DIRECTIONAL — which is why small round buttons looked rimless.drawBackdrop's default ishighlight = { Highlight.Default }, andHighlight.DefaultcarriesHighlightStyle.Default: a rim lit along a single direction (angle = 45f,falloff = 1f), not a rim around the outline. An elongated pill catches that sweep along its long edge and reads as glass; a 48dp circle catches a short arc of it and reads as nothing at all.HighlightStyle.Plainis the uniform one (Color.White.copy(alpha = 0.38f),BlendMode.Plus).liquidGlass/LiquidGlassContainer/LiquidGlassIconButton/drawInteractiveGlasstherefore takehighlight: Highlight = Highlight.Default— the default keeps Kyant's own behaviour, so Android and the portrait branch are untouched. The small round buttons passHighlight(width = 1.dp), NOTHighlight.Plain(corrected 2026-08-26).Plainis uniform but reads as a flat wash; 1.dp is the smallest step that stays visible without looking like a plain border. Live examples:AnalyticsScreen.kt:422and the Apple Music player's Desktop dismiss button. Copy the value from the tree, not from this file's history.- Four builds were burned guessing at this before anyone read the library source. Things ruled out along the way, so nobody repeats them: hand-rolling the button as
Row + liquidGlass(widening it to 96dp does make glass appear, because a longer edge catches the directional sweep — a symptom, not a fix), shrinking the lens radii through alensScaleparameter, moving the backdrop source between amatchParentSize()box and the content column, and painting the cover art into the source at low alpha (visible, but it silently changes the header design — do not). Swapping the back button and the pill betweenTopStart/TopEndwas the one useful experiment: the glass followed the widget, proving it was the surface's own geometry rather than its position.
- Four builds were burned guessing at this before anyone read the library source. Things ruled out along the way, so nobody repeats them: hand-rolling the button as
- Proven on skiko, and Desktop is now ALWAYS glass (corrected 2026-08-26). This entry originally read "unproven on skiko"; it has since shipped and runs. Desktop does not consult the
liquid_glasssetting at all — three call sites readisLiquidGlassEnabled == TRUE || getPlatform() == Platform.Desktop(App.kt:427,App.kt:553,MiniPlayer.kt:179), so the toggle is Android-only and Desktop is unconditionally on. New Desktop surfaces should therefore reach forLiquidGlassIconButton/liquidGlassby default rather than treating glass as the risky option. Kyant's desktop path does go throughSkikoRuntimeShader, the same neighbourhood where haze 1.7.2'sHazeProgressivethrowsNoSuchMethodError— that remains true of haze, not of Kyant's backdrop. LiquidGlassAppBottomNavigationBarstill has an empty JVM actual — Desktop navigates with the rail, not the bottom bar. It is unrelated to the above and was not touched.
- The expect class was itself the blocker. An
-
Listen Together (2026-08-23): shared rooms where every client plays the same track at the same position, in the rooms Metrolist already uses — a SimpMusic client and a Metrolist client can sit in the same one. New KMP module
core/service/listenTogether(android + jvm + iosArm64 + iosSimulatorArm64) speakingMetrolistGroup/metroprotoover a Ktor WebSocket tometroserver. Nothing in the protocol layer may be "improved": the wire format is not ours.- protobuf without protoc. Messages are
kotlinx-serialization-protobufclasses with@ProtoNumber; a protoc-generated Java layer would be JVM-only and strand Desktop and iOS. encodeDefaultsmust befalse. With it on, any payload carrying a null message field throws'null' is not supported for optional properties in ProtoBuf— andPlaybackActionPayload.trackInfois null on every play, pause, seek and volume command. Matches proto3, protoc and the Go server.- The capability handshake type names are not in the .proto.
client_capabilities/server_capabilitieswere read off metroserver'sprotocol.goand are pinned by a test. - The server forces
IsPlaying=falseon EVERYchange_track— protocol default, not the host pausing. A guest that obeys it pauses the track it just loaded, which is the same bug on next, prev and end-of-song alike. The guest carries the room's previous intent across the change; a real pause arrives as its own command with the track unchanged. - Play intent is decided BEFORE loading and passed into
addMediaItem, never corrected afterwards. Loading with a hardcodedplayWhenReady = trueand letting the transport pause it is a race the guest wins — it starts playing in a room the host has paused. playWhenReady, notisPlaying, everywhere intent is meant. A buffering track reportsisPlaying = falsewhile already committed to playing, so comparing against it made the host publish PAUSE to the whole room on its own network hiccup, and made the track-change PLAY never fire.- A joiner starts at the room's position, not at zero. The state pushed on join carries the position as of the host's last command, which can be minutes old, so the guest also sends
request_syncthe moment it is in. - Loading gaps are absorbed by position, not by waiting. Each client resolves its own stream; the host publishes
positionwith every command andServerClockcompensates the flight time, with a seek pastSEEK_TOLERANCE_MS = 750(Metrolist'sHARD_SYNC_THRESHOLD_MS). The buffer barrier only runs when someone stalls mid-track —change_trackclearsBufferingUsersserver-side, so it never gates a track change. - Guests may pause, and stay paused. Forcing them back to room state makes pause impossible; pressing play calls
request_syncso resuming lands where the room is now, not where this device stopped. Matches Metrolist's manager. MediaPlayerInterface.crossfadeSuppressedturns crossfade off inside a room without touching the user's setting — a fade overlaps two tracks for seconds and drifts the room apart. Writing the DataStore value instead would lose the real preference on a process death mid-room.Track.toGenericMediaItem()guesses song-vs-video from artwork aspect ratio and reads its ownmaxresdefault.jpgfallback as video, so room tracks are built from the room's ownTrackInfowithMERGING_DATA_TYPE.SONGforced — otherwise the guest gets video with no sound where the host has audio.- UI never sees the protocol.
composeApptalks toListenTogetherRepositoryindomain;ListenTogetherRepositoryImplindatais the only place that knows the service module exists. - Entry point is a top-bar icon on Home and Library carrying a dot while a room is live. Reconnects are capped at 5 attempts, then reported.
- protobuf without protoc. Messages are
-
Apple Music lyrics style (2026-08-25): a second lyrics renderer, chosen by
LYRICS_STYLEin DataStore (CLASSICdefault |APPLE_MUSIC) from Settings → User interface, and deliberately independent ofNOW_PLAYING_STYLE— it governs how a lyric line is drawn, so it applies everywhere lyrics are drawn: the Apple Music tab, the Classic and M3 Expressive players, andFullscreenLyricsSheet.LyricsViewreads the setting itself viakoinInjectrather than taking a parameter, so none of its four call sites changed. Every sizing number is lifted from AMLL (amll-dev/applemusic-like-lyrics), whose stylesheet expresses everything inem:0.4emline padding,~1.2emleading,0.5em/1.5em/0.3emfor the translation row. AMLL does NOT set the lyric font size — the host does — so 28sp is ours.- Gated on Android 12, not degraded.
Modifier.bluris backed byRenderEffect(API 31) and is a documented no-op below it — no crash, no warning, just a page with no blur.isLyricsBlurSupported()(expect/actual, named after the capability rather than the OS version because Desktop answers differently) hides the option entirely below that, andLyricsViewre-checks it: a DataStore restored from a backup can carryAPPLE_MUSIConto a phone that cannot draw it. Modifier.blur(radius)alone CLIPS. The single-argument overload usesBlurredEdgeTreatment.Rectangle, so softened glyphs get sliced off square at the line's own bounds.Unboundedis required — and it is not sufficient on its own: the blurred Box must also be wider than the text, with the gutter applied INSIDE it. Gutter outside means the blurred box starts exactly where the glyphs start and there is no margin for the blur to spill into. This is the same problem AMLL solves withmargin: -1em; padding: 1em, which widens the painted area without moving the layout; Compose has no negative padding, so the equivalent is to blur the wide box and inset the content.- Following from that,
FullscreenLyricsSheetmust not add its own gutter on top. It keeps a 50dp column for Classic; under this style it contributes50 - AppleMusicLyricPaddingXso the total is unchanged and the renderer owns the inner 20dp. Zeroing it instead leaves the lyrics sitting further out than that screen's own header and slider. graphicsLayer { alpha = … }re-introduces the rectangle. Any alpha below 1 forces the node into an offscreen layer sized to its bounds, which crops theShadowthat by definition spills outside them. Glow intensity therefore rides inshadow.color.alpha, never in a layer. The glow itself is the word drawn in transparent ink so only its Shadow lands — that is what makes the light follow the glyph outline instead of boxing it.- A word is drawn per CHARACTER. With one
Textper word, the smallest thing that can light up IS the word, no matter where the glow is attached — which is why it kept flaring whole words. AMLL splits intocharacterElementsfor the same reason. Intensity is a continuous falloff from the playhead (1 - |progress - charCentre| / reach, reach ≈ 1.5 characters) and the glow node is composed unconditionally at alpha 0: anifaround it adds and removes the node, so A vanishes and B appears rather than one fading down as the other fades up. - Emphasis for held notes is AMLL's, constants and all (
initEmphasizeAnimation): strength isdu/2000cubed below the reference duration and square-rooted above it,×0.6capped 1.2 for scale,×0.5capped 0.8 for bloom,×1.6/×1.5on the closing word of a line. A linear ramp — the obvious first guess — makes ordinary words shimmer and held notes underwhelming, i.e. exactly backwards. - Blur magnitude is ours, not AMLL's. Their
min(5, (1 + distance) * 0.8)is CSS px sized for their host's font; at this size it lands around 1.6dp one line out, which is invisible. Measured off the reference screenshots instead and expressed against the font size:0.095emper line, capped0.45em. Distance is asymmetric, which IS AMLL's: lines already sung carry a+1so the page behind the singer recedes faster than the page ahead. - Blur drops to zero while the user drags the list (
collectIsDraggedAsState, notisScrollInProgress— the latter is also true for the player's own animated scroll). The sung line is anchored one PHYSICAL ROW from the top, not one item: a wrapped lyric is one item spanning several rows, soanimateScrollToItem(index - 1)hangs the whole wrapped block above it. Scrolling is a spring, not a tween, so a one-line step and a six-line jump settle the same way.
- Gated on Android 12, not degraded.
-
Compose Hot Reload + MCP (2026-08-17):
org.jetbrains.compose.hot-reload1.2.0 is applied indesktopApp(root hasapply false;foojay-resolver-conventionin settings provisions the JBR). Run with./gradlew :desktopApp:hotRunJvm --auto— plainjvmRundoes NOT hot-reload; the plugin creates separate tasks.mainClassresolves automatically fromcompose.desktop.application.mainClass, and the existingtasks.withType<JavaExec>block already coversComposeHotRun(it extends JavaExec), sompv.bundled.pathreaches hot runs. The MCP server (:desktopApp:hotMcpServerJvm, registered in Claude Code local scope) exposesstatus/reload/await_reload/take_screenshot/get_semantic_tree/click/… — measure UI with the semantic tree instead of guessing: it returns exact bounds (it is how the capsule's 2px column overflow was found), whiletake_screenshotcaptures the window's screen rect, so an occluded window photographs whatever covers it. No hover tool exists: to see hover-only UI, temporarily force the state in code, reload, measure, revert. CHR cannot invalidate global state (Koin singletons, player adapters) — restart the app after touching those. -
Ten-band equalizer on both platforms (2026-08-22): one stored curve —
equalizer_bands(CSV),equalizer_preamp,equalizer_enabledin DataStore — drives two entirely different backends. Desktop writes mpv'saf; Android runsEqualizerAudioProcessor(a Media3BaseAudioProcessor) in the sink chain, ahead of the crossfade filter and the sleep fade. Both are Audio-EQ-Cookbook peaking biquads on the same ISO centres (31, 62, 125, 250, 500 Hz, 1, 2, 4, 8, 16 kHz) at Q 1.41, measured against ffmpeg's ownequalizerfilter at ±0.0000 dB at all ten centres (re-measured 2026-08-24; the earlier "from 125 Hz up" understated it) — which is what makes one curve, and one AutoEq profile, mean the same thing on both. UI is embedded in Settings → Playback (EqualizerSection), not a separate screen: a draggable curve rather than ten sliders, because the thing being edited is a shape.- mpv's
afhas two owners, and neither may write it directly. Crossfade installs its own entries and clears them at the end of every transition, so anything else parked inafused to vanish with them.MpvPlayernow keepseqEntryandcrossfadeEntriesapart andapplyAudioFilters()is the single writer;clearAudioFilters()drops the crossfade tier alone. The symptom this prevents is "EQ works, then randomly doesn't" — the same shape as the crossfade guard that was dead on one of two trigger paths. - Android needs no re-apply; Desktop does. The processor reads the curve through a supplier bound to a
@Volatilefield on every buffer, andExoPlayer.Builderappears in exactly one factory, so any player — initial, next-track, crossfade, precache — is already correct with no push. mpv is the opposite: a fresh handle starts with an emptyaf, soapplyPlaybackLevels()re-asserts the curve on all four creation sites.secondaryPlayeris the one to miss: while being promoted it belongs to neithercurrentPlayernorprecachedPlayers. - Never override
isActive()totruein aBaseAudioProcessor. The base answers it frompendingOutputAudioFormat, whichconfigure()assigns immediately before the call — so the default already means "active iffonConfigureaccepted the format", and it stays active across curve changes because activity is only reconsidered on configure. Forcingtrueclaims the processor is in the chain while handing backNOT_SETfor a format it cannot read.SleepFadeAudioProcessorstill does this; harmless only whileenableFloatOutputstays off. - Every band keeps a stage even at 0 dB. With
A = 1the numerator equals the denominator exactly, so the stage is a true identity transfer function — which fixes the array sizes, so dragging a band never resizes the filter state and never clicks. (Corrected 2026-08-24: previously called "bit-identical", which is checkable and false — the left-to-right accumulation leaves ~1e-14 residual, ~180 dB below 16-bit LSB.) - Presets follow Spotify's names; the gains are ours, because neither Spotify nor Apple has published theirs and both run six bands. The active preset — and an imported AutoEq label — are read back off the curve rather than stored, so dragging a band drops the label by itself and a preset re-selects itself if the curve returns to it.
- mpv's
-
AutoEq profile import (2026-08-22): new
core/service/autoEqServiceplus three Room tables at v25 (autoeq_entry,autoeq_index_meta,autoeq_curve; three added tables and nothing else, so Room writes the migration itself).results/INDEX.mdis read straight off raw.githubusercontent — 851 kB, 8850 profiles — parsed to rows so a search is an indexedLIKErather than a re-parse. Its ETag is weak (W/"…") and still answers 304, which keeps a routine freshness check to a couple of hundred bytes; curves are fetched per profile and cached, so a headphone used once works offline afterwards.- It drops in untouched because AutoEq's fixed-band output is generated at
31.25 * 2**iwithq = math.sqrt(2)bounded to ±12 dB — the same centres, Q and range this equalizer runs. Its written centres are rounded (31.25 → "31"), so the parser matches by frequency within a tolerance, and places gains by frequency rather than by filter number. - Its
Preamp:is computed from the summed response, not the tallest band, so it goes past −12 dB (−12.1 observed across a 60-profile sample). The preamp slider floor is therefore −15: a value outside aSlider's range is pinned to the end of the track while holding a different number.
- It drops in untouched because AutoEq's fixed-band output is generated at
-
System equalizer removed entirely (2026-08-22): the
ACTION_OPEN/CLOSE_AUDIO_EFFECT_CONTROL_SESSIONbroadcasts,MediaPlayerListener.shouldOpenOrCloseEqualizerIntentand everynotifyEqualizerIntentcall site, theOpenEqexpect/actual trio and the Settings row are all gone — two equalizers on one audio session multiply, and the in-app one is now the answer. On Desktop that whole chain had been firing into empty stubs anyway.MediaPlayerInterface.audioSessionIdstays: it looks like part of this, butLoudnessEnhancer(volume normalisation) is its real user. An equalizer app already attached to the session survives until the process dies, so force-stop before judging whether the removal worked. Reversed on 2026-09-13 — it is back on Android as an option; see "System equalizer returns as an option". -
Analytics joins the Apple Music family, and gets a landscape layout (2026-08-22): the screen already had the immersive artwork header — the #1 track at
hDP/2.5undersmoothScrimBrush— so this is the rest of the treatment rather than a redesign: the page background is now the artwork's dominant tone (rememberPaletteState→toImmersiveBackground, the same machinery Album/Playlist/Artist use), the back button and the day-range picker become liquid glass, and the three counters drop their underlined labels. The day-range picker stays aDropdownMenu— only its trigger changed, from a calendar icon with "7d" printed inside it at 8sp to a pill that says which range is showing.isPortrait = wDP < hDPpicks the layout, as on the other four screens. Landscape reserves a 48dp + 16dp strip so back and the pill get their own top row before the 280dp artwork —Spacer(48.dp)in the column and.padding(top = 16.dp)on the sibling buttons are two separate calculations, and the button's height must equal the reserved strip or it hangs into the artwork.- The body splits in half (
AnalyticsScreen.kt:357-360): a fixed-width mosaic column was tried first and starved the remainder at real window widths (at 986dp the remainder was 202dp), so the even split survived. Both song lists and the chart share one column — Recently played sits with Top tracks rather than below the grid, since they are the same kind of block and separating them left one column ragged. (Corrected 2026-08-24: this entry previously described the abandoned 600dp fixed column as shipped.) - The 30-day range buckets by week, not by day. Thirty rows is a list nobody reads to the end. It is four buckets of exactly seven days rather than four-and-a-bit covering all thirty: an uneven last bucket would carry more days than the others and draw a longer bar for it, which is the one thing a bar chart must not do. 7 days still lists days, 90 days and the year still list months (
ChartType.WeekjoinsDayandMonth). FiveImagesComponentkept, and given a landscape arm. Its 1 + 2 + 2 mosaic is what carries the ranking — a horizontal shelf sizes every entry the same and throws that away. But its 2:1 banner is 171dp tall at 390dp and 616dp at 1280dp, solandscape = truemakes #1 a square taking the left half with the other four as a 2×2 beside it: same tiles, same hierarchy, half the height. The three duplicated tile blocks collapsed into oneMosaicTile.- Glass is for what floats over content, not a skin for content. Only the back button and the range pill use it; the counters and the chart sit directly on the page. A glass card on a flat background is just a border, and a border tells the user "separate object, probably tappable".
- Three bugs fixed on the way through, all visible to every user: the total and the per-track times printed raw seconds (
"47231 seconds");"Listened time"was a hardcoded English literal no translation could reach; and every date usedMonthNames.ENGLISH_FULL/ENGLISH_ABBREVIATED, which are constants rather than locale lookups — kotlinx-datetime ships no localized alternative, so the twelve abbreviations are string resources like everything else (AnalyticsFormat.kt).
-
Analytics gains a period navigator and five Last.fm-shaped charts (2026-08-22): the screen could only ever show now; it can now step back through weeks, months and years with ← →, and every figure carries its change against the same span one period earlier. The range-in-range queries this needed (
queryTop*InRange,getPlaybackEventCountInRange) already existed and were wired DAO → datasource → repository → ViewModel — they were simply only used for "This year", while the other three ranges took aLastXDaysshortcut. Stepping is therefore a different argument, not new plumbing.- One snapshot per period, not a dozen flows.
AnalyticsRepository.getPeriodStats(start, end)is a plainsuspend funreturningAnalyticsPeriodStats, because the screen wants exactly two of them as a matched pair. Ten independent flows would let a count from this week render beside a total from last. - The hour and the day a play belongs to are LOCAL questions. Bucketing in SQL would need
'localtime', whose answer depends on the process timezone.getPlaybackSamplesInRangetherefore returns raw(timestamp, listenedSecond)samples with the timestamp declaredLocalDateTime, so Room's converter — chosen by TARGET TYPE — decodes it and no timezone arithmetic exists in the path (declaring itLongbypasses the converter and re-applies the offset: the first trap below). The clock, the busiest day, plays-per-day and the consistency axis are all derived from that one scan in Kotlin. (Corrected 2026-08-24: this entry previously described the pre-fixLongshape as the design, contradicting the trap entry that fixed it.) - The fingerprint needs no global corpus. All five axes are self-normalised 0..1 from
playback_eventalone: discovery = new artists / artists, replay = 1 − distinct tracks / plays, concentration = top-5 share, consistency = 1 − relative spread of daily counts, diversity = normalised entropy over per-artist counts. Only Last.fm's grey "global average" needed their corpus; the second polygon here is the previous period, and it is load-bearing — a lone polygon on five self-normalised axes says almost nothing. getArtistPlayCountsInRangeis unbounded on purpose.queryTopArtistsInRangecaps at 100, which is right for a top-five list and wrong for concentration and diversity: those are shares of the whole, so a cut tail inflates both.- "New" means first-ever, not first-in-window.
getNewArtistCountInRangetakesMIN(timestamp)over the artist's whole history and asks whether it lands inside the range; grouping inside the window instead would call every artist new. - Music by decade is partial by construction and says so.
AlbumEntity.yearexists, but the join runs through the nullableplayback_event.albumBrowseId— radio and standalone videos carry no album — so the chart prints the share of plays it could date. A distribution that silently drops an unknown share of its input is not a distribution. - Music ratio is concentric arcs, not a donut. Songs, albums and artists measure three different things and add up to no whole; slicing one circle between them would claim a share of something that does not exist. (Last.fm's own page makes exactly this mistake.)
- The clock draws filled wedges over a full-ring dark track, not spokes. Without the track an hour with one play and an hour with none look nearly identical — the eye reads a short spoke as a missing tick rather than as "almost nothing here".
- A delta is absent, never
+∞%, when the previous period is empty — otherwise every figure in a new user's first week reads as an infinite increase.
- One snapshot per period, not a dozen flows.
-
Four traps found while finishing the Analytics screen (2026-08-22), each of which produced plausible output rather than an error:
playback_event.timestampis NOT a UTC instant — it is the local wall clock encoded as one.Converters.dateToTimestampwrites everyLocalDateTimewithtoInstant(TimeZone.UTC), so the only correct decode isTimeZone.UTC, which is whatConverters.fromTimestampdoes. Reading the column as a rawLong(declaring the fieldLongbypasses the converter, which Room selects by TARGET TYPE) and then decoding it withcurrentSystemDefault()applies the offset a second time: on the owner's UTC+7 database the busiest hour read 03:00 instead of 20:00, and 917 of 2663 plays (34%) were counted on the following day. The fix is not "useTimeZone.UTC" — that still leaves a zone to pick wrong — but to declare the fieldLocalDateTimeand let the converter decode it, so no timezone arithmetic exists in the path at all. Every total still added up, so only a human asking "who plays music at 3am?" caught it.- kmpalette reports
paletteas null unless its state isSuccess, andgenerate()setsLoadingbefore its suspension point. So the page background resolves toColor.Blackfor the whole duration of every generation — and if the effect is cancelled mid-flight the state staysLoadingforever. Analytics keyed its effect onbitmapAND the artwork URL; the URL goes null on every reload and then back to the same value, which cancels the generate and then makes thepaletteGeneratedForguard skip the retry. Symptom: the same song sometimes tinted the page and sometimes left it black.AlbumScreenkeys onbitmapalone, which is why it never showed this. Anything painting a surface from a palette must also hold the last colour that actually resolved. - Compose Resources understands
%1$sand%1$dand nothing else — no flags, no width, no%%.%1$02d:00 – %2$02d:00rendered verbatim on screen. Padding, rounding and unit symbols belong in Kotlin; a string resource should only ever join already-formatted pieces, which also spares translators from format specifiers. ForceDarkContentis applied per-destination in the nav graph, and Analytics was the only immersive screen never wrapped — so on the light theme its labels drew dark-on-dark over an artwork-derived background that is always dark. Note theMiniPlayerhas the same gap for a different reason: it is a sibling of the NavHost, so a CompositionLocal provided inside cannot reach it.FiveImagesComponentgained ashape(the block is clipped as ONE object — the tiles stay flush) and, more importantly, hole-free arrangements for counts 1–5. The originalif (images.size < 3) returnsilently dropped an entry at even counts; portrait hid that by simply getting shorter, while the landscape arm added in this change left a visible empty rectangle beside the tall first tile.
-
Now Playing style system + Material 3 Expressive style (2026-08-23): the player is now a style-agnostic SHELL + swappable CONTENT layer, picked by a Settings row (User interface, under Theme) persisting
NOW_PLAYING_STYLE(SPOTIFYdefault |M3_EXPRESSIVE; an Apple Music style is the planned third).NowPlayingScreen.ktwent 2630 → ~670 lines and keeps everything style-agnostic — VM state collection, palette Animatables, artwork-pager sync, every sheet/dialog — handing the rest to the content composable through the two @Stable holders incontent/NowPlayingContentState.kt(NowPlayingContentState/NowPlayingContentActions). The old UI moved VERBATIM intocontent/NowPlayingContentSpotify.kt(two sanctioned cleanups only: the twice-duplicated metadata row becameNowPlayingTrackInfoRow, andisUserLoggedIn()— arunBlockingexecuted inside composition on every recomposition — became a shell-collected flow). The new style iscontent/NowPlayingContentM3Expressive.kt+NowPlayingExpressiveCards.kt+content/expressive/{WavySeekBar,ExpressiveTransportRow}.kt, built to the owner-approved "Tonal pills" design canvas.- The one-screen vertical rule is per-style, not shell: gap = max(30, (hDP − topBar − artwork − info − 30) / 2), artwork between two equal gaps, the inline lyric line centered in the lower gap, 30dp of breathing room before the fold — each content file carries its own copy of the measurement block, so a future style may define different maths without touching the shell.
- The expressive APIs are usable from commonMain on the pinned CMP artifact (
material3-multiplatform 1.12.0-alpha01= androidx 1.5.0-alpha19):LinearWavyProgressIndicatorandMaterialExpressiveThemecarry@Material3ExpressiveApi, which is a@RestrictTomarker, NOT@RequiresOptIn— no opt-in needed;MaterialTheme.motionSchemeis stable. The wavy seekbar is the indicator plus a transparentpointerInputlayer (it is not a slider); the wave amplitude animates to 0 when paused OR while scrubbing, and the thumb morphs circle→tall bar during drag. - M3E colours are a whole artwork-seeded dark scheme, not two gradient stops:
rememberDynamicColorScheme(seed = state.startColor.value, isDark = true, style = PaletteStyle.Vibrant)wrapped inMaterialExpressiveThemearound the content subtree, everything reading tonal roles (noColor.Whitesemantics, no black-gradientdrawBehind); falls back to the appseeduntil the palette resolves. - M3E has no separate canvas-takeover UI, but the canvas itself renders FULLSCREEN at page level (
NowPlayingExpressiveCards.kt:187-189), behind the content — not inside the 28dp artwork card, andcropToBoundsis not passed there (the modifier chain differs from the card's). Classic's video overlay (±5s seek, subtitles) is ported at that layer. Of the shell'sshowControlLayout/controlLayoutAlpha/showHideMiddleLayout, two of the three ARE used by this style (NowPlayingContentM3Expressive.kt:187,:190,:258,:600); only one goes unused. Transport keeps Classic's Android-only gate — the Desktop side panel still has no slider/transport in either style. (Corrected 2026-08-24 against the ship commit: the entry previously claimed in-card rendering,cropToBounds = true, and all three fields unused.) - The connected action group order is a product decision: Info · Cast · Shuffle · Repeat · PlaylistAdd · Queue — and the cast glyph is NOT Material "cast": the app ships
ic_music_cast.xml(Material Symbolsmusic_cast, group-scaled 0.9) in the cast module. Design mocks must lift the real drawable, not the icon a name suggests.
-
Lyrics romanization for 12 languages (2026-08-26, issue #2342): a Latin-script reading shown as its OWN row between the original and the translation — never replacing either, because the point is to read the original script and know how to pronounce it. New package
core/service/lyricsService/.../romanization/, driven by one DataStore key (romanization_languages, a comma-separated list of enum NAMES) and off by default. Detection is per LINE, not per song: a lyric sheet routinely alternates an original line with an English one, and romanizing the English half produces gibberish.- Ten of the twelve need no dependency at all. Hangul decomposes arithmetically (
0xAC00 + (initial*21 + medial)*28 + final), so 19+21+28 table entries cover all 11 172 blocks; Devanagari and Gurmukhi are abugida state machines (a consonant carries an inherentathat a matra replaces, a virama deletes, and nothing else keeps); Cyrillic is seven separate tables, because those languages share one alphabet and romanize it differently — Russian additionally needs position-dependent rules (е→yeword-initially and after a vowel or either sign). Only Japanese (kuromoji) and Chinese (pinyin4j) need a library, so only they are behind an expect/actual; iOS gets a no-op actual returning null, which the caller already treats as "show nothing". RomanizationLanguagelives incore/domain, not beside the romanizers.datadepends onlyricsServicewithimplementation, so the dependency stops there andcomposeAppcannot see it — the layering rule working as intended. The UI therefore goes throughLyricsRomanizerRepositoryin domain, the same shape Listen Together uses. Anything the UI and a service must BOTH name belongs in domain.- TinyPinyin — what Metrolist uses — cannot be used here, for two independent reasons. It is published only on JitPack, where its POM names its own groupId with the wrong case (
promegvspromeG) so the transitive resolve dead-ends; and it drags intinypinyin-android-asset-lexicons, an Android-asset artifact that breaks:lyricsService:jvmMainandjvmTest. pinyin4j (Maven Central, 316 KB, onlyjunitat test scope) replaces it and is strictly better here — it carries tone marks, which TinyPinyin does not. Its trap:WITH_TONE_MARKthrowsBadHanyuPinyinOutputFormatCombinationunlessvCharTypeisWITH_U_UNICODE. - kuromoji needs a packaging exclude.
kuromoji-ipadicpullskuromoji-core, and BOTH jars shipMETA-INF/CONTRIBUTORS.mdandMETA-INF/LICENSE.md, which failsmergeDebugJavaResource. Excluded asMETA-INF/*.mdinandroidApp— by pattern, because excluding only CONTRIBUTORS.md just moves the failure to LICENSE.md. Note the pre-existingMETA-INF/LICENSE…list there is underjniLibs.excludesand does NOT apply to java resources. - Rich sync is romanized from the STRIPPED string, as a separate row. The raw text still holds its
<mm:ss.xx>markers; romanizing it in place would destroy the very timings the word-by-word wipe runs on. Because the reading is its own row, the original is left untouched and keeps lighting up — and the reading gets the whole line's context rather than one word at a time, which a per-word pass had cost. - Nukta characters are TWO code units.
क़is क + U+093C, not aChar— writing the composed forms as char literals is a compile error (13 of them), so the nukta is handled like a matra: consumed first, because it changes which consonant this is (ज → z, not j) before the inherent vowel is decided. - Cyrillic language detection is a guess by distinctive letters (
ѓќѕ→ Macedonian,ђћџљњј→ Serbian,ңөү→ Kyrgyz,ўі→ Belarusian,їєґ→ Ukrainian, else Russian) and runs only when more than one Cyrillic language is enabled; with exactly one, that choice is the answer. SettingAlertStatepicks its dialog body with anif / else ifchain testingmessageFIRST — passingmessagealongsidemultipleSelectrenders the text and no list at all. The four nullable fields are mutually exclusive despite the type saying otherwise.
- Ten of the twelve need no dependency at all. Hangul decomposes arithmetically (
-
The Apple Music player's tabs broke three things that had been fine (2026-08-26):
Crossfade(targetState = viewState)composes exactly ONE body, so on QUEUE or LYRICS the artwork pager inside MAIN does not exist — and the shell had been treating it as always alive. Tapping a queue row played the wrong song, and it took three attempts because the first two chased the symptom. The decisive clue was that Classic and M3 Expressive are never affected — they useQueueBottomSheet, which renders the WHOLE queue and passes the indexitemsIndexedhands it, computing nothing.AppleMusicQueueViewinstead diddrop(offset)and addedoffsetback at click time, making every row's identity depend onstate.currentOrderIndex, a value DERIVED in the shell. It now doeswithIndex().drop(offset)so the queue-wide index travels WITH its track: a wrongcurrentOrderIndexcan still cut the list in the wrong place, but it can no longer play the wrong song. When one screen is wrong and its twin is right, compare the mechanisms before debugging the values — the twin that computes nothing has nothing to get wrong. Two further guards on the shell's pager→player effect: it is keyed on the pager alone with the index read throughrememberUpdatedState(re-keying it per track rebuilt thesnapshotFlow, whose first emission is the stalesettledPagethatdistinctUntilChangedcannot suppress), and it now requires a latchedpendingUserSwipeset by a real finger drag — on the Queue/Lyrics tabs the pager does not exist, so every seek it appears to request is fictitious. The page background went stale for the same reason (onArtworkBitmapis only fed from inside that pager), fixed with a loader outside the Crossfade. And the canvas flashed sideways on returning from LYRICS becauseMediaPlayerView's legacy path seeds its width to the SCREEN width and only corrects it onceonVideoSizeChangedreports the truth —cropToBounds = truetakes its size from Media3'spresentationStateinstead, so there is no wrong guess to correct. -
SimpMusic Wrapped (2026-08-27, issue #2345): a year-in-review told as a story reel — ten cards, auto-advancing, tap/hold/swipe — built entirely from the local
playback_eventtable. No new data collection and no network call beyond artwork: every figure already existed onAnalyticsPeriodStats. Lives inui/screen/home/wrapped/, entered from a banner on the Analytics screen, gated on local tracking exactly as the Analytics tab is, and replaced by a "not enough of the year yet" screen belowREQUIRED_ACTIVE_DAYS = 30.- Shell / content split, as with the Now Playing styles.
WrappedScreenowns everything constant — progress segments, year label, close button, footer, timer, capture — and each card fills the slot and draws nothing else.WrappedYear.cardsdrops the cards a given year cannot fill, so the segment count is derived, never a hardcoded ten. - One new query, and only one.
queryTopArtistsInRangereadsevent_artist, which holds one row per artist per play and no duration at all, so card 04's listening time can only come from a join back toplayback_event. Added asqueryTopArtistsWithTimeInRangereturning a newTopPlayedArtistTime— adding a column toTopPlayedArtistwould have broken the two existing queries that do not select it. A track credited to several artists hands its fulllistenedSecondto each, so that column deliberately does not sum to the period total. - The design canvas is a layout reference, not a stylesheet — and porting it literally is how this shipped wrong the first time. The first pass carried its own palette of hex literals, a bespoke
wrappedText()TextStylebuilder, andBox+ border "pills", and was rejected on sight. Colour now comes only fromMaterialTheme.colorSchemeinsideMaterialExpressiveTheme(colorScheme = rememberDynamicColorScheme(seed = top track's artwork, isDark = true, style = Vibrant))— the constructionNowPlayingContentM3Expressivealready uses; type only fromMaterialTheme.typography; surfaces fromLiquidGlassIconButton/liquidGlassoverrememberBackdropand real Material 3 buttons. A tokens object carrying a literal palette is itself the defect — it licenses every call site downstream to hand-roll.WrappedTokensnow holds geometry and durations and nothing else. displayLargeis the wrong style to enlarge. In this app'stypo()it is 20sp Normal in body colour, sodisplayLarge.copy(fontSize = …)yields a grey, light hero that needs a weight and a colour argued back onto it.titleLargeis 25sp Bold in the title colour, so.copy(fontSize = …)alone is enough — the idiom already live atListenTogetherScreen.kt:482. The scale has no display tier, which is why each card names exactly one size and nothing else.distinct()on an artwork URL is not distinct by image. YouTube Music embeds the requested size in the link, so one sleeve arrives as…=w544-h544from a track,…=w120-h120from an artist and…=w226-h226from an album, withi.ytimg.com/vi/<id>/maxresdefault.jpgas a fourth shape. A list hides this; a mosaic does not — the poster drew the same three covers six times and, because the duplicates inflated the pool, tiled over the whole card instead of stopping near the top.distinctByArtwork()compares a normalised key instead (strip the size segment; reduce/vi/<id>/to the id), the same normalisationJvmMediaPlayerHandlerImplalready applies when handing artwork to the media session.- Wrapped counts as fullscreen.
isInFullscreeninApp.ktwas derived fromFullscreenDestinationalone, and it is what hides the navigation rail and the tablet mini player — without Wrapped in that check, both sat on top of the reel, and on top of every card captured as a share image. - Card 07 shows the biggest day against a typical day and no daily series:
WrappedYearcarries none, and thirteen invented bars on an image that leaves the app is not a rounding error. Card 09 is dropped entirely below 50% album-year coverage rather than drawn from a minority of plays. Card 08's archetype is a straight argmax over the fingerprint's five axes, ties broken by declaration order. - A Wrapped chip in Library, and a recap playlist per month.
LibraryChipType.WRAPPEDgates on local tracking exactly as the YouTube chip gates on being logged in — one line in the chip loop and a branch in the sharedLaunchedEffect(currentFilter), no second mechanism. Each month is aLibraryDynamicPlaylistType.MonthlyRecap(year, month)round-tripping asrecap_<year>_<mm>, soLibraryDynamicPlaylistScreenserves it with play, shuffle and download already attached, and aMonthlyRecapItem : PlaylistTypeputs it in the sharedGridLibraryPlaylistrather than a bespoke list. Months are those with plays, newest first, capped at twelve. - A recap carries no artwork, deliberately. It first borrowed the month's top song's cover, which made the tile read as that song; it now falls to
painterPlaylistThumbnail(title)— the deterministic gradient with the name on it that every artwork-less playlist in this app already gets. - A full-width block above a grid is a grid item, not an overlay. The entry card was first drawn in a
BoxoverGridLibraryPlaylistwith its height reserved incontentPaddingand its position translated by the scroll offset; the measured height included the top inset that was then added to it again, leaving a screen-tall hole.GridLibraryPlaylistnow takes aheaderslot rendered asitem(span = { GridItemSpan(maxLineSpan) }), the same mechanism its create tile and chart button already use. A grid item cannot be double-counted. - Sharing reuses the pipeline that shipped with the lyrics share (
Capturable,saveImageToDevice,shareImage). The footer captures the card content only; card 10 exposes aposterModifierso its own Save/Share buttons stay out of the PNG.
- Shell / content split, as with the Now Playing styles.
-
Stream itags centralised, and "High" split into Opus and AAC (2026-08-28): every itag lived as a bare number scattered across five files —
setOf(250, 251, 774, 141)in two copies ofBraveNewPipeUtils, a136/134fallback ladder and anif (itag == 774) find { 141 }branch inStreamRepositoryImpl,it.itag == 251inYouTube.getNParam. They now all nameITAGincore/common/Config.kt, which also ownshighQualityTwinOf()— 774 ↔ 141 are the Opus and AAC renditions of the same 256 kbps master, so an account entitled to one may be served the other, and asking for the twin beats dropping to "any audio stream" (which is 70 kbps).QUALITYgained a fourth entry so the user can pick the family:High Opus - 256kps(774) andHigh AAC - 256kps(141). The setting is persisted as the label STRING, not an index, so renaming an entry orphans every device holding the old text — and the old guard resolved anything unrecognised toitems[0], i.e. it would have silently dropped Premium accounts from 256 kbps to 66 kbps.QUALITY.normalize()maps known older labels forward and is the single place that decides;QUALITY.itagOf()replacesitags.getOrNull(items.indexOf(...)), whose-1on an unknown label produced a null itag and no match.- Migration is lazy: nothing rewrites the stored value, so a read never races a write. The text is replaced the next time the user picks an entry.
-
Ciphers decoded on-device, with the player table pulled from a remote registry (2026-08-28):
api.pipepipe.devused to solve every signature andnparameter, which makes playback depend on someone else's server staying up. PipePipe already had the hook —YoutubeApiDecoder.setLocalDecoder()takes aYoutubeJavaScriptDecoder, an interface it ships with no implementation — so nothing in the fork needed changing. The implementation isFaradayJsDecoder(androidMain + jvmMain) overFaradayCipherEngineand acipher/package copied fromMetrolistGroup/innertubex(GPL-3 ↔ GPL-3). Three tiers now, no shared point of failure: faraday table on-device → api.pipepipe.dev → BravePipe. Since 2026-08-31 those are three separate extractions rather than one, so the middle tier cannot be skipped. PipePipe's own local-then-server fallback lives inside a singleStreamInfo.getInfoand only fires when the local decoder throws — but the failure that actually happens is a stale player table, where the signature is well-formed and merely rejected by the CDN with a 403. Nothing throws,decodeBatchtreats the local answer as final, and the app's ownheadCheckRandomStream()then dropped it straight to BravePipe:api.pipepipe.devwas never asked, precisely in the case it exists for.newPipePlayernow callspipePipeStreams()twice — once aftersetLocalDecoder(faradayDecoder), once aftersetLocalDecoder(null)— and only then BravePipe. Passing null is the supported way to reach the API tier: both readers of the field null-check it and branch straight to the API when it is absent (decodeBatchatifnull 24,getPlayerMetadataatifnull 79, read from the pinnedf8982ca9e7jar). Do not try to reach that state with a stand-in decoder that throws:getPlayerMetadatacallsgetPlayerDatawith no try/catch, so a throwing stand-in lands on BravePipe — the very jump this change exists to prevent.faradayDecoder.invalidate()is gated on the local tier, since only the on-device table can go stale.- The remote table is a locator, not a decoder.
player_configs.jsonmaps a player hash to the names inside YouTube's own player JS (sig: "Tl(48,5831,INPUT)",nClass,sts); the script is still downloaded and executed locally in QuickJS. What it buys is the step that breaks on every obfuscation reshuffle — guessing those names by regex. Source israw.githubusercontent.com/MetrolistGroup/faraday/master/registry/player_configs.json, refreshed on a 6-hour TTL with an ETag, plus two failure-triggered refreshes (unknown hash, CDN rejection). - The URL must pass two independent gates:
configuredUrl()wants the/player_configs.jsonsuffix,validatedSourceUrlOrNull()wants the path under/MetrolistGroup/faraday/. Metrolist's own app fails the second (it points atZemerTeam/zemer-cipher), so its top tier silently returns null and never runs — a whole feature disabled with no error anywhere. - QuickJS
maxStackSizedefaults to 256 KB and YouTube's n-transform goes deeper — and QuickJS does NOT reliably raise a JS error when it overruns, the process just takes SIGSEGV. No Kotlin frame, and nohs_errfile either, because the crash is below the JVM:-XX:ErrorFileis set and still writes nothing,dmesgis restricted, and apport discards it because a Gradle-provisioned JDK "does not belong to a package". The only thing that located it wasLogger.wmarkers around each native call — the last line printed wascallFunction.begin name=_nTransformFunc. Fixed on both sides:maxStackSize = 8 MB, on a thread created with a 32 MB stack. The soft limit must sit well under the real stack so the engine trips its own guard and throws instead of walking off the end;newSingleThreadContextcannot be used because it offers no way to size the stack. - Throwing is how a request is handed back.
YoutubeApiDecoder.decodeBatchtries the local decoder and falls through to the API only on an exception — a half-filled result is accepted as final and the missing URLs merely fail later. SodecodeBatchthrows unless every challenge is solved.getPlayerDatais the opposite:getPlayerMetadatacalls it with no try/catch, so an exception there skips the API tier entirely and lands on BravePipe; the engine returns null (rather than guessing) when either the player id or thestsis missing, because a wrong timestamp poisons the whole response. setLocalDecoderis the only public member ofYoutubeApiDecoder.getLocalDecoder,decodeBatchandclearCacheare package-private, anddisableLocalDecoder(YoutubeJavaScriptDecoder)— which PipePipe calls on itself the moment a decoder throws — is private, so it cannot be invoked from the app. Calling it anyway compiles as far as "No value passed for parameter 'decoder'", which reads like a missing argument rather than a visibility problem. Replace the decoder (setLocalDecoder(null)to clear it) instead. Because PipePipe drops the decoder for good the first time it throws,Extractor.newPipePlayerre-registers on every extraction, turning "disabled forever" into "skipped for one track".headCheckRandomStream()failing now also callsinvalidate()— a CDN 403 is the only visible symptom of a stale table, since a wrong signature is still well-formed and throws nothing.ExtractSource(in-memory, last 32 videos) feeds an "Extract source" row in the info sheet —PipePipe · local,PipePipe · pipepipe.dev,PipePipe · cachedorBravePipe. Deliberately not a column onNewFormatEntity: a format row is cached and reused, so a persisted source would keep naming the path taken the first time.SharedViewModelre-reads it on every format emission, because the first one lands before extraction finishes.
- The remote table is a locator, not a decoder.
-
Japanese romanization dictionary moved out of the APK (2026-08-28): kuromoji-ipadic's dictionary — 8
.binclasspath resources, ~13.2 MB compressed — was 92% of a +14.4 MB APK regression vs v1.7.0 (28.0 → 42.4 MB full arm64). Android now excludes them (androidApppackagingresources.excludes += "com/atilika/kuromoji/ipadic/*.bin") and fetches the pack on demand: first time the user confirms a romanization selection containing Japanese,KuromojiDictionarystreamskuromoji-ipadic-0.9.0-dict.tar.gzfrom theabcrelease ofmaxrave-dev/simpmusic-files(the same release that hosts the mpv natives — reuse existing releases, do not mint new tags), verifies a pinned SHA-256 while downloading, extracts via a whitelist tar reader and atomically renames intofilesDir/kuromoji-ipadic/. Desktop keeps the dictionary bundled (13 MB is noise beside a 230 MB DMG); iOS stays no-op.TokenizerBase.Builder's protectedresolverfield is a decoy. ipadic'sloadDictionaries()assignsresolver = SimpleResourceResolver(getClass())unconditionally at its own top (verified in 0.9.0 bytecode), so planting a resolver in a subclassinitis silently overwritten.DirectoryDictionaryBuilderoverridesloadDictionaries()itself, restating its short body — including ipadic's private penalty defaults[2, 3000, 7, 1700]. kuromoji asks the resolver for bare filenames ("tokenInfoDictionary.bin"); the resolver maps by basename anyway.PlatformRomanizer.androidbuilds the tokenizer only when all 8 files are on disk and caches only a successful build, so Japanese lines come alive the moment the download lands — no restart. Until thenjapanese()returns null, the pipeline's existing "show nothing" contract. State (NOT_DOWNLOADED/DOWNLOADING/READY/FAILED) flowsLyricsRomanizerRepository→ Settings row subtitle; retry = confirm the dialog again.- Known gap, accepted: a device that had Japanese enabled before this build only starts the download on the next dialog confirm — irrelevant for v2.0.0 since no such users exist yet.
-
Animated album artwork as a second canvas source (2026-08-31): an opt-in setting (off by default) that plays Apple Music's animated album cover in the slot a Spotify canvas fills. It reuses the AM plumbing that already existed for the artist name-logo —
AMTokenManagerscrapes the web-player JWT out ofmusic.apple.com/assets/index~*.js,buildAMHeaderssigns the call, and both search helpers carry the same refresh-once-on-401 retry — so not one line of token handling was written for this. The result is a plainCanvasResultpersisted into the existingsong.canvasUrl/song.canvasThumbUrlcolumns, which is why nothing downstream and no migration had to change.- The two sources are one slot, so AM wins in code while both switches stay independent in the UI.
SharedViewModel.getCanvaspicks the source with awhen, and the Spotify switch is deliberately left alone — it stays where the user put it and simply stops being consulted. Racing them, or disabling the other switch, would both be wrong: the first spends a login-gated round trip per track on a result that gets discarded, the second edits a preference the user did not touch. - AM's album index is not complete enough to trust a near-miss, so the album name is a first tier rather than the only path. Searching "Mắt Nhắm Mắt Mở HIEUTHUHAI" with
types=albumsreturns exactly ONE album — Mắt Nhắm Mắt Mở (Studio Live Session) - EP — and not the album itself, so the closest available match was a different recording entirely, accepted only because nothing else was on offer.pickAlbumMatchtherefore accepts an exact name and nothing less; anything else defers to the track search, which finds the real album for the same track (Đáng Lý Anh Nên Yêu Em Hơn→ Mắt Nhắm Mắt Mở, exact). The album name is still used on that path — as the tiebreak between the several albums a track legitimately appears on, ahead of "has artwork" and ahead of AM's ranking. Measured 12/12. - A track title cannot be looked up as an album, and the failure is a silent empty result.
types=albumsanswers a title with the singles that share it — "Blinding Lights - Single", never "After Hours" — and those rarely carry animated artwork, so the request succeeds with 200 and the feature just never works. Measured 0/3 on the fallback path before this was found. The title path therefore searchestypes=songs&include[songs]=albums&extend=editorialVideo, which returns the parent album already extended, in the same one request. The album path keepstypes=albums. (A single is not disqualified from having artwork — HIEUTHUHAI's "Người Im Lặng Gặp Người Hay Nói - Single" has one — it is simply rarer.) - On the title path the album must come from the matched TRACK's
relationships.albums, never from the flatresources.albumsmap. That map holds every album any hit belongs to, so "the first album that has artwork" attaches an unrelated release to the song: for an artist with exactly one animated release, every one of their tracks wore it. Seen live on HIEUTHUHAI.searchAMSongsWithAlbumspairs each song with its own album and the caller scores the TRACK title. - Running time is the strongest signal, and it was already the one the Spotify canvas search used. That path picks its track by comparing durations, not names — the same idea applies here and is what separates takes a title cannot: "Không Thể Say" is 228s, its Live Band cut 188s, its festival cut 327s.
AMSongAttributes.durationInMilliscomes back in the same response, andSongEntity.durationSecondsis already on the row, so the song path demotes anything more than 3 seconds off before it considers the album name or whether artwork exists at all. Measured 12/12 end to end. - Filter order decides correctness here: name closeness first, "has artwork" only as a tiebreak. Filtering to albums-with-artwork before scoring discards the studio recording and leaves the live one, which then wins by default — "Không Thể Say" resolved to a festival live album. Scoring first and demoting artwork-less candidates within an equal-name tier keeps both cases right: a track that exists on a bare single AND on a full album picks the album (HIEUTHUHAI's "Nước Mắt Cá Sấu" → Mắt Nhắm Mắt Mở), while a track whose only exact match has no artwork correctly shows none. Measured 10/10.
- AM's ranking must never be the decision — it is only the tiebreak. The search API cannot be told to sort, and its order is wrong in two different ways. It ranks a different album above the right one ("LINK Hoàng Thùy Linh" puts Hoàng above LINK, both carrying artwork), and it ranks reissues above the plain edition — Hybrid Theory (Deluxe Edition) above Hybrid Theory, 1989 (Taylor's Version) [Deluxe] above 1989 — so taking the first loose match returns the artwork of the wrong pressing, silently and with no error anywhere.
pickMatchrequires the artist to agree, then scores each candidate by how closely its name answers the one searched for: exact, then subject-plus-a-suffix ordered by how much was added, then the looser containments. AM's own position is used only to break ties. Measured 10/11 exact against live responses atlimit = 5— raising the limit changes nothing, so it stays at 5. - Normalising to letters and digits can leave nothing at all. Ed Sheeran's
÷and=contain no alphanumerics, normalise to an empty string, and so matched nothing — those albums returned no artwork, again with no error.matchKey()falls back to the raw lowercased text for exactly that case. - Normalising with
[^a-z0-9]destroys the languages this app serves. That class treats every accented letter as punctuation and grinds "Hoàng Thùy Linh" down to "ho ng th y linh"; the comparison is built onChar.isLetterOrDigit()instead. isVideo = url.contains(".mp4")had to widen to.m3u8. AM artwork is an HLS master playlist, and the branch that reads this is the one restoring a cached url — so the mismatch would have rendered every AM artwork as a still image only from the second play of a track onwards.Mapping.kt's copy is untouched: it converts Spotify's own response, where.mp4is correct.- Nothing in the player changed:
MediaPlayerViewbuilds aDefaultMediaSourceFactoryandmedia3-exoplayer-hlswas already a dependency, so ExoPlayer recognises.m3u8on its own,REPEAT_MODE_ONEalready loops it, and mpv handles HLS natively on Desktop. The stream itself carries no auth, no DRM and no audio track, so it cannot collide with playback. - The app picks the rendition, not the player — and codec is decided before quality.
editorialVideo.videois a MASTER playlist: one measured artwork advertises 28 variants, 16 of them HEVC Main 10 (10-bit) interleaved with 12 H.264 by bitrate. mpv defaults to--hls-bitrate=maxand so took the very top —2048x273210-bit HEVC at 20 Mbps, 52.39 MB for a 22-second loop, decoded in software because canvas renders through mpv's SW render context — while ExoPlayer would have picked by bandwidth estimate, so the two platforms disagreed on the same track.selectAMRenditionnow resolves the master down to one media playlist before the url is ever stored: filter toavc1, take the narrowest rendition at or above 720 px, cheapest of those. Measured identical across six albums —830x1106 @ ~2.85 Mbpstall,768x768 @ ~2.1 Mbpssquare.- Capping the bitrate instead does not work, which is what the first attempt did. The ladder is generated per artwork, so one threshold lands on H.264 for one album and 10-bit HEVC for the next, and bitrate does not even track resolution within a ladder:
486x648costs 1516 kbps while the larger664x886costs 1494. Filter by codec first, then by width. - Failure degrades rather than breaks: an unreachable or unparseable master falls back to the master url, which still plays.
- Capping the bitrate instead does not work, which is what the first attempt did. The ladder is generated per artwork, so one threshold lands on H.264 for one album and 10-bit HEVC for the next, and bitrate does not even track resolution within a ladder:
- The query is cleaned by the character-for-character same chain the canvas search uses. The setting sits in the Spotify section directly under the Canvas row, since it replaces what that row does — but unlike every other row there it carries no
isEnable: those need a Spotify session and this one needs no account at all, so gating it onspotifyLoggedInwould hide it from the users it actually works for. Roughly a third of albums have no animated artwork, and those fall through to the still cover.
- The two sources are one slot, so AM wins in code while both switches stay independent in the UI.
-
Delay and convolution Reverb (2026-09-02): two audio effects, one stored setting each, the same sound on Android and Desktop, wired exactly like the equalizer:
MediaPlayerInterface.setAudioEffects(AudioEffects)(default no-op) → seven DataStore keys (delay_enabled/time_ms/feedback/mix,reverb_enabled/preset/mix) → both handlerscombinethem into oneAudioEffects(off ⇒null, values survive the switch) → AndroidEchoAudioProcessor+ConvolutionReverbAudioProcessorsampling a@Volatilevalue per buffer, Desktop a thirdfxEntriestier composed byMpvPlayer.applyAudioFilters()(still the ONLY writer ofaf) and re-applied per handle. Chain order isequalizer, echo, reverb, crossfadeFilter, sleepFade/eq → fx → crossfade, so the crossfade sweep also fades the effects' tails. Settings → Playback, under the EQ. A one-tap "Slowed + Reverb" preset was built and then removed at the owner's instruction: it wroteplaybackSpeed/pitchstraight into DataStore, bypassing the Now Playing lock that disables speed and pitch while crossfade is on (crossfade owns mpv'safchain, soapplyPitch()ignores the value there) — anything that touches speed or pitch must honour that lock.- Delay is ffmpeg
aecho, andDelayEffect.taps()incore/domainis the ONLY definition of the tap layout.aechois feed-forward (the delay line stores the input, so there is no feedback path): a "feedback" offbis expanded into N = ceil(ln 0.05 / ln fb) taps (cap 12) at k·T with decaysmix·fb^k,inGain = 1 − mix, andoutGain = 1 / max(1, inGain + Σdecays)so the sum can never clip. NaN is clamped to the safe end, decays floored at 0.001 — an out-of-range aecho argument fails the WHOLEafstring, equalizer included.EchoKernelis bit-exact withaf_aecho.cagainst a checked-in ffmpeg vector; the load-bearing details are the ring index shared by all channels and advanced once per frame,delay*rate/1000.0truncated to int, clip-then-truncate to int16, and tap products computed in FLOAT (int16 × float) before the double accumulation — summing in double is more accurate and therefore wrong (10/192 000 samples off by 1 LSB). Desktop formats the aecho numbers withFloat.toString(), which round-trips;mpvNumber's%.4fcosts that same LSB. The ring (24 s × channels = 4.6 MB) is allocated on the first enabled buffer, never before. Known divergence: mpv drains aecho's tail after EOF, Android does not. - Reverb is convolution against a GENERATED impulse response (
ReverbImpulseResponse,core/domain: xorshift64* seeded per preset, Box–Muller noise,exp(−6.907 t/rt60)envelope, a one-pole low-pass whose cut-off falls two octaves over the RT60, five signed early reflections mirrored L/R, unit energy per channel;SAMPLE_RATE = 48_000,VERSION = 1is part of the desktop cache name — bump it when the math changes). No assets, no licences, and exact parity at 48 kHz; at other stream rates ffmpeg resamples the IR while Android generates at that rate. FFmpeg has NO algorithmic reverb (allfilters.c: zero hits for "reverb");afiris convolution with the IR arriving as a SECOND INPUT, and itsdry/wetare input/output GAINS, not a blend — henceasplit[dry][wet];amovie=IR[ir];[wet][ir]afir=dry=1:wet=1:irnorm=-1[w];[dry][w]amix=inputs=2:weights=<1−m> <m>:normalize=0.amix weightsis a runtime command, so the mix slider goes throughaf-command simpFxReverb weights "…" amixwithout rebuilding;irnorm=-1because the generator already normalised. mpv accepts the graph becausef_lavfi.ccounts only UNCONNECTED pads (force_bidirwants exactly two) andamovieis a source, andm_option.c'sread_subparamsplices[…]out verbatim while counting bracket balance, which is what makes[dry][wet]legal inside anafentry. Measured:afiradds NO timeline latency (impulse out at sample 0 at every partition size) and costs less than the ten-band EQ. Android'sPartitionedConvolver(overlap-save, P = 4096, float Hermitian half-spectra, double arithmetic, CATHEDRAL ≈ 7 MB stereo) matchesafirto 1e-7; a dry FIFO keeps(1−m)·x + m·(x⊛h)aligned,onQueueEndOfStreamflushes the last partial block, and leaving bypass emits the dry backlog instead of dropping 85 ms. - lavfi path escaping was measured, not reasoned. Two parsers sit under mpv —
av_get_token(filtergraph) thenav_opt_set_from_string(the filter's options) — and each strips one backslash layer, so inside anamoviefilename:and=need\\,'needs\\\,[ ] , ;need\, spaces nothing, and backslashes become/first. One backslash before:makes ffmpeg open a truncated path with NO error, and every Windows path has a colon. mpv adds no layer of its own. - The IR lives at
~/.simpmusic/reverb/<preset>-v<VERSION>.wav(float32, stereo, 48 kHz), written temp-file + atomic rename, and its LENGTH is validated on every use: a WAV truncated mid-data opens fine and silently yields a shorter room.amovieopens the file at PARSE time, so a missing file fails the wholeafwrite; a null path therefore means "no reverb entry". Tiered fallback inMpvPlayer.setAudioEffects:[echo, reverb]→[echo](latches the process-widereverbFiltersUnavailable, one warning) →[]— never leave a rejected entry behind, or every later equalizer write fails with it. A stereo IR on a mono or 5.1 source is fine: lavfi auto-converts the IR's layout. - Linux bundle: the whitelist gained
afir,amovie,asplit,amix(scripts/mpv-linux/Dockerfile), and the rebuilt slice no longer bundleslibglib-2.0.so.0(thejava.awt.Desktopcure finally lands). Trap on Apple Silicon:docker buildxis absent, so--platform linux/amd64is silently ignored by the legacy builder andmpvSetupLinuxCiproduces an arm64 slice whose ELF check (e_type only) passes — pin the amd64ubuntu:22.04base by digest (or install buildx) and checke_machine == 62. Built in 8 min under colima (vz + Rosetta); the tarball (16,909,206 B, SHA-2566cc64efb…) replaced the Linux asset on theabcrelease ofsimpmusic-fileson 2026-09-02 and is pinned inmpvNativesChecksums. An older pinned tarball would make Linux fall back to Delay-only through the tiered drop. - Verification lives in JVM tests against ffmpeg-generated reference vectors under
core/media/media3/src/test/resources/audio/(echo bit-exact, convolver < 1e-4). kotlin-lsp is not a compile gate here: it has no KMP support and answers "No diagnostics" for a brokencommonMainfile; the JetBrains MCP is the only gate.
- Delay is ffmpeg
-
Lyrics timing offset (2026-09-12, half of issue #2338): one signed
lyrics_offset_msin DataStore, default 0, edited by typing a number in Settings → Lyrics. It exists for Bluetooth, where the sink buffers and the ear trails the player, so it is a property of the listener's audio path and therefore global — not per song. (The other half of #2338, editing lyrics by hand, does need per-song storage and is untouched.)- Applied at READ time, never to the lyric rows. Every display picks its line from
position - offset; nothing is written back intoLyrics. That is not a style choice — the state write atSharedViewModel.kt:1288is immediately followed byinsertLyrics(Room cache) and, on the translated branch at:1248, byinsertSimpMusicTranslatedLyrics, which uploads to the community lyrics database. Shifting the data instead would bake a personal correction into the cache (compounding on every reload) and push it to everyone. - Read-side also wins on the word level, which is what decided it. Rich sync compares each character against the same
nowthe line does (currentTimeMs = current.current), so one subtraction covers both. Shifting the data would additionally require rewriting the<mm:ss.xx>markers insideLine.words, since word timings have no field of their own. - SUBTRACTED, not added. At player position P the ear is hearing P − offset, and that earlier moment is the one a lyric answers to. Early in a track this can go negative, which every display's existing
> 0Lguard already reads as "no line yet" — correct in heard time. - Four displays, and three of them cannot be handed a pre-shifted clock:
NowPlayingScreen,MiniPlayerLayoutandLyricsView's fullscreen sheet use the sameTimeLinefor the seek bar and elapsed readout, which must keep reporting where the player is. OnlyMediaPlayerViewWithSubtitle(both platforms) uses it for lyrics alone. So the subtraction is per read site, not a second flow.LyricsViewcovers the most ground by itself: both styles, rich sync, and the share picker's opening line; all three player styles shareNowPlayingScreen'scurrentLyricLineIndex, so the inline line is one edit. SettingAlertStaterenders its text field ONLY inside themessage != nullbranch (SettingScreen.kt:2974). A state carrying atextFieldand nomessageopens an empty dialog with no error anywhere. Confirm is disabled whileverifyCodeBlockfails, so the value cannot be committed as garbage; validation is "is it a whole number" and nothing more, since how far a listener's own audio path lags is theirs to say.setLyricsOffsetMsdeliberately does not re-call its getter the way its neighbours inSettingsViewModeldo: those getterscollectforever, so re-calling one per write leaks another collector for the life of the ViewModel.
- Applied at READ time, never to the lyric rows. Every display picks its line from
-
Rich-sync lyrics: an interlude line, a frame-accurate playhead, and a travelling light (2026-09-12): three changes to the same renderer, all rich sync only.
- A dots line is INSERTED INTO THE SHEET, not drawn over it.
buildDisplayLinesreturns the sheet's own lines plus a synthetic rich-syncedLinecarrying<mm:ss.xx>•three times, so the parser, the wipe and the layout it goes through are the ones every other line goes through — which is the only way its dots land on the same baseline and left edge. Writing it as markup rather than special-casing the renderer is what keeps the renderer free of any knowledge that interludes exist. It starts three seconds after the line's LAST WORD begins, not after the line ends: a word's start is the only moment rich sync actually records, because there is no timestamp after the last word andLine.endTimeMsis frequently absent (the parser writesLong.MAX_VALUE). Starting it any earlier drags the sheet off a word still lighting up. - One index space, or four systems disagree. The sung line, the blur distance, the scroll target and the translation lookup are all line INDICES, so the display list replaces
lyrics.linesat every one of them rather than running alongside it. The translation map matches by TIME and the silence begins close enough to the line before it that the dots would otherwise inherit that line's translation, so inserted indices are excluded from translation and romanization explicitly. - The sung WORD was quantised to the position tick.
currentWordIndexwas recomputed only when the player published a position, and word timings routinely sit tens of milliseconds apart (the parser's own doc example is a 20 ms gap), so several words began inside one tick and every one but the last went from "not yet" to "past" without ever being active — no wipe, and the flare never touched them.rememberSmoothPlayheadcarries the position forward between ticks at frame rate; it is returned asStateand read inside aderivedStateOf, so the line recomposes only when the sung word changes rather than sixty times a second. Both hardcoded floors under the wipe are gone with it (100 ms on the word's duration, 150 ms on the animation): a floor makes a short word's wipe outlive the word, so by the time it finished the next two had started and the line looked like the words were chasing each other. The wipe now runs for exactly the gap to the next word. - The flare and the lift are two separate curves on one signal. The glow is symmetric ahead of the playhead and decays EXPONENTIALLY behind it — a long linear ramp does not make a tail, it makes the whole word evenly bright, because the first character of a four-letter word still sits at ninety percent of it. Its gate opens in
FLARE_ATTACK_MSand closes inFLARE_FADE_MS, and those must be separate numbers: one tween served both until the release was lengthened for the wake, which silently made the attack just as long and put the glow on screen seconds after the character it belonged to. The lift isCHAR_RISE_MSon each character's OWN clock, triggered when the light arrives and never brought back down — Apple does not lower the glyph, the sung half of a line simply sits higher, and every attempt to animate a fall was chasing something that does not happen. A word's progress cannot drive it, because that number stops advancing when the word is done and a word sung in less than the rise would freeze its characters half-risen.
- A dots line is INSERTED INTO THE SHEET, not drawn over it.
-
Desktop: the published position is smoothed in the handler (2026-09-12): mpv moves
time-posonly three to four times a second in steps of 250-500 ms — measured, and upstream cannot do better (mpv#13695 asks for a finer property and is open; mpv#4195 shows property observation is coarser still;audio-ptsis nil during seeks). Everything driven by position inherited that staircase.JvmMediaPlayerHandlerImplnow carries the last real reading forward on the wall clock before publishing it, monotonically — never backwards, never more than 600 ms ahead, snapping on a gap over a second, frozen while paused, scaled by the playback rate. Only what is PUBLISHED is smoothed; the crossfade trigger, sleep timer, position persist and scrobbler still read mpv's own number.- Two failed attempts are worth more than the fix. Extrapolating inside the adapter's
currentPositionmade it WORSE: the guess ran ahead and every poll dragged the reported position back to the truth, and a progress bar that steps backwards is far more visible than one that is slightly stale. Then the sampler was set to the same 50 ms as the source, which beat against it —SimpleMediaState.Progressis a data class in aStateFlow, so a tick reading an unchanged value is swallowed entirely, and which ticks survived depended on drifting phase, so the clock visibly sped up and slowed down. The rule that came out of it: a sampler must never run at the same rate as its source, and smoothing must be monotonic or it is worse than no smoothing. Position polls and handler ticks are all 50 ms now, which is only safe because the published value is derived from the wall clock and therefore differs on every tick.
- Two failed attempts are worth more than the fix. Extrapolating inside the adapter's
-
System equalizer returns as an option (2026-09-13): Android gets an Equalizer type choice in Settings → Audio —
equalizer_typein DataStore,BUILT_IN(default) orSYSTEM— and the two never run together. The system side is the pre-2.0.0 code restored verbatim (appf7f35e96^, core7ddeb379, identical to v1.7.0):MediaPlayerListener.shouldOpenOrCloseEqualizerIntent; in the adapter, thenotifyEqualizerIntentcalls (OPEN when the current player moves intoPLAYING; CLOSE on pause, stop, end of queue and cast handoff) and the per-playeronEventshook, which re-sends OPEN or CLOSE on every playback-state, play-when-ready, is-playing or position-discontinuity event of the current player (restored in the never-constructedExoPlayerAdaptertoo, for parity); the handler'ssendOpen/CloseEqualizerIntentwith CLOSE inrelease(); theOpenEqexpect/actual pair and the Open system equalizer row. The only new logic is the gate: withBUILT_INthe handler drops every open/close request; withSYSTEMit flattens the built-in curve and lets them through; switching while music plays sends CLOSE or OPEN at once, because nothing in the player changes state when only the setting does. Desktop is untouched: it never had a system equalizer, and its old side was empty stubs.- The one deliberate departure from verbatim: CLOSE now carries
EXTRA_PACKAGE_NAME. AudioEffect's javadoc makes both extras mandatory forACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, and AOSP MusicFX'sControlPanelReceiverreturns early on a null package and closes sessions by package — so the restored CLOSE was silently dropped. Switching from SYSTEM back to BUILT_IN then left MusicFX attached while the built-in curve came back, both running until the track ended, and pausing could not fix it because by then the gate drops every request. With the extra, a pause now releases MusicFX's effects as documented; v1.7.0 kept them attached through pauses (MusicFX holds one session per package, so they never piled up). A stop issued mid-playback still ends re-attached, inherited and harmless under SYSTEM where the built-in curve is flat:stop()leavesplayWhenReadytrue, and Media3 deliversonEventsthroughsendMessageAtFrontOfQueue— after the adapter's own CLOSE — so it computesshouldBePlayingand sends OPEN. Found in review, not on a device; closed-source EQ apps (SoundAlive, Wavelet, Poweramp EQ) are unverified. onEventsis the path that is easy to miss. It callslisteners.forEach { it.shouldOpenOrCloseEqualizerIntent(…) }directly instead of going throughnotifyEqualizerIntent, so searching for the helper finds only five of the seven call sites — which is exactly how the first restore pass left it out.- A gap carried over unchanged from the old code (read from the code, not measured): a crossfade produces no event the adapter forwards — the incoming player starts playing before it is current, so its listener returns early, and the swap's
transitionToState(PLAYING)is a no-op. Every ExoPlayer has its own audio session, so with crossfade on the system equalizer stays on the outgoing player's session until the promoted player next changes state (a rebuffer, a seek, a pause). Fixing it means announcing the new session on the swap, or giving all players one shared session.
- The one deliberate departure from verbatim: CLOSE now carries
-
Fullscreen lyrics becomes content + host, with a landscape layout (2026-09-13): the page is now
FullscreenLyricsContent(newui/component/FullscreenLyricsContent.kt), hosted the way Now Playing is.FullscreenLyricsSheetkeeps its name but is only the Android host — the sameModalBottomSheet(container, scrim, insets, shape unchanged) around the content, animating its own hide when the page asks to close. On Desktop,NowPlayingScreenContenthosts the content in aPopup(focusable = true) instead, atIntOffset.Zeroand sized toLocalWindowInfo.containerSize— the whole window, the 40dp custom title bar included, so while the page is open the bar's drag area and window buttons are under it. It first stopped below the bar, and a focusable Popup dismisses on any press outside itself, so a click on the bar closed the page;PopupProperties(consumePointerInputOutside = false)would let such clicks through, but it exists on skiko only (experimental, present inui-desktop1.12.0-alpha01) and is not part of commonMain'sexpectconstructor. A Popup also appears in a single frame, which read as a flash over the whole window, so the page runs throughAnimatedVisibility(visibleState = MutableTransitionState(false).apply { targetState = true })— fade plus scale from 0.96, 300ms in and 220ms out — and closing only flips the target:showFullscreenLyricsdrops once the transition is idle atfalse, or the Popup would leave mid-exit. The page clips itself to 12dp corners: DesktopApp makes the window transparent and clips its content toRoundedCornerShape(12.dp)whenever it draws the custom title bar (both gated on!isVM), but a Popup is a layer of its own that clip never reaches, so a full-window page came out square-cornered. commonMain reads that condition ascontainerSize.height > getScreenSizeInfo().hPX, sincegetScreenSizeInfo()subtracts exactly that bar. The host block moved belowstate/actions, because the landscape layout takes the same contract the style content does — and the shell's own sheets (more, queue, info, add to playlist, vote) moved below the host:orientationis not in the activity'sconfigChanges, so a rotation recreates it, every open sheet re-enters composition in one pass and attaches in declaration order, and the landscape page opens those same sheets on top of itself. On Android the sheet passessheetMaxWidth = Dp.Unspecifiedin landscape only: Material 3 caps aModalBottomSheetat 640dp by default, which would squeeze the two columns into a centred strip. Desktop has no drag or back gesture, so there the page draws its own chrome in both orientations: a glass pill with Close and the mini-player switch, and a glass pill with the mute toggle and a volume slider running the Desktop capsule's volume state machine fromMiniPlayer.- Portrait is the old page, moved verbatim. Two mechanical adjustments only: the tap-to-show-controls
clickablemoved from the sheet onto the content root, and the root paints black itself — the sheet'scontainerColorused to sit under the gradient's translucent stops, and a Popup has no container. The artist tap now just callsonDismiss(); each host decides how it closes. - Landscape is Apple Music's desktop full-screen player: the left 45% holds the artwork (
min(maxWidth × 0.75, maxHeight × 0.5, 420dp)) in a column 40dp wider than it — every style row carries 20dp side gutters, so that width lines the rows up with the artwork's edges — with the CURRENT Now Playing style's own track row and playback controls under it; the right 55% is the sameLyricsViewportrait shows. The background follows the style too: Apple Music → its frosted artwork, the other two → the animated gradient. Each style's action row (Info · Cast · Queue…) is deliberately left out. Orientation (wDP > hDP), not platform, picks the layout, so Android tablets and rotated phones get it as well; the left column scrolls, since a phone on its side cannot fit artwork, row and transport at once. - Shared pieces extracted verbatim from the style files:
NowPlayingTrackInfoRowandExpressiveTrackInfoRow(nowinternal, withshowCanvasThumbnailso landscape drops the small canvas thumbnail beside a full-size artwork),AppleMusicMainTitleRow(internal),SpotifyPlaybackControls/ExpressivePlaybackControls/AppleMusicPlaybackControls,NowPlayingExpressiveTheme(M3E's artwork-seeded scheme),AppleMusicArtworkBackdrop(blurred artwork,hqdefaultfallback, tint) andAnimatedLyricsGradientBackground. The three playback-controls functions areColumnScopeextensions that emit straight into the caller's Column instead of wrapping themselves: the Spotify and M3E panels passisElementVisiblein throughsliderModifier, and that modifier compares the slider against its PARENT layout, so a wrapper would silently change what it measures against.TimedLineIndex/activeIndexAtbecameinternalso the share picker could move with the page. - Shuffle and repeat ride the transport row on this page only. Now Playing keeps them elsewhere — M3 Expressive in
ExpressiveConnectedGroup, Apple Music in its queue — and this page leaves both of those out, soAppleMusicTransportRow/ExpressiveTransportRowand their*PlaybackControlswrappers takeshowShuffleAndRepeat = falseby default and onlyFullscreenLyricsContentpassestrue. Apple's row switches from its 58dp centred cluster toSpaceBetweenwhen it carries five, since five at that gap no longer fit beside the artwork; M3 Expressive adds two narrower pills (TOGGLE_WEIGHT) in the connected group's on/off colours, set 24dp off the transport instead of the row's 8dp so they read as their own pair. Spotify'sPlayerControlLayoutalready had both.
- Portrait is the old page, moved verbatim. Two mechanical adjustments only: the tap-to-show-controls
After completing any of the following types of changes, the AI agent MUST update this CLAUDE.md file:
- Architecture changes: Module additions/removals, dependency changes (e.g., library swaps like GStreamer → VLCJ), build system changes
- New major features: New modules, new service integrations, new platform capabilities
- API/Technology migrations: Swapping core libraries, changing data flow patterns
- Build/CI changes: New build variants, changed packaging formats, CI workflow changes
- Module structure changes: Adding/removing modules in settings.gradle.kts
What to update:
- Relevant sections in this document (Module Structure, Key Technologies, etc.)
- Add entry to Changelog Summary section with date/version context
- Update "Last updated" date at the bottom
What NOT to update for:
- Bug fixes, minor UI tweaks, translation updates
- Simple refactoring within existing patterns
- Dependency version bumps without API changes
This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.
Last updated: 2026-09-15 Project version: Check latest release on GitHub Maintained by: maxrave-dev and contributors