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
- vivoAndroidApp/ builds the same app under
com.spotify.musicfor vivo's Origin Island. It has no code of its own: its source set points at androidApp's sources, resources and manifest. Itsbuild.gradle.ktsmirrors androidApp's, so keep the two in step.
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) - loginSync/: the QR-paired transport that moves sign-ins from the Android app to the Desktop app (Ktor client on the phone, one-shot Ktor CIO server on the Desktop, AES-256-GCM). Android + JVM targets only; carries opaque bytes — the data layer decides what they mean
- 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>" - Never add
autoMirror = true: in right-to-left languages only text flips, icons keep their shape (owner's rule, 2026-10-14). The six icons that had it —ArrowForwardIos,QueueMusic,Sort,TrendingUp,VolumeUp,VolumeOff— drew mirrored in Hebrew/Arabic/Persian until it was removed - Replace the closing
.build()with.buildSimpIcon()(inSimpIcons.kt): it isbuild()plus the fit into Material's 20×20 live area described in the traps below.SimpIconsFitTestfails on a file that still ends with.build()
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. - Glyphs that spill past Material's 20×20 live area are fitted into it (2026-10-18). Material keeps a
glyph inside the centred 20×20 square of the 24 box (2dp padding) and balances the glyphs inside it
by keyline shapes of about equal area; 24 of our 111 Symbols spill into the padding anyway (
Lyrics' note andThumbsUpDownreach the box edge) and read bigger than everything beside them.buildSimpIcon()measures how far each glyph reaches from the centre along either axis and shrinks one that passes 10 of 24 about the centre until it fits (Lyrics,ThumbsUpDown×0.83, the other 21 ×0.86–0.996). A glyph inside the square is left as Google drew it, nothing is enlarged, and anything it cannot measure keeps the glyph as generated and logs underSimpIcons.Groupsis the exception, named inKEEP_AS_DRAWN(2026-10-20): it spills only sideways, so fitting its 24 width also cut its height, which was never too tall, from 12 to 10 of 24, and it read shorter than every icon beside it.SimpIconsFitTest(jvmTest) fails if a file skipsbuildSimpIcon(), an icon outsideKEEP_AS_DRAWNends up outside the square, or one in it gets rebuilt. Fitting into a circle was tried first and dropped: judged by its one farthest point,Lyricsshrank to ×0.72 and read far smaller thanQueueMusic. All 111 files match Google's generator at the axes above (audited the same day). - A small round button cuts a glyph that fills the live area.
IconButtonclips its content to itsshape(a circle by default); sized 24dp, that circle reaches 12dp from the centre, while a glyph filling the 20×20 square reaches 14.1 (comment13.3). Classic's Comments button clips to 28dp withrequiredSize(28.dp)inside the same 24dp slot. - Icons in one row take one size. Per-glyph tuning by eye (the Apple Music capsule at 23/24dp beside 22dp dock buttons, the title-row star at 32dp) is what made a row read big-small; only a deliberate hierarchy, such as Play over Previous/Next, gets a different size.
Location: changelogs/<version>/, one folder per version, holding en-US.md and vi-VN.md. The full procedure is the simpmusic-release-changelog skill in .claude/skills/.
fastlane/metadata/android/<locale>/changelogs/<versionCode>.txtstays the short store text (F-Droid caps it at 500 characters) and is written by the owner. The full changelog lives inchangelogs/.- Sections are New / Improved / Fixed (vi: Mới / Cải thiện / Sửa lỗi) and list only shipped work. Every line links its issue or PR with a full URL, because GitHub does not autolink
#Ninside repository files. - Shipped changes with no issue of their own are gathered into ONE "Polish for " task on Project #2, and their lines link that task.
- Reconcile Project #2 before writing. Close issues fixed by shipped commits: post the "Fixed in ()" comment FIRST, then set the milestone and Status Done. Done closes the issue by itself, so a later
gh issue close --commentsilently drops the comment. - vi-VN addresses the reader as "bạn".
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 (macOS only; Windows and Linux keep the native title bar)
- 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.- Superseded 2026-09-25 — that measurement was a radio started from a song. A radio started from a video is nothing but videos (a UGC seed: 38 OMV, 11 UGC, 1 podcast episode, no song), so dropping every video emptied the queue outright. The setting is now "Play audio version instead of MV in radio", the same idea as YouTube Music's own Don't play music videos: a video row whose song rendition YouTube shipped alongside it (
SongItem.counterpart, fromNextPage.fromPlaylistPanelContent, logged-in responses only) is swapped for that song; a video with no song version is skipped as before; and a page left empty is kept as it came, since a radio that plays videos beats one that plays nothing.preferAudioWhenRadioAudioOnlylogs rows / swapped / kept per page — whether a logged-in video radio really carries song counterparts was not measured, only inferred from the song-radio wrappers.
- Superseded 2026-09-25 — that measurement was a radio started from a song. A radio started from a video is nothing but videos (a UGC seed: 38 OMV, 11 UGC, 1 podcast episode, no song), so dropping every video emptied the queue outright. The setting is now "Play audio version instead of MV in radio", the same idea as YouTube Music's own Don't play music videos: a video row whose song rendition YouTube shipped alongside it (
- 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. Its only user was ArtistScreen's bottom fade, latent because Desktop never rendered that branch. (Updated 2026-09-23: removed there on both platforms, so nothing uses it now. On Android it also drew a visible seam across the artwork at the top of its 200dp box, even withstartIntensity = 0f. The fade is now aModifier.blurcopy of the artwork masked by aDstIngradient, which is fully transparent where the ramp starts and so cannot leave a seam; below Android 12 blur is a no-op and only the colour scrim remains.) 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, and since 2026-09-23 not in ArtistScreen either — itsHazeProgressiveguard (about skiko, not layout) went with the switch to aModifier.blurfade.- 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 two values on the orientation: frame
aspectRatio(1f)vs the decoded banner's own aspect (2.4:1 until it decodes; a square bitmap left from portrait is ignored), andContentScale.FillWidthvsCrop. (Changed 2026-09-22: landscape was a fixedheight(hDP / 2)strip that cropped the banner.) The scrim isfillMaxHeight(0.7f)of the frame in both — measured off the frame, since the landscape frame no longer follows the window.
- 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, 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 artwork has been scored. The seed wasstate.startColoruntil 2026-10-03 (see that entry). - 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 · Output · Shuffle · Repeat · PlaylistAdd · Queue (Output replaced Cast on 2026-10-03). Design mocks must lift the real drawable, not the icon a name suggests — the old Cast slot was
ic_music_cast.xml(Material Symbolsmusic_cast) from the cast module, not Material "cast".
-
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 (when the user confirms a romanization selection containing Japanese, or when Japanese is already selected and the pack is missing — see below):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. The build happens inside the download, on IO, before READY is published (RomanizationDictionaryPack.download()→PlatformRomanizer.prepareJapanese()): lyrics on screen re-read every line the moment READY lands, and the first Japanese one used to read the whole dictionary on the UI thread mid-song. A build guard (AtomicBoolean, notsynchronized) makes a second caller get null instead of queueing behind a running build, so the UI thread never waits on the IO one. That null is only safe because the one off-UI build runs while the state is stillDOWNLOADINGand theREADYafter it makes the lyrics read their lines again — a warm-up started anywhere else (say, at startup with the pack on disk) needs a signal of its own. A failed build is still not cached, and a pack already on disk at process start is still built on first use, on the UI thread. State (NOT_DOWNLOADED/DOWNLOADING/READY/FAILED) flowsLyricsRomanizerRepository→ Settings row subtitle; retry = confirm the dialog again (at once), or open lyrics / move to the next song's lyrics once 2 min have passed since the failure (doubling with each automatic failure in a row up to an hour; a failed Settings retry starts the schedule over); nothing retries on a timer.LyricsViewkeys each line's romanization on aderivedStateOf { state == READY }, because a line read before the pack landed would otherwise keep its null for as long as it stays on screen.- The selection is backed up, the pack is not (fixed 2026-10-09).
romanization_languageslives in the settings file, so it rides along in every backup the app writes (manual, and the scheduledAutoBackupWorker); the pack lives only in this install'sfilesDir, which an uninstall or Clear storage wipes. Reinstalling and restoring a backup therefore brought Japanese back with nothing on disk, and since the settings dialog was the only thing that ever started a download, lyrics silently lost their Japanese reading while the Settings row still listed Japanese. Two automatic triggers now callLyricsRomanizerRepository.ensureJapaneseDictionary()(non-suspending, returns at once): aLaunchedEffectinLyricsViewkeyed on the selection and the song's lyrics, and the repository's own one-time check of the stored selection on first use (it stays lazy), which covers Settings being opened before any lyrics. The Settings confirm still calls the suspendingdownloadJapaneseDictionary(), which retries a failure at once, joins a download already running so its toast still shows (one waiter at a time, so saving the dialog twice mid-download does not toast twice), and returns the state it settled on — the toast reads that, notjapaneseDictionaryStateafterwards. After a failureensuredoes nothing for 2 min, doubling with each automatic failure in a row up to an hour (FIRST_/MAX_AUTOMATIC_RETRY_DELAY); the first call after that retries. A failed download started from Settings resets the schedule to 2 min rather than lengthening it (one Settings merely joined was automatic, and counts as such). The backoff lives in memory, so each new process gets one immediate attempt. There is no resume, so without the backoff lyrics opened over and over on a connection that keeps dropping long transfers would restart the 13 MB from byte 0 every time. Downloads run in the repository's own scope (SupervisorJob+ a loggingCoroutineExceptionHandler) instead ofSettingsViewModel's, because leaving Settings mid-download used to cancel the fetch and leave the same broken state behind. One download at a time is enforced by claimingDOWNLOADINGwithgetAndUpdate, not a Mutex —KuromojiDictionarystages into fixed.part/.stagingpaths, so two concurrent downloads would clobber each other — and the outcome is published in afinally. Three things keep the state from sticking atDOWNLOADING: nothing runs between the claim and thelaunch(the log line moved inside the job, since a throw there would strand the claim), every fetch ends withinKuromojiDictionary.FETCH_TIMEOUT= 15 min (a connection that trickles bytes never trips OkHttp's 10 s socket timeouts, and with no screen owning the download nothing else would end it), and nothing cancels the scope. Only the fetch is bounded: extraction, the install rename and the analyzer build are local and take seconds, and a deadline landing on them would report a pack that is already installed as FAILED. Every failure logs oneLogger.wline with the next retry delay, including a rethrown cancellationKuromojiDictionaryitself stays silent about. The same triggers cover a device that had Japanese on before the pack moved out of the APK.
-
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
-
"More" on home sections, and a generic Browse page (2026-09-19): a home section that carries a
moreContentButtonnow shows a More button, and its title opens the same place — measured on live responses, the title run's endpoint is identical to the button's (onlyclickTrackingParamsdiffer), and a section without the button has no title endpoint either. The endpoint lands onHomeItem.moreEndpoint(browseId,params,pageType), read inHomeParser.- Route by where the endpoint points, never by guessing from the id. YouTube states the page type on the endpoint itself (
browseEndpointContextMusicConfig.pageType): Explore's "Trending" More carriesMUSIC_PAGE_TYPE_PLAYLIST.navigateToMoreEndpoint(inAdapterItems.kt) sends album/audiobook, playlist, artist/user-channel and podcast pages to their existing screens (PlaylistRepositoryImpladds theVLprefix itself), and everything without a page type — theFEmusic_*ids:listen_again,library_landing,moods_and_genres,new_releases_videos… — to the newBrowseDestination.paramstravel verbatim, still URL-encoded (ggMEgAKgAw%3D%3D); ytmusicapi and Metrolist send them that way too. - The Browse page copies Metrolist's
YouTubeBrowseScreen:YouTube.browse()in the scraper went from two shelf kinds to Metrolist's four — grid, carousel (two-row items, list rows and podcast episodes; immersive carousels arrive as carousels via@JsonNames), music shelf and playlist shelf — plus mood buttons (musicNavigationButtonRenderer), which is allFEmusic_moods_and_genresholds; each opensMoodDestination(params)from its own endpoint. Everything is flattened into one grid withdistinctBy { id }, section titles dropped, and no paging — Metrolist's browse screens do not page either. - Home's card dispatch is now
HomeContentCard, shared by Home's rows and the Browse grid, so an item opens and plays the same in both. It decides the card from which id is set and draws a wide thumbnail as a video card, soYTItem.toHomeContent()(inMapping.kt) writes 544×544 for songs and 1280×720 for videos to say which. - Justified rows, not a grid — every thumbnail in a row is the same height, each card keeps its own shape (square, video 16:9), and the number of cards per row follows the shapes in it;
justifyRows()inBrowseScreen.ktpacks greedily toward a 180dp thumbnail and sizes each card to fill the row exactly (subtracting the cards' own 20dp padding, so the thumbnails — not the cards — come out equal). The shape comes fromContent.homeCardAspectRatio(), which mirrorsHomeContentCard's dispatch. The cards takefillMaxWidth(defaultfalse, so Home/Library/Artist/Album are unchanged):fillMaxWidth()plus the card's own aspect ratio instead of the fixed 160dp / 284.5dp. Two grid attempts came first and both read as scattered: fixed-size cards in stretchedGridCells.Adaptivecells with videos spanning two left a hole beside every card and a card-wide hole after every video; cards filling one cell each left videos squashed into a square cell's width.LazyVerticalGridcannot vary the column count per row, which is the whole requirement once cards keep their own shapes. - It wears Mix for you's background: the artwork-tinted ambient glow (from the page's first item) and the bar that is transparent at the top and frosted once scrolled. The grid's top gap is
contentPadding, not a spacer item, because the glow parks once item 0 has scrolled by — with a spacer as item 0 it parks after ~64dp, while most of the glow is still on screen. - The two sections the app builds itself at the bottom of Home (issue #2461) come from
FEmusic_new_releasesthroughparseNewRelease, not fromFEmusic_home, so they needed their own wiring:YouTube.newRelease()now keeps each shelf's own More endpoint (ExplorePage.releasedMoreEndpointfrom the first shelf,musicVideoMoreEndpointfrom the last — the same shelves the two lists are read from), andparseNewReleaseputs them on the two sections. The video one leads toFEmusic_new_releases_videos, a 100-item grid with no continuation, i.e. the whole list. Signed out, the page has no album shelf (measured 2026-09-19 in VN, US and GB, andFEmusic_new_releases_albumsanswers 404 there) — a signed-in session still gets one first, which is why "New releases" shows up for the owner. Measuring only anonymously led to the wrong conclusion that albums were gone;parseNewReleasenow simply drops a section with no contents, which covers the signed-out case. - The album shelf's More leads to a dead page. It points at
FEmusic_new_releases_albums, which 404s for every client tried on 2026-09-19 — the app's own 1.20260304 web client, YouTube Music's current 1.20260915.14.00, signed out in VN/US, and the owner's signed-in session. It has been failing since at least 2026-05-29, when ArchiveTune (commitda4f838a) added a fallback fornewReleaseAlbumsthrowingClientRequestException; a Metrolist #1383 comment from 2026-02-06 already reports ArchiveTune's New Releases not working when signed in. Metrolist still calls it and shows an empty screen.parseNewReleasetherefore drops that one id (takeUnless) — the plumbing stays, so the button returns by itself if YouTube ever points the shelf somewhere live. ArchiveTune's fallback chain (no account →FEmusic_new_releases→ Explore) yields nothing in VN today either: signed out, neither page has an album shelf. getAlbumMorealso callsYouTube.browse()and reads the FIRST section. The artist-discography page is a single grid, so that is unchanged — but a page that ever puts another shelf kind above its grid would now handgetAlbumMorethat shelf instead.BrowseViewModelis a new KoinviewModel {}definition, and Koin definitions are the global state the Compose Hot Reload entry above says CHR cannot rebuild — restarthotRunJvmbefore opening the Browse page after pulling this in.
- Route by where the endpoint points, never by guessing from the id. YouTube states the page type on the endpoint itself (
-
"Keep service alive" removed entirely (2026-09-20, issue #2510): the Android setting and everything behind it are gone. That covers:
- the switch and its two strings (base + all 17 translations)
keepServiceAlive/setKeepServiceAliveonDataStoreManager+KEEP_SERVICE_ALIVE- the
SettingsViewModelstate - the whole block in
SimpleMediaService.onCreate media3-uifromcore/media/media3, which that block alone used
The block ran a second
PlayerNotificationManagernotification beside Media3's own, and restartedwhile (isActive) { startForeground(); delay(30 s) }on every notification post, onSERVICE_SCOPE(Dispatchers.Main).- It was the app's only uncaught
startForeground. media3-session'sMediaNotificationManagercatchesForegroundServiceStartNotAllowedExceptionaround its own call, so on Android 12+ a background denial of OUR call killed the process on main. The system then restarted the service, which crashed again — #2510's 1s → 4s → 16s → 64s → 256s restart backoff. - Its notification also captured
mediaSession?.sessionActivityat service creation, beforeMainActivitybinds and sets it, so tapping that notification did nothing. - A stored
keep_service_alivevalue simply stays unread in existing DataStore files. - Still open next door: #2071, the uncaught
ContextCompat.startForegroundServicefallback instartService()(Media3ServiceModule.kt).
-
Radio queues trim their own history, and the per-append queue scan got cheap (2026-09-21, issue #2504): a radio queue keeps 100 played tracks behind the current one and drops the oldest once the history passes 120 (
RadioQueueTrimincore/domain), so a listener can scroll back at most ~147 played tracks. Nothing ahead of the playing track is ever touched, and a playlist or album is trimmed only after Endless queue has carried it past the list the user picked — at which pointloadMorere-points it at the last track's radio and now also re-types itPlaylistType.RADIO(in BOTH of its Endless branches), because from there on it IS a radio.PlaylistType.RADIOdoes not mean "a radio". Favorites, Downloaded, Most played, Monthly recap, Your top tracks and the Analytics lists are queued asRADIOwithplaylistId = null, and a playlist's Shuffle is queued asRADIOwith the playlist's own id. The first version gated on the type alone and trimmed all of them.RadioQueueTrim.appliesTonow requires the type AND a YouTube radio id:RD…, minusRDCLAK…(a curated playlist, which has an end).reorderShuffledQueuewas the stall in #2504, not the appends themselves.loadMoreCatalogadds a batch one track at a time, so it fires ~50 timeline events; each one re-matched the whole queue with a nested search (queue × player string compares) and built three log lines containing every title, then threw the result away when the sizes disagreed. It is now a map lookup and logs sizes only. The first shape of this fix — an early return when the player list is shorter — was wrong:loadMoreCatalogupdatesqueueDataonce at the END, so mid-batch the PLAYER is the longer list.MediaPlayerInterface.removeMediaItems(from, to, onRemoved)removes a range of played tracks in one pass (one precache top-up, one timeline notification). Removing one at a time would repeat exactly the storm above. The default refuses; only the two real adapters remove anything.- Both adapters re-check everything INSIDE their own launch (issue #2156's crash shape).
- They refuse while
isCrossfading:crossfadeFromIndexindexes the same playlist and is not shifted, so a cancelled fade would revert to a track ~100 positions away. - They refuse while shuffle is on: the tracks before the current one in the unshuffled list are then still to come.
- Android also refuses while casting:
CastHandoffManager.remoteToPlaylistholds absolute playlist positions, so shifting them sends the receiver the wrong tracks. - They do NOT call
clearPrecacheExceptCurrentInternal(). That releases every precached handle, including the one the next crossfade is about to use; the removed tracks are all behind the playhead and precache is keyed bymediaId.
queueDatais cut by the removal's own answer, never by a guess. The adapter callsonRemoved(removedIds)exactly once, on its own thread, from INSIDE the removal and before anything else can run. It passes an empty list when it refuses. The handler cutslistTracksright there, only if those ids are exactly its front (RadioQueueTrim.afterFrontRemoved). AnAtomicBooleanin-flight flag stops a second request until the answer arrives.- The first version recorded an expected size and inferred the outcome from later timeline events, and that broke Desktop outright.
MpvPlayerAdapter.notifyListenersQUEUES listener calls, each carrying a snapshot, so a batch's own add events arrived after the request and cleared it before the removal ran. The player was trimmed andqueueDatawas not, for good. - With
queueDatalonger than the player, the load-more check (listTracks.size - player.currentMediaItemIndex < 3) never fires again, so the radio stopped about 50 tracks later. - Two requests in flight at once (a batch plus a crossfade end, or a batch plus Add to queue) cut twice for the same reason.
- Offset lists are the "tap a queue row, play the wrong song" bug, and
reorderShuffledQueuebails on a size mismatch, so nothing heals them.
- The first version recorded an expected size and inferred the outcome from later timeline events, and that broke Desktop outright.
- Shuffle is excluded on purpose.
currentMediaItemIndexand the indicesremoveMediaItemstakes count the UNSHUFFLED playlist, whilegetCurrentMediaTimeLine()andlistTracksare in shuffled order, so the oldest-played tracks are not a contiguous range there at all. Radio with shuffle on keeps its full history. TRIM_ABOVE_HISTORYis 120, not the ~150 the history is meant to stay under: the check runs right after a batch append, and a radio appends roughly every 50th track, so a threshold is only crossed at ~47, ~97, ~147, ~197… At 150 the first cut waited until ~197; at 120 it lands at ~147 and the history swings 100…~147.- With crossfade on, the batch lands mid-fade and the trim is refused — the batch is requested by the transition that STARTS the fade and arrives 1–3 s later, inside a 5 s default fade. Without a retry, crossfade users would never be trimmed; the handlers therefore call
trimRadioHistoryIfNeeded()again fromonCrossfadeStateChanged(false). That retry is posted, never run inline. Android callsonCrossfadeStateChangedfrom inside its own code: Next mid-fade commits the incoming track, then posts a seek to an index it has just computed. An inline trim therefore queued the removal ahead of that seek, and the seek landed ~47 tracks later. The fade remembers the track it may revert to as a playlist POSITION (crossfadeFromIndex), and an id would not do instead: a radio repeats songs, so ids are not unique in a queue. - Anything that holds a queue POSITION over time must expect the queue to shrink from the front. Before this, only the user's own actions shortened it.
playNextrecords just its insertion and applies it to the current list inside_queueData.update. It used to write back a copy of the whole queue taken beforegetSongInfosuspended, which silently undid a trim that landed meanwhile.- The handlers'
removeMediaItem/moveItemUp/moveItemDowncheck the position against the current queue and edit it insideupdate. - The queue ⋯ sheet, on both queue screens, remembers which track it was opened for and does nothing once that track is no longer at its position.
- Android's
removeMediaItem/moveMediaItem/replaceMediaItemand Desktop'sreplaceMediaItemre-check bounds inside their launch (#2156's crash shape). reorderShuffledQueuewrites only over the exact list it read (it.data.listTracks === listTrack). On Desktop, Add to queue runs it on the UI thread right after requesting a trim, while the cut lands on the player thread; writing its snapshot afterwards put the trimmed tracks back.
- Queue rows (the Classic/M3E sheet and the Apple Music queue) are keyed
videoId#occurrenceinstead ofindex + videoId: a front trim changes every position, so a position-based key made the whole list lose its scroll anchor and jump under the user. - Now Playing's artwork pager snaps instead of animating when the index moves more than one page — a trim drops it by ~47 without changing the track, and animating that flings the pager through dozens of covers.
-
Login sync: sign-ins from phone to desktop over the LAN (2026-09-21): Settings → Content gains a row that reads Send sign-ins to desktop on Android and Sign in from your phone on Desktop. Desktop shows a QR (
simpmusic://login-sync?h=<ips>&p=<port>&k=<key>) and runs a one-shot Ktor server (CIO engine) only while that dialog is open — 5 minutes, restarted when the phone connects. Android scans it (CameraX + ZXing), sends a sealed hello, gets the computer's name back sealed under the same key, asks Trust ()?, then lets the user tick services and sends only those. Layered like Listen Together: servicecore/service/loginSyncowns the transport and nothing else — invite/QR, AES-GCM,LoginSyncClient(commonMain,ktorExt's engine) andLoginSyncServer+reachableAddresses()(jvmMain,ktor-server-cio); it moves one opaque payload. domain holdsLoginSyncHostRepository(Desktop) andLoginSyncSenderRepository(Android) plusmodel/loginsynctypes. data holds what the payload means —LoginBundleandLoginSyncStoreincommonMain— and the two impls injvmMain/androidMain, each bound byloadLoginSyncModule()on its own platform (iOS binds nothing); core/data itself imports no Ktor. composeApp holds onlyLoginSyncHostViewModel/LoginSyncSenderViewModeland the dialog/sheet. Two repository interfaces rather than one so no platform carries methods it cannot implement; both VMs are declared in the common Koin module, which is safe because Koin resolves a dependency when the VM is created, and each platform only ever creates its own.- Only sign-ins travel, by allowlist: YouTube (every
GoogleAccountEntityrow — each carries its own cookie incacheandnetscapeCookie— plus the currentcookie/page_id/auth_user/data_sync_id/logged_in), Spotifysp_dc, the Discord token, the Last.fm session. No settings, no AI keys, no proxy credentials. The picker lists service names only, never account names. - Room before DataStore on restore.
CommonRepositoryImplrewritesytdlp-cookie.txtfrom the used account the moment the DataStore cookie changes, so writing the cookie first writes the previous account's file with no error anywhere. A newsp_dcalso clears Spotify's personal token, as the logout does. - The key travels on the screen, never over the network. Every body is AES-256-GCM (
nonce || ciphertext || tag) withsimpmusic-login-sync/v1/<kind>/<request|reply>as associated data, so a hello cannot be replayed as a login bundle and a request cannot pass as a reply. The name coming back sealed is what proves the machine answering is the one showing the QR. - composeApp's
commonMaincan use the JDK (it targets only Android and JVM —Platform.ktalready callsSystem.getProperty), and the loginSync service targets only Android + JVM for the same reason, so its cipher is plain javax.crypto in commonMain. Its server handlers are suspend functions, so the restore is a plain call.core/service/loginSync/src/jvmTesthas a real client↔server round trip on 127.0.0.1 — hello, payload, reply, then the spent code refused.stop()/disconnect()are deliberately NOT suspend: callers are teardown paths whose scope is already being cancelled, so the repository runs them on its own scope, and the server stop isNonCancellableso a cancelled caller cannot leave the port open. Do not go back to JDKcom.sun.net.httpserver: with its default executor every handler runs on the dispatcher thread, andHttpServer.stop()joins that thread — the first version called it fromonDisposeon the UI thread while a restore was in flight, and the desktop app froze. - CameraX + ZXing, not ML Kit or Google's code scanner — both are proprietary and F-Droid refuses them.
CAMERAis paired withuses-feature android.hardware.camera required="false"; without it the permission implies a required camera and hides the app from TVs and cars. - Android UI is a bottom sheet in the app's own sheet style (transparent
ModalBottomSheet+CardonrememberSurfaceDarkColors()): header with a close button, a square rounded viewfinder, and in-sheet states for camera permission — ask, and once denied with "don't ask again" (shouldShowRequestPermissionRationalefalse after a denial) an Open settings button, re-checked on resume. Trust and service picking stay popups over the sheet.PreviewViewmust useImplementationMode.COMPATIBLE: the default SurfaceView ignores Compose clipping, so the rounded viewfinder renders with square corners. - Android 17 local network permission:
ACCESS_LOCAL_NETWORKis declared in the manifest and asked for together with the camera on every Android 17+ device, whatever the targetSdk — without it a LAN connection does not fail, it times out (element-x-android#7694). Corrected 2026-10-04: the request used to wait for targetSdk 37, because apps below 37 get the permission implicitly with INTERNET. They do, but only when they do not declare it themselves (AOSPParsingPackageUtils.convertSplitPermissions; Google's own page says not to declare it below 37), and ours declared it — so on Android 17 Login Sync timed out on every phone until the user allowed Nearby devices by hand. Ping working both ways proves nothing here: the block is per app. A custom AI base URL asks for it when saved, but only when the URL lands on the local network (rememberLocalNetworkPermission: a.localname, or — after a DNS lookup off the main thread — an address in AOSP'sIPV4_LOCAL_PREFIXES169.254/16, 100.64/10, 10/8, 172.16/12, 192.168/16, or IPv6 link-local/unique-local). A proxy on the home network deliberately asks nothing and works only once the user has granted Nearby devices. - Addresses are not limited to private ranges or to Wi-Fi. Every up, non-loopback, non-link-local IPv4 is listed under the QR as a radio list, in interface order with no ranking, and the QR carries only the one the user picks; switching re-points the QR without restarting the server, which listens on every interface anyway.
isSiteLocalAddressalone reported "no network" on a corporate LAN numbered from 21.x, and excluding point-to-point interfaces threw away the Tailscale address (100.x ontailscale0) — which is what lets a phone on the same tailnet pair from anywhere. Only container/hypervisor bridges (docker,br-,veth,virbr,vboxnet,vmnet,podman,cni) are dropped, since their addresses exist only inside the machine. - Desktop name: macOS
scutil --get ComputerName, WindowsCOMPUTERNAME, Linux/etc/hostname. - Known limits: the phone must be able to reach one of those addresses (guest Wi-Fi usually blocks client-to-client traffic), and the first listen raises the Windows Firewall / macOS incoming-connection prompt.
- Only sign-ins travel, by allowlist: YouTube (every
-
Unofficial copies are blocked on Android (2026-09-22): our own release APKs circulate renamed and re-signed (App Cloner / apktool — seen as
com.spotify.music,cn.kuwo.player,com.apple.android.music,com.anxhify.music). The package must be inConfig.OFFICIAL_PACKAGE_NAMES(com.maxrave.simpmusic,.dev) and the signing certificate's SHA-256 must matchAllowedAPKSigningKeysin F-Droid's metadata for this app — F-Droid ships the APK we sign, so that value is our release key and one check covers both flavours. The key is not in the code: it follows the update check's path —Ytmusic.fdroidMetadata()(fdroiddatamaster, raw.githubusercontent — so a key rotation on F-Droid reaches the app without a release; the field may be one inline value or a YAML list) →YouTube.getFdroidSigningKeys()→UpdateRepository.getFdroidSigningKeys()→SharedViewModel.checkOfficialBuild(packageName, signingCertSha256())→isOfficialBuild: StateFlow<Boolean>, collected inApp(). It startstrueand only a successful fetch with none of our keys turns itfalse, so a failed fetch (or no certificate read at all) never blocks — the app plays offline. A failing copy getsUnofficialBuildScreeninsideApp()'s ownAppTheme(so it follows the user's theme mode and colour) in place of the whole app: a full-screen block with no way back into the app, only a download button to simpmusic.org/download. Desktop is out of scope and untouched (the state defaults totrueand nothing there calls the check).- The two halves catch different things. Signature-killer tools (MT Manager, ApkSignatureKiller) hook
PackageManagerinto returning our certificate, but cannot hide a renamed package — and every clone seen so far was renamed. - Debug builds skip the check (
BuildConfig.DEBUG, a compile-time constant): they are signed with each machine's debug key. Do not key this off the manifest'sdebuggableflag instead — a modder can set that. MainActivity.onCreatereloadsviewModelModule, soSharedViewModelis rebuilt whenever the activity is recreated (language or light/dark change) and the signature half re-fetches fromtrue; the package half is synchronous and unaffected.- It blocks the UI only: the media service still starts, so anything driving the MediaSession without the activity (Android Auto, car head units) is not blocked by it.
- The nightly workflow (
android.yml) signs withsecrets.SIGNING_KEY, the release workflow withBASE_64_SIGNING_KEY. If those are different keystores, every nightly is blocked. - Anyone patching the APK can remove the check; only server-side Key Attestation resists that, and nothing here needs it yet.
- The two halves catch different things. Signature-killer tools (MT Manager, ApkSignatureKiller) hook
-
Intel Macs get their SQLite JNI library back (2026-09-23, issue #2538): androidx.sqlite 2.7.0 ships no
natives/osx_x64/libsqliteJni.dylib, so the mac.amd64 build died on Room's first open with "Cannot find a suitable SQLite binary for mac os x | x86_64". Not a deliberate drop: androidxb4d7ca565f(2025-12-16) removedMACOS_X64from sqlite-bundled's native target list while retiring the deprecated Kotlin/NativemacosX64target (KT-78660), and the JVM JNI libraries are compiled from that same list;androidx-mainstill lacks it.sqliteIntelMacNativeincomposeApp/build.gradle.ktsdownloads sqlite-bundled-jvm 2.6.2 (SHA-256 pinned), extracts only that dylib and adds it tojvmMainresources —NativeLibraryLoaderfalls back togetResourceAsStream("natives/<os>_<arch>/…")on its classloader, so everything else stays on 2.7.0 and no runtime code changed. Safe because both versions bind throughRegisterNativesinJNI_OnLoadand register the identical 22 methods, on the same SQLite 3.50.1; 2.7.0 only adds SQL math functions, which no query uses. Re-diff the RegisterNatives strings before bumpingsqlite, and drop the block if upstream restores osx_x64. -
"Liked songs" section on the artist page (2026-09-23, issue #2524): a section shaped like Spotify desktop's, placed above Popular — a "Liked songs" heading, then a 48dp artist picture wearing the red liked heart beside "N songs" / "By " (plural
liked_songs_count, written%1$d: the old unusedn_songplural uses bare%d, which Compose Resources prints verbatim). Hidden while the count is zero; openingLibraryDynamicPlaylistScreenas the newLibraryDynamicPlaylistType.ArtistLiked(channelId)(routeartist_liked_<UC…>). Liked only, not downloads: the row says "Liked songs" and downloads already have their own list.DatabaseDao.getLikedSongsByArtistis a Room Flow (unlike the pagedgetLikedSongs), so liking from the page updates the count at once; it matches the channel id as a quoted token inside theartistIdJSON with the samereplace()+ESCAPE '\'treatment asdeleteUnfollowedArtists— verified against the owner's DB: 76/76 artists match a JSON-parsed count, and 23 of those ids contain_. The list is ordered like Favorites (favoriteAt ?: REMOVED_SONG_DATE_TIME), not in SQL: 57 of 72 liked songs there have a NULLfavoriteAt. The row navigates with the route'schannelId, the id the count was queried with. -
Multi-language audio: the original track, or a preferred language (2026-09-23): a video with several audio tracks lists every one of them under the same itag, ordered by language code — measured on
0e3GPea1Tyg: 25 tracks per itag, Arabic first, the original English at position 23.YouTube.newPipePlayerpaired formats to URLs withstreamsList.find { it.first == itag }, so every dubbed podcast played its alphabetically-first dub. Each googlevideo URL names its own track in thextagsquery value (acont=original:drc=1:lang=en-US,acont=dubbed-auto:lang=vi);extractor/AudioTrackOrder.ktstable-sorts the (itag, url) pairs before thatfind: a track in the preferred language, then the original, then the rest. A single-track video carries noxtagsat all, ranks as original and keeps its order, so songs are untouched. One sort in commonMain covers Android, Desktop, all three extraction tiers, playback and download.contentLengthmust come from the chosen URL'sclen.StreamRepositoryImplrequestsrange=0-<contentLength>, and it read that length off the firstFormatwith the itag — each dub has its own size (Arabic 25 099 982 vs original 24 140 597 bytes), so the range described a different file than the one fetched.- Setting: Settings → Preferred audio language (
preferred_audio_language, a 2-letter code, empty = original), passed asplayer(…, preferredAudioLanguage). Deliberately not defaulted to the app language the way the YouTube subtitle language is: that would replace the speaker's own voice with an AI dub (dubbed-auto) for every user whose language has one.descriptivetracks (narration for blind viewers) never count as the preferred language. - Not handled: the original has two variants, and
acont=original:drc=1(dynamic-range compressed) is listed first, so it wins — which is what the itag-first code already picked. A URL already stored inNewFormatEntityis reused until it expires, so changing the setting does not affect a video played recently.
-
haze 1.7.2 → 2.0.0 (2026-09-24): 2.0 publishes no
haze-materials; blur moved tohaze-blurand the presets tohaze-blur-materials(packagedev.chrisbanes.haze.blur.materials, no opt-in). The mutableModifier.hazeEffect(state) { … }DSL is gone — ahazeEffectstill exists but is the generic custom-effect API (factory, input, style), so leftover calls fail as "no matching overload", not "unresolved". All 22 call sites are nowhazeBlur(HazeInput.Sources(hazeState), style):Sourcesis the 1:1 replacement for 1.x's state-based effect (Backdropis only a native path behind an off-by-default flag, Android 37.2). The 13 tinted top bars sharebarBlurStyle(tint, tintAlpha)and the 9 plain onesultraThinBarStyle(), both inUIExt.kt. Neither forcesblurEnabled. haze blurs by default on Android 12+ and on Desktop, and everywhere else draws the style'sfallbackColorEffect, which is set to the bar's own colour, opaque (2026-10-04).- Do not force blur back on. Below Android 12, haze blurs through RenderScript, which haze's own docs call experimental. That path still dies natively on haze 2.0 and 2.0.1, in
GrallocConsumer::lockNextBuffer, while one blurred screen replaces another: Library into the four-tile screen above all, More albums too. In Sentry this is ANDROID-Q, -ND and -SE, on Android 10–11 only. A native crash leaves haze no chance to fall back. Forcing blur was why tapping Favorite / Followed / Most played / Downloaded crashed on a Realme 6 Pro running 2.2.0. - A Style is compared by its recorded writes (
HazeBlurStyle { }records into a list;RecordedHazeBlurStylehas structuralequals), so building one per recomposition is free — norememberneeded. performanceModeis left atDefault, which is nowAdaptive(downsamples under load); passHazePerformanceMode.Qualityif a bar looks softer than before.- haze 2.0 is built on Compose Multiplatform 1.12.0, i.e. skiko 0.150.x, and so is everything that moved with it. That killed the Desktop tray at runtime, not at build time:
composenativetray1.3.3 (the last release underio.github.kdroidfilter, built on Compose 1.10.3) callsImage.encodeToData$default, which skiko 0.150 no longer has —NoSuchMethodErroron the first composition, fromTray(…)rendering its icon, while Gradle reports BUILD SUCCESSFUL. The tray moved todev.nucleusframework:composenativetray2.1.6 (Compose 1.12.0), packagedev.nucleusframework.composenativetray.Tray/Item/Dividerkept their shape. SingleInstanceManagerdid not disappear in tray 2.0 — it moved todev.nucleusframework:nucleus.core-runtime(dev.nucleusframework.core.runtime). The tray pulls that artifact only into its RUNTIME variant, so it must be declared directly (libs.nucleus.core.runtime, on thenucleus-notificationversion) to compile against it.isSingleInstance(onRestoreRequest = …)still exists and still runs beforestartKoin(#2044); its callbacks now receive the restore-filePath. The README steers tonucleusApplication { }instead — do not take that route without movingstartKoin, since that check would then run after Koin has already opened DataStore.
- Do not force blur back on. Below Android 12, haze blurs through RenderScript, which haze's own docs call experimental. That path still dies natively on haze 2.0 and 2.0.1, in
-
Now Playing video frames take the video's shape (2026-09-25): the frame around an inline video was a hard 16:9 in all three styles, so a 4:3 video sat pillarboxed and a vertical one shrank to a sliver. Every style now reads one value,
NowPlayingContentState.videoAspectRatio(16:9 until known), and fits the frame into the space that style already reserves for artwork — never more, so the one-screen layout cannot grow. Spotify fits it inside the square slot; the hidden thumbnail under it takes the same shape while the video plays, because its box is what casts the artwork shadow — left at 16:9, that shadow ringed a tall video. M3E's card takes the video's shape inside a slot capped at square, andexpressiveCardSlotRatio()is the ONE expression both the card and its measuring spacer use. Apple Music fits it into the zone above the controls, starting 16dp below the top chrome (status bar + 28dp grabber on Android, the glass dismiss button on Desktop), because the frame's top-right corner carries the fullscreen button. That zone is the page's own height minus the controls, NOTartworkZoneHeightDp: that value is measured against the window, and the Desktop side panel is shorter than the window by its shell padding, so a tall frame ran into the gap above the title.- The value comes from
rememberVideoAspectRatio(playerName)(expect/actual). Android reads Media3'srememberPresentationState(player).videoSizeDp. Desktop reads mpv'svideo-params/aspect, observed byMpvPlayerand published asMpvVideoFrameSource.aspectRatio— the frames cannot say it themselves, since mpv letterboxes them to the target size. Living on the frame source, it is re-pointed byMpvPlayerAdapter.currentVideoFrameson every handle swap for free. - The Full build's session player only reports what changed. There
MAIN_PLAYERis a Media3CastPlayer(ForwardingSimpleBasePlayer→SimpleBasePlayer): it re-reads the wrapped player ononEventsand fires a callback only for fields that differ from its last snapshot. A view composed mid-video — Now Playing reopened after fullscreen — therefore never hearsonVideoSizeChanged,onTracksChangedoronMediaItemTransitionfor the current item.MediaPlayerViewWithSubtitlestarted atvideoRatio = 16f / 9and waited for that event, so the video came back stretched to 16:9 ever since Cast wrapping landed (corec310577, 2026-07-10); FOSS passes events straight through and healed on the next frame. UI state derived from the session player must be read from the player's getters (or Media3'srememberPresentationState) before listening.
- The value comes from
-
Apple Music style: uncropped artwork, lyric strip, output capsule, mesh page (2026-09-30): MAIN was reworked. The controls block now hangs from the bottom of the page, and the artwork is a full-width square at the top (
minOf(width, height × 0.62)), so a cover is no longer cropped by roughly a quarter of its width. The space left between the artwork and the title is page, not artwork.- Apple Music's animated artwork is no longer a fullscreen canvas. It plays in a frame of its own shape (3:4 or 1:1) at full width, top-aligned, with the controls exactly where the still cover put them — they must not jump when the clip arrives a few seconds in — so a tall clip's lower part fades out behind the title. The clip's still (
CanvasData.thumbUrl, newly carried fromcanvasThumbUrl) sits under it, sizes the frame and fades out as the clip fades in. Only a Spotify canvas (.mp4, 9:16) still takes the whole page and auto-hides the controls;isAnimatedArtwork()tells them apart by the.m3u8Apple serves. Since 2026-10-03 the shell's auto-hide fires only for that canvas (fullscreenCanvas()). That canvas's bottom scrim is sized off the measured controls block (the controls fill its lower 60%), so the title sits at the same depth of the ramp however tall the block grows. - The clip cannot be masked, so the page is laid over it. Media3's
PlayerSurfaceis used withSURFACE_TYPE_SURFACE_VIEW(media3-ui/MediaPlayerView.kt), and a SurfaceView is composited under the window: no Composealpha,DstInmask or offscreen layer reaches its pixels. Masked like the cover, the clip ended in a hard edge right under the title. InsteadAppleMusicMainViewdraws a secondAppleMusicMeshBackdrop— same mesh, same size, same seam, so it matches the page beneath it pixel for pixel — ABOVE the pager, faded in withappleMusicFadeBetweenover the stretch the cover dissolves over, ending at the clip's bottom edge. Compose content drawn above a SurfaceView does cover it, which is what makes this work on every surface type; the frame itself carries no mask, so Desktop (where mpv frames are plain Compose images) gets the identical fade. - The band under the status bar (Android) is blurred and darkened so white status-bar glyphs and the grabber survive a bright sleeve. The owner rejected a first version (grabber + 40dp long, on a smoothstep ramp) as too dark and too long. The band is the status bar + 32dp. The blur is
TopBandBlur: aModifier.blur(32.dp)copy of the picture inside each pager page, solid under the status bar and gone at the band's end — the clip's band uses its still, since nothing blurs the video surface. The wash is black atalpha × (1 − p)^1.5(sampled at 24 stops), with alpha from 16% on a black sleeve to 65% on a white one, driven byAppleMusicMesh.topLuminance(the top grid row, read before lifting). (Changed 2026-10-23, Apple Music style only: the artwork no longer runs under the status bar. It starts at the status bar's height, its first status-bar-height of picture dissolves on the same smoothstep as its bottom edge, and the band above holdsAppleMusicMesh.topEdge— the artwork's top grid row left unshifted, since every row but row 0 is carried sideways — throughAppleMusicMeshBackdrop(topHold = …), handing over to the held bottom row halfway down the fully opaque stretch. The animated artwork gets the page laid over its top edge, as over its bottom.TopBandBluris gone from this style; the wash stays. Classic/M3E'sAppleMusicAnimatedArtworkPagekeeps the band blur.) - The page is a mesh of the artwork's own colours (
AppleMusicMeshBackdrop): a 6×6 grid of means read bottom-up, rows below the seam shifted sideways by a seeded amount, smoothstep-resampled to 32×32 and drawn from the artwork's bottom edge (the first row is held above it), blurred 44dp, crossfaded 900ms on a skip. The colour directly under the artwork is the colour it ended on, so there is no seam to hide. LYRICS and QUEUE keep the frosted cover. Pixels are read throughtoReadableBitmap()(expect/actual) because Coil hands back HARDWARE bitmaps on Android, which throw when read or drawn into a software canvas. - Every fade is smoothstep —
appleMusicVerticalFadeEdgesnow builds 24 stops on the curvesmoothScrimBrushuses; the linear ramp it had left a visible line where the artwork started to dissolve. The owner asked for this twice. - Lyric strip over the progress bar (
AppleMusicLyricStrip): the line being sung inlabelSmallwhite — the role Classic and M3E use for their inline line — rising in over 340ms as the old one rises away. Word-synced lines are lit as they are sung by drawing the line twice and clipping the bright copy to the playhead in the draw phase, which is what lets the light travel inside a word. The playhead is the strip's ownrememberSweepPlayhead, not LyricsView'srememberSmoothPlayhead: that one restarts from every 50 ms report, and since reports land a frame or two late and never on a frame, a continuous sweep stalled and jumped several times a second. The sweep clock runs on the frame clock, only leans toward the reports (time constant 150 ms), never runs backwards, and snaps only past 500 ms (a seek). Before the first line starts the slot holds three dots. One line tall in every state, so nothing below it moves. Tap opens the Lyrics tab. - Text: the main title row keeps M3E's roles (
titleMedium/bodyMedium, 18/13sp) — one role up (titleLarge/bodyLarge, 25/18sp) was tried and rejected as far too big, since the app's scale jumps 18 → 25. Secondary text (artist, times, output caption, compact artist, queue subtitles) isWhite @55%, nottypo()'s body colour: underForceDarkContentthat is an opaque#A8A8A8, darker than a bright sleeve's page, so it read as a dark grey smudge there. Both sliders' track is 8.4dp (16.8dp while held), 20% thicker than before. - Quality line instead of the codec badge: "154 kbps · 48 kHz" from the stream's own
NewFormatEntity.bitrate/sampleRate(toAudioQualityLabel), behind a 13dp headphones glyph, pinned to the row's centre — the three-way weighted row wrapped it onto two lines.audioCodecLabelis gone. - Dock → action row: Lyrics · [Output | Listen Together] · Queue, with the output's name under it. The output half opens
AppleMusicOutputSheet(the ⋯ sheet's card, handle and rows), where the device's own outputs and the Cast receivers are one list — Cast moved out of the dock into the sheet. The Listen Together half is highlighted while a room is live and navigates to its screen. - Per-app output routing —
MediaPlayerInterface.audioOutputs/selectAudioOutput/refreshAudioOutputs(default: empty/no-op). Android:AudioOutputRouterlists outputs throughAudioManager+AudioDeviceCallbackand routes withExoPlayer.setPreferredAudioDeviceon every player (createExoPlayerInstancehands the pick to each new one). This routes the app's player only, not the phone. Picking what the system already uses clears the preference, so a headset plugged in later still wins. Desktop: mpv'saudio-device-list/audio-device, applied to every live handle and re-applied inapplyPlaybackLevels. Only the pinned driver's devices are offered: mpv lists every driver together, and naming acoreaudio/…device would switch a macOS handle back to the driver that crashes the process on hotplug (see "macOS desktop audio moved toao=avfoundation"). Cast receivers come fromrememberCastRoutesin thecastmodule (MediaRouter withCastContext.mergedSelector;selectRoutestarts the session andunselect(UNSELECT_REASON_STOPPED)ends it, per Google's "Custom Cast Dialogs"), with a no-op twin incast-empty.
- Apple Music's animated artwork is no longer a fullscreen canvas. It plays in a frame of its own shape (3:4 or 1:1) at full width, top-aligned, with the controls exactly where the still cover put them — they must not jump when the clip arrives a few seconds in — so a tall clip's lower part fades out behind the title. The clip's still (
-
Live broadcasts play (2026-10-01): YouTube Music's "Station" shelf (e.g. Walker Stations,
9l63T77YL2c) is 24/7 live streams, and every layer of playback assumed a fixed length. A live video is recognised byPlayerResponse.VideoDetails.isLive— NOTisLiveContent, which stays true on the recording a finished broadcast leaves behind — and plays from its HLS playlist.- Nothing says a queue item is live until its stream resolves. Queue rows (
next) carry no LIVE chip — a live seed shows only its elapsed time aslengthText— soStreamRepositoryImpl.getStreamrecords every resolution inLiveStreamRegistry(core/domain; in memory on purpose — a broadcast ends, and the next run re-learns it). (Corrected 2026-10-16: this used to say search results do not mark it either. They do, and so do playlist rows; see "Live items are marked from the parser down".) Both adapters andSharedViewModel(TimeLine.isLive) read it from there. Live streams are deliberately NOT written to the format table. - Extraction — only the YouTube Android client's HLS keeps playing (measured 2026-10-01 on
9l63T77YL2c/QJXse_NH9Ho, signed in and anonymous): WEB_REMIX, WEB,web_safariand MWEB return playlists that load (200) but every segment is refused (403 — GVS PoToken); IOS 21.26.4 and TVHTML5 send no HLS for a live stream;ANDROID_VR1.65.10 plays and then refuses every segment from ~30 s in while its playlist keeps refreshing (mpv:paused-for-cache, then idle — the user saw an endless spinner), although yt-dlp's policy still marks its HLS as not needing a PoToken.ANDROID21.26.364 (YouTubeClient.ANDROID, versions from yt-dlp's INNERTUBE_CLIENTS) played 130 s straight with segments 200 throughout.Ytmusic.liveStreamPlayersends no cookie and a fresh anonymousvisitorDatafromvisitorData()(sw.js_data): with none every client is bot-checked, and ANDROID_VR also refused the session's own stored one.YouTube.player()checksisLiveBEFORE the OK-status gate —videoDetailsis present even on a refused response — and takesliveStreamHlsUrl()first, thenExtractor.liveHlsUrl()(PipePipe/BravePipe tiers returningStreamInfo.hlsUrl, kept apart fromnewPipePlayerbecause every tier there rejects a result without regular audio+video itags) as a fallback.tempRes's ownhlsManifestUrlis deliberately NOT a fallback: it is the web kind. The response goes out throughasLiveStream(url): HLS only, formats emptied. Re-measure with a 2-minute run before trusting a client: the 30-second cut-off is invisible in a short test. - Android cannot know a track is live before building its source.
DefaultMediaSourceFactorypicks progressive vs HLS when the MediaItem is set; the URL is only resolved later, inResolvingDataSource. So the progressive resolver throwsLiveStreamDetectedExceptiononce the registry knows,CrossfadeExoPlayerAdapter.onPlayerErrorloads the track again, andMergingMediaSourceFactorythen builds anHlsMediaSourceonsimpmusic-live://playlist/<id>, resolved through a plain-HTTPResolvingDataSource(no cache, no 5 MiB chunks — segment URLs are real).LiveStreamAwareLoadErrorHandlingPolicystops the Loader backing off for seconds on an error a retry can never fix, and a precached player that already errored is dropped instead of promoted. All of it lives inmedia3/.../mediasourcefactory/LiveStreamPlayback.kt. - Desktop: mpv plays HLS itself, so
extractPlayableUrlhands it the playlist without theedl://merge (the playlist carries audio and video). The Linux FFmpeg build disables no demuxers and enables thehlsprotocol. reconnect_streamed=1breaks live HLS in mpv. The app'sstream-lavf-o(reconnect=1,reconnect_streamed=1,reconnect_delay_max=30, the VLC-era http-reconnect) made a live playlist play 3–4 s and then stall — or report EOF, whichhandleTrackEndInternalturns into "next track": the build that first played a live stream skipped it after ~2 s. Measured on libmpv 0.37, audio-only and video alike: 4 s played in 20 s with the option, 17 s without;reconnect=1,reconnect_delay_max=30alone is fine.MpvPlayer.create(liveStream = true)drops onlyreconnect_streamed, chosen throughPlayableSource.isLive.SharedViewModel's Progress branch treatstotal <= 0as still loading, which a live stream on Android (ExoPlayer durationC.TIME_UNSET) would be forever —TimeLine.isLiveexempts it.- Live tracks skip crossfade on BOTH trigger paths (near the live edge mpv's "time remaining" looks like a song's last seconds), skip precache and
seekTo, and Previous goes to the previous track instead of restarting. Seek bars readTimeLine.lengthLabel()/elapsedLabel()(AllExt.kt) and showLIVE. No lyrics provider is asked for a live track:SharedViewModel.getLyricsFromFormatreturns early for it, and every lyrics fetch comes through there (on Desktop the seek-window "duration" would otherwise trigger one). - LIVE badge: Home's "Station" shelf is served signed-in only; its cards are
musicTwoRowItemRendererwithsubtitleBadges: [{liveBadgeRenderer: {label: "Live"}}](read off a signed-in response).Badges.liveBadgeRenderer→MusicTwoRowItemRenderer.isLive→Content.isLive→ a red chip ahead of the channel name inHomeItemVideo. The badge only decorates the card; playback still learns liveness from stream resolution.
- Nothing says a queue item is live until its stream resolves. Queue rows (
-
Chart: one odd shelf blanked the whole chart (2026-10-01):
parseChartbuilt playlists withcontents.map { … ?: return null }. Areturninside an inline lambda returns from the ENCLOSING function, so the US chart's new "Weekly top podcast shows" shelf (list rows, not two-row cards) made the whole chart null — only the US showed nothing. Unreadable items are now skipped, and that shelf renders asChart.podcastsin the Top artists layout, openingPodcastDestination. -
Classic and M3E play animated artwork the Apple Music style's way, and get the output sheet (2026-10-03): in these two styles Apple Music's animated artwork used to play like a Spotify canvas — page gone black, artwork card hidden, controls auto-hiding after 5 s. It now plays through
AppleMusicAnimatedArtworkPage(inNowPlayingContentAppleMusic.kt, so it reuses that style's private pieces): the clip edge to edge at the top in a frame of its own shape, dissolving into a mesh of its own colours, with the controls where they always are and never hidden. It is the current pager page's top layer and covers the card once the clip's still is up; until then the style's own page stays in view, so nothing jumps from a square cover to a full-width one. Over its last 8% it fades into the colour below the fold (PlayerBackdropColor, M3Esurface), so scrolling past it shows no seam. Only a Spotify canvas still fills the page.isAnimatedArtwork()andfullscreenCanvas()live on the shared contract (NowPlayingContentState.kt). The shell'sshowHideMiddleLayoutand every auto-hide trigger readfullscreenCanvas(), so animated artwork hides the controls in no style.- The band under the top bar is taller than the Apple Music style's, because the bar's title sits inside it: blur and wash are solid through the status bar and ease out by the bar's end + 32dp, the wash on smoothstep. The grabber's
(1 − p)^1.5falloff would have left the title on a band that had already faded. - Secondary text turns White @70% over animated artwork (
secondaryTextColor(): artist, elapsed, length). The mesh can be bright, andtypo()'s body grey reads as a smudge there. - Output replaced Cast in Classic's bottom row and M3E's connected group: Headphones, opening
AudioOutputSheetHost(the Apple Music style's output sheet, collecting its own state), where Cast receivers sit with the device's own outputs. Tinted cyan (Classic) / primary (M3E) while casting. - M3E's scheme is seeded the Material You way:
themeColorOrNull()(MCU QuantizerCelebi + Score) on a 112px read of the artwork, not the shell's palette colour. That colour is kmpalette's DARK swatch, chosen for Classic's gradient; on a light cover it is often a near-grey whose stray huePaletteStyle.Vibrantblows up into a colour the cover does not have — a pink sleeve came out green. - The flat (non-glass) Android bottom bar now folds and unfolds like the glass one:
LiquidGlassAppBottomNavigationBar(liquidGlass = false)drawsFlatTabBar/FlatSearchButton, and the whole bar grows from the left instead of a circle jumping to the middle.App.kttakes that path whenevergetPlatform() == Platform.Android || liquid glass is on.
-
Settings grouped like Apple Music (2026-10-03): every section of
SettingScreenis aSettingGroup(ui/component/SettingItem.kt): an uppercase header (the old headers'labelMediumsize, Normal weight instead of Bold), the rows on one 20dp-rounded card, and an optionalfooternote under the card (SponsorBlock's intro, the import tools link). Rows separate themselves:SettingItemdraws a hairline along its top, inset 16dp on the text's side, and the card clips the first row's away (it cannot paint over it: the card is translucent, alpha 0.5), so a row hidden by a condition never leaves a stray line at the top. The card issurfaceContainer(surfaceContainerHighread too heavy), except on a light Desktop, whose panel already issurfaceContainer; there it issurfaceContainerLowest(white). The equalizer, delay and reverb panels inside it step up tosurfaceContainerHighso they stay a surface of their own. The proxy rows and the crossfade rows, which were LazyColumn items of their own, now sit inside the Content and Playback groups.SettingItemis used nowhere else, so its 16/12dp padding assumes a card; the equalizer, delay and reverb panels are inset 16dp to match. -
AI "Your music taste" card in Library (2026-10-04): Your Library gains a card under the four tiles, shown only while local tracking is on. Tapping it sends the last 12 months of the listener's own history to the AI provider configured in Settings › AI, which writes back a persona headline and three short paragraphs (about 120–160 words: the sound, the moods and themes, how they listen; a first cut at two sentences read as far too short). The reading stays on the card (DataStore key
ai_taste_profile, JSON) with Regenerate and Share; Share opensShareTasteSheet, the lyrics share sheet's shell (ShareLyricsSheetHeader/ShareLyricsPalette/ShareLyricsPillbecameinternalfor it, and the card's signature row becameShareCardSignature). Layers:TasteInput/TasteProfile/TasteExceptionandTasteRepositoryin domain,TasteRepositoryImplin data,AiClient.describeTasteplusTasteReading.kt(prompt, data block, parser, with a commonTest) in aiService,TasteViewModelandui/component/taste/in the app.- Taste, not statistics. A first draft recited the listener's own figures back and was rejected; the prompt forbids repeating any number, hour or date, names a genre only for artists the model clearly knows, and treats song titles as impressions. Behaviour goes to the model as words ("replays the same songs: a lot"), never as figures it could quote.
- The model knows nothing about the listener:
TasteViewModelgathers names through the queriesWrappedViewModelalready uses (30 songs, 25 artists, 10 albums, the previous 12 months' top artists, four day-part shares, replay and concentration). Plays credited to a blankchannelIdare dropped — on a real history they were one play in eight, enough to make a blank line the top "artist" — and so are view counts that slipped into artist credits ("28M plays").hasEarlierHistoryis judged on the previous window's rows, not on the names that resolved, so a first-year listener is not praised as an explorer for meeting every artist for the first time. - No
responseFormat. The prompt asks for a bare JSON object andparseTasteReadingtakes the outermost{…}; providers on the custom OpenAI-compatible path do not all acceptjson_schema(#2297), and a refused format fails the whole request. - Failures come back as
TasteExceptionsorted by what the user can do: no key → "AI is not set up" with a Settings button, 400/401/403/404 → Settings, everything else → Retry. A failed Regenerate keeps the stored reading and only shows a toast. Clearing listening history also clears the reading. The card needs 30 active days in the window, Wrapped's own bar. - Text inside an M3
Button/TextButton/FilledTonalButtonmust passcolor = LocalContentColor.current. The theme typography istypo(), whose styles carry colours, andMaterialThemeprovidesbodyLargeasLocalTextStyle; a button only mergeslabelLarge(no colour) on top, so a bareTextkeepsonSurfaceVariantinstead of the button's content colour — grey on the filled Share button, grey instead ofprimaryon Regenerate. - The AI client now waits 2 minutes for a byte back (
AiService.REQUEST_TIMEOUT,Timeout(socket = 2.minutes), set on all three hosts, so translation gets it too). The library default is 30 s, and the taste prompt through a custom OpenAI-compatible router ran past it with "Socket timeout has expired" while the provider was still working.
-
Listening receipt on Analytics (2026-10-04): a Receiptify-style receipt of the visible period's ten most played songs, printed on scanned paper and shared as a PNG. A glass
ReceiptLongbutton sits left of the day-range pill (both orientations), shown only once the period's top tracks and stats have loaded; it opensShareReceiptSheet(ui/screen/home/analytics/ListeningReceipt.kt). No AI, no new query, nothing incore: every figure comes from theAnalyticsUiStatethe screen already holds.- The arithmetic is the owner's real Receiptify print, not Receiptify's Spotify mode: QTY is the play count (not the rank), AMT the song's length, ITEM COUNT the sum of QTY, TOTAL the sum of QTY × AMT as
h:mm:ss. ORDER # is the whole period's play count. A song with no stored length prints--:--and adds nothing. - The period line is the range's own name at the present and the navigator's span once stepped back — "Last 30 days" on a past period would name the wrong thirty. The date is the day it is printed, spelled out through string resources (seven new
weekday_*strings besidemonthFullName), since kotlinx-datetime has no localized names. Every word on the receipt is a string resource, English as source. - The name is the DataStore
AccountNamekey Home already reads (sign-in, account switch, sign-out and Login Sync all write it); blank means signed out, and the receipt then drops "FOR …" and the CARDHOLDER line. - Barcode and AUTH CODE are FNV-1a over the ten titles feeding xorshift32, so the same list always prints the same marks.
- Paper is ambientCG Paper003 (CC0) with lighting baked from its normal map into one 38 KB grayscale JPEG (
drawable/receipt_paper.jpg; the owner capped the asset at ~200 KB after a 2.5 MB download). It is drawn at the receipt's width and repeated down its length insideclipRect, so a long receipt gets more paper, never stretched paper. Type is Space Mono Regular (font/space_mono_regular.ttf, OFL — the licence rides in the font's own name table, IDs 13/14): Receiptify's Anonymous Pro has no Vietnamese. Titles in other scripts fall back to the system font. The logo's bold is synthesized frompoppins_mediumlike every bold in the app —fontFamily()declares only that file, andpoppins_bold.ttfis bundled but unused. - The main entry is a banner, stacked above Wrapped's (
ReceiptEntryCard): a button alone hid the feature. Both banners now wear one shell,EntryCardinWrappedEntryCard.kt(picture slot, two lines, primary disc), and share ONE list item, so they sit 12dp apart instead of the list's 32dp section gap. The receipt banner's picture is the real receipt for the period —ListeningReceiptPaperlaid out at its full 340dp, then scaled to 0.17 from its top edge inside a dark slot — and is hidden from accessibility, since read out it would be the whole receipt. ShareImageSheet(ui/component/) is now the one share-an-image sheet: header, scrolling gradient, capture, storage permission, Save/Share pills and toasts.ShareTasteSheetpasses its palette through thebelowImageslot; the receipt passes nothing. The receipt's shadow is applied outside the captured node, so it belongs to the sheet and not to the PNG.
- The arithmetic is the owner's real Receiptify print, not Receiptify's Spotify mode: QTY is the play count (not the rank), AMT the song's length, ITEM COUNT the sum of QTY, TOTAL the sum of QTY × AMT as
-
Queue rows open the full song sheet, and queue edits respect shuffle (2026-10-04): a row's ⋯ in every queue — the Classic/M3E sheet (
QueueBottomSheet, opened from Now Playing, the fullscreen lyrics page and the Desktop capsule) and the Apple Music queue tab — now opensNowPlayingBottomSheetfor that track withqueuePositionset, which adds Move up, Move down, Move to play next and Delete from queue above the usual rows and drops Play next and Add to queue — the track is already in the queue.QueueItemBottomSheet(those three rows alone) is gone.QueueBottomSheethosts the sheet itself and so takes anavControllerplusonNavigateToOtherScreen(MiniPlayergained an optionalnavController, passed byApp.kt); the Apple Music queue goes throughNowPlayingContentActions.onQueueItemMoreto the player screen. The queue rows act only if their track is still at the position the sheet was opened for, as before.- Queue positions are SHUFFLED positions while shuffle is on, and three operations ignored that.
reorderShuffledQueuekeepslistTracksin the shuffled play order, and tapping a row already translated it (getUnshuffledIndexinplayMediaItemInMediaSource), but move up/down and delete handed the shown position straight to the player as a playlist index — touching a different track than the one picked. Removal now translates the position the same way; moves go to the newMediaPlayerInterface.moveShuffledItem(from, to), which reorders only the adapter's ownshuffleOrder(default no-op; implemented inCrossfadeExoPlayerAdapterandMpvPlayerAdapter). Turning shuffle off still restores the original order. The Androidswap(drag to reorder) takes the same branch; Desktop'sswapalready loops over move up/down. - Both adapters used to reshuffle the WHOLE order on every edit while shuffle was on —
moveMediaItemandremoveMediaItemboth ended increateShuffleOrder(). Removal now drops just that track fromshuffleOrder(removeFromShuffleOrder) BEFORE anything reads the order (precache, next track), and removing the playing track hands over to the next one in the shuffled order rather than the next playlist entry.moveMediaItemstill reshuffles, but the handlers no longer call it while shuffle is on;replaceMediaItemalso still does. MediaPlayerHandler.moveItemToPlayNext(position)targets the slot right aftercurrentOrderIndex():current + 1from ahead,currentfrom behind (the track leaving its slot shifts everything after it). Up/down/play-next share onemoveQueueItem, which updates queueData only if the track is still atfrom— the player's own timeline event rebuilds the queue in the moved order too, and on Desktop it can arrive first, from the player thread.
- Queue positions are SHUFFLED positions while shuffle is on, and three operations ignored that.
-
Calf removed; files are picked with each platform's own dialog (2026-10-04, issues #2546, #2559, #2580, #2593, #2595): Calf 0.14.0's desktop
rememberFilePickerLaunchercreated its native dialog in aLaunchedEffectthe moment the launcher was composed, so a failure there crashed any screen that merely held a launcher — Settings (backup restore, playlist import) and the local playlist ⋯ sheet (cover picker) — before anything was clicked. It failed three ways: Smart App Control refused the unsignedcalf_filepicker_native.dllthat Calf extracted to%USERPROFILE%\.cache\calf-filepickeron Windows (its loader triesSystem.loadLibraryfirst, butconveyor.confkeepsextract-native-librariesoff, so the package held no copy); its Linux.soneeded GLIBC 2.39; and ProGuard renamedjodd.net.MimeTypes, whosegetResourceAsStream(simpleName + ".properties")then missed ("Mime types file missing") on every OS — reproduced by loadingf.b.astraight from the shipped 2.2.0 jar. The dependency is gone. Desktop opens files with AWT'sFileDialog(showOpenFileDialoginFilePicker.jvm.kt) —GetOpenFileNameon Windows,NSOpenPanelon macOS, the GTK chooser on Linux (AWT draws its own where GTK is missing) — and Android with SAFOpenDocument, which is what restore used before Calf (bb1e7156).filePickerResult(onResultUri)hands back a content Uri on Android and a plain path on Desktop: exactly whatrestoreNativeopens, and what the newreadPickedFile(playlist import, besiderestoreNativein the SettingsViewModel files) opens.FilenameFilterdoes work in the WindowsFileDialog, whatever the javadoc says: OpenJDK's dialog hook handlesCDN_INCLUDEITEMand asksWFileDialogPeer.checkFilenameFilterabout each item (present in jdk21u). macOS (queryFilenameFilter) and GTK (filenameFilterCallback) ask too, so the cover picker filters images on all three.
-
Windows "Unable to rename settings.preferences_pb.tmp" crash traced to a DataStore race and fixed there (2026-10-04): Sentry SIMPMUSIC-DESKTOP-J logged ~26k fatal crashes on Windows from 2025-12 onward, on every release. The race lives inside DataStore 1.2.1.
FileStorageConnection.readScopeonlytryLocks, andDataStoreImplreads anyway when the lock is busy. A read that arrives mid-save goes to disk, because the cached version is behind betweenincrementAndGetVersion()and the cache update inside that save. That read opens the real file withFileInputStream, which the JDK opens withoutFILE_SHARE_DELETE(io_util_md.c). On Windows,Files.move(REPLACE_EXISTING)begins withDeleteFile(target)(WindowsFileCopy.move), so the save fails. DataStore swallows the real error and blames "multiple instances". The save had been launched onSERVICE_SCOPE, which has no handler, soCrashDialogended the process.createDataStoreInstance()on JVM now builds the store asPreferenceDataStoreFactory.create(storage = ReadsWaitForWritesStorage(FileStorage(PreferencesFileSerializer) {…})), which holds one Mutex acrossreadScopeandwriteScope. The file and its format are unchanged, so nothing migrates. The race cannot be reproduced off Windows. Confirm the fix by the SIMPMUSIC-DESKTOP-J and -12 rates on the next release: an outside program holding the file would still fail a save, and 1.2.1 hides which one did.- Bumping DataStore does not fix it.
650ccfb4d(in 1.3.0-alpha03 and later) only addsATOMIC_MOVEand stops swallowing the error. The unlockedFileInputStreamread is still on androidx-main, and b/203087070 carries a report of the crash on 1.3.0-alpha10. WhatATOMIC_MOVEwould buy is diagnosis and safety: it attaches the real Win32 error, and it removes 1.2.1's window in which the delete succeeds, the move fails and the settings file is lost. - The same race also crashes the reader. SIMPMUSIC-DESKTOP-12 logged ~300 crashes on 18 machines from 2025-12 onward, still on 2.2.0. A read that opens the file between the save's
DeleteFileandMoveFileExgetsFileNotFoundException("cannot find the file specified").FileReadScope.readDatathen checksfile.exists(). If that returns false, still inside the gap, the read silently returns empty preferences. If it returns true and the retry fails, DataStore throws "Inoperable file". The lock closes both. - Do not switch the read to NIO, even though NIO shares delete. On Windows without POSIX delete semantics, the delete goes pending and the move fails, and DataStore then deletes the
.tmp. The settings file is gone. - The trigger was ours.
skipSegmentsJobaskedsponsorBlockEnabled.first()on every progress tick, against the 5 s position save. That was 20 reads a second once 2.2.0 smoothed the published position (3–4 before), and ten times that with SponsorBlock on, becausegetSponsorBlockCategories()reads each of the nine categories. The check now readssponsorBlockCategories, aMutableStateFlowthat a collector ininitkeeps current from those settings (null while SponsorBlock is off or not loaded yet), so no tick touches DataStore. Android'sMediaServiceHandlerImplhad the identical per-tick read and got the identical change. The two handlers' SponsorBlock blocks must stay identical. - The lock is safe only because of how DataStore enters it. It enters the connection through
readData()andwriteScope, and neither runs inside the other. It also always takes its own coordinator lock before the wrapper's. Re-check both on any DataStore bump. The Desktop backup and restore (backupNative,restoreNative) still read and write the file directly, outside the lock.
- Bumping DataStore does not fix it.
-
SimpMusic for vivo, a separate artifact (2026-10-04, issues #2074, #2316): vivo's Origin Island and music widget only follow apps whose package name is on vivo's own list. They never look at the MediaSession, and a normal app cannot add itself to that list: it is a Settings key that only shell or root can write. The new module
vivoAndroidApptherefore builds androidApp unchanged undercom.spotify.music. Spotify is on the list on global OriginOS: r/Vivo users report the island follows Spotify, and they lend its package to apps that are not on the list.- It is a full Android app module with no code of its own.
sourceSets["main"]pointsmanifest,java,kotlinandresat androidApp. Bothjavaandkotlinare set, because androidApp keeps its Kotlin undersrc/main/java; a source set that missed it would build an APK with no app classes and no error. The build is arm64-v8a only with no ABI splits, Full only, and debug has no.devsuffix so it can be tried on a vivo phone. - Its
src/release/res/xml/shortcuts.xmloverrides androidApp's, andsrc/debugholds a copy. androidApp's copy hard-codestargetPackage="com.maxrave.simpmusic", which would open the wrong app, or nothing, from the vivo build. A debug build needs the override too, since it keepscom.spotify.music(no.devsuffix), so keep the two copies in step. - It passes the unofficial-build check only because both layers hold.
com.spotify.musicis inConfig.OFFICIAL_PACKAGE_NAMES, and the APK is signed with our release key, which the F-Droid key check accepts. A user who renames the app with apktool or ReVanced is still blocked: changing the manifest invalidates the signature, so those tools re-sign with their own key. - Building it: run
./build_and_sign_apk.sh --release --full --vivo. The--vivoflag switches the module and output directory and names the fileSimpMusic-full-vivo-….apk. CI jobbuild-vivo-releaseinandroid-release.ymluploads it asapp-vivo-release. - Limits.
- It cannot be installed next to the real Spotify, and its data is separate from the regular app's.
- The in-app update link still opens the regular download page.
- The Listen Together user agent reports
com.spotify.music, becauseAppIdentitytakes the realapplicationId. - The rolled-back alternative (a Shizuku switch that wrote vivo's list from the regular app) needed Developer options, which users rejected.
- It is a full Android app module with no code of its own.
-
Windows keeps the native title bar (2026-10-06): the custom title bar (undecorated + transparent window, 12dp corners, the 40dp
CustomTitleBar) is now macOS-only.DesktopApp.ktgates it onnativeTitleBar = !isMacOS, the same path Linux has taken since 2026-09-22. That turned the Windows VM probe into dead code, so it is gone: it ran PowerShellGet-CimInstance(thenwmic) insideremember {}during the first composition, so every Windows launch waited for it before the window appeared. The-Dcompose.window.no-transparentoverride went with it, since all it did was force Windows onto the native bar.getScreenSizeInfo()subtracts nothing there, because native decorations sit outsidecontainerSize. The bar's colour is Windows' own: nothing tells DWM about the app's theme. -
App log on disk, read back in Settings → Developer option (2026-10-06): every
Logger.i/w/eis also written to files by Kermit'skermit-ioRollingFileLogWriter(new dependency, same 2.2.0 as Kermit).Logger.enableFileLogging(dir)incore/commonadds two writers:app.logtakes Info and above (1 MB per file, 3 files),error.logtakes Error only with its stack trace (512 KB, 2 files). Debug never reaches the files, only Logcat/console. Nothing is kept in RAM; the writer runs on its own thread, so callers never wait on the disk. Android enables it first thing inSimpMusicApplication.onCreate(files/logs/), Desktop right after the single-instance guard (~/.simpmusic/logs/), since a second instance must not open the same files. The screen (ui/screen/other/AppLogScreen.kt+AppLogFiles.kt) follows the filestail -f-style only while it is open: tab All with Info/Warn/Error chips (lowest level shown), tab Errors with cards and their stack traces, search, pause, copy on tap, and an export that joins every file into one.txt, redacts cookies, tokens and Authorization, and hands it toshareImage(which gained amimeTypeparameter, PNG by default).- Two formats are coupled on purpose.
Logger'sLogLineFormatterwritesL/Tag: messageafter the writer's ISO timestamp, andAppLogFiles.kt'sHEAD_LINEregex parses exactly that; a stack trace is every following line without a timestamp.Logger.logFiles()mirrorsRollingFileLogWriter's own naming (<name>.loglive,<name>-N.logolder). Change either side and the screen silently shows nothing. - Clear hides, it does not delete. The writer keeps the live file open and only reopens it on a roll, so deleting it would make every later line vanish into an unlinked file until the next roll.
kermit-io2.2.0 was built against kotlinx-io 0.8 and kotlinx-datetime 0.7, while the app resolves 0.9.1 and 0.8.0. Every call it makes into both was checked in the bytecode and still exists, so the mismatch cannot surface as a runtimeNoSuchMethodError. Re-check on a Kermit, Ktor or datetime bump.- Crashes (uncaught exceptions) do not pass through
Logger, so they are not inerror.logyet. - Warn is not a debug level. Since the files exist every
Logger.wlands on disk, and the codebase had usedwas its debug level (426 calls against 53i): five minutes of playback rolled the whole 3 MB, 80% of it three dumps (a Home shelf and the queue twice, ~70 KB a line). Diagnostics go toLogger.d, andBaseViewModel.log()now defaults to DEBUG. Never log a whole entity, response or list; log ids, counts and status codes. Two things never go into any log: aGoogleAccountEntity(itscache/netscapeCookieare the session) and a googlevideo URL (it carries the listener's IP). - Sign-in events all use the tag
Auth, so typing "Auth" into the screen's search shows them: the Spotify/Discord/Last.fm DataStore setters (saved/cleared),SettingsViewModel.addAccount/setUsedAccountandLoginSyncStorefor YouTube, 401/403 from the Ytmusic and Spotify clients (HttpResponseValidator, host and path only),SpotifyAuth.refreshToken(anisAnonymoustoken means sp_dc has expired), Last.fm code 9 and the Discord gateway's 4004. Discord's own tag stays muted: it prints the identify payload, which holds the token. - A log inside a settings collector fires every few seconds.
settingsDataStore.data.map {}flows have nodistinctUntilChanged(), so every DataStore write (the playback position is saved every ~5 s) re-runs every collector. That is how the AI key reached the disk 94 times in five minutes.
- Two formats are coupled on purpose.
-
Coil images use a 24-hour TTL, then network with forced stale-cache fallback (2026-10-06): the main Android and Desktop singleton loaders register
utils/NetworkFirstInterceptorfromcomposeApp/commonMain. HTTP(S) requests first read RAM/disk with network disabled: an image younger than 24 hours (86,400,000 ms) is returned without an Internet request. At exactly 24 hours, or on a cache miss, the loader attempts a network refresh. Success updates the image and its age; failure returns the previously decoded cached image regardless of TTL. Expiry never deletes an entry, and neither a cache hit nor a failed refresh renews its TTL. Local files/resources and explicitly cache-only requests keep Coil's original behaviour. Expired-cache refresh is not stale-while-revalidate: it must fail or time out before the stale image is displayed.- Age survives a restart. Disk entries already contain
NetworkResponse.responseMillis; the interceptor reads it throughCacheNetworkResponse.readFromand carries that same timestamp in the RAM value'ssimpmusic#image_response_millisextra. Warming RAM from disk must not replace that timestamp with the current time. Unknown/invalid timestamps are stale but remain usable for fallback.CacheNetworkResponseis an internal Coil API: re-check its metadata reader on a Coil bump. The injected clock andttlMillis = 0keep tests deterministic without sleeps. - Failed responses must not overwrite the fallback. Coil 3.6.3 writes responses before decoding and its default strategy caches some failure codes, including 404. The interceptor downloads under a unique temporary key in the SAME
image_cache, then copies the encoded bytes and metadata throughDiskCache.Editorto the original key only after decoding succeeds. It repairs the RAM entry's source key and removes the temporary entry infinally, including cancellation. Requests for the same disk key are serialized, with the lock removed after the last user. A cache-write failure does not hide a successfully downloaded image. - The directory (
SYSTEM_TEMPORARY_DIRECTORY/image_cache), 512 MiB limit, original cache keys and image quality are unchanged. There is no new cover store, database field, dependency or download format. Widget/notification/download thumbnail code that constructs a separateImageLoader(context)is deliberately unchanged. Existing entries remain available for offline fallback; this does not pin them against ordinary LRU eviction or manual cache clearing. A process killed mid-request can leave a staging entry until normal cache eviction. composeApp/src/jvmTest/.../NetworkFirstInterceptorTest.kthas 14 cases against real Coil memory/disk caches and its network fetcher with an in-process fake client/decoder: fresh RAM/disk hits skip the network, disk-to-RAM warming preserves age, exact 24-hour expiry refreshes and starts a new TTL, failed refresh forces expired cache without renewing TTL, plus HTTP/decode errors, cache miss, custom keys, local files, cache-only requests and cancellation. Tests were added but not run by the agent (no-auto-build rule); generic kotlin-lsp diagnostics are not a KMP compile gate.
- Age survives a restart. Disk entries already contain
-
Your library cards redesigned (2026-10-07): the four pastel tiles at the top of the Your library tab (hardcoded
Color(0xff…)fills, black text, blind to the theme) became one wide Favorite card plus Followed / Most played / Downloaded cards, each fanning the artwork of its newest items (SleeveFaninLibraryTilingBox.kt: newest drawn last so it lands on top). An empty collection draws dashed empty sleeves in the same slots, so a new user sees the shapes waiting to be filled; a null overview (first read not landed) draws titles only, so a full library never flashes as empty.- Data is
CommonRepository.getLibraryOverview(): COUNT + newest-thumbnail DAO Flows for liked songs, downloaded songs and followed artists, plus the existing most-played Flow (alreadyLIMIT 50, so its size is the count). All are Flows, so a like, follow or finished download updates the card at once. - The card container is
libraryCardColor()(formerly TasteCard's privatetasteCardColor(), white on a light Desktop); the taste card went to the same 20dp radius and lost its elevation so the tab reads as one flat family. - Rejected by the owner on the way, do not bring back: Apple Music's category list ("ko phải library đẹp"), an icon inside a tinted rounded square ("AI slop"), and 2×2 cover mosaics (YouTube Music's style).
- Data is
-
Home: Your daily discover hero, "Play all" and hover play buttons (2026-10-07): three Home changes, all driven by what YouTube's Home response actually declares (measured on a signed-in and an anonymous Home).
- What YouTube declares about each shelf.
numItemsPerColumn: "4"appears only on song-list shelves (Quick picks, Heard in Shorts, Trending songs for you), anditemSizeisCOLLECTION_STYLE_ITEM_SIZE_MEDIUMon every shelf, so it carries no information. The header button is "More" when it holds abrowseEndpointand "Play all" when it holds awatchEndpoint. The Play all endpoint hasvideoId+paramsand noplaylistId, and/nextturns it into a finiteTLGG…queue with no continuation.HomeParserused to read only the browse form, so Play all was dropped. These now land onHomeItem.itemsPerColumnandHomeItem.playAllEndpoint, and every shelf header (HomeSectionHeader, plus Quick picks') shows Play all as a text button, the same as More. - "Your daily discover" is ArchiveTune's hero carousel (
HomeDiscoverSectioninHomeSectionStyles.kt, M3HorizontalCenteredHeroCarousel, which the pinned material3 ships). It applies to any card shelf of songs only that has no More page and noitemsPerColumn.- It shows one large card with narrow neighbours, the title on the art over a
colorScheme.scrimgradient inForceDarkContent. Tapping a neighbour brings it to the middle, and tapping the middle card plays its radio. - The large card targets a FIXED 360dp (
HERO_TARGET_WIDTH, capped at 0.74 of the row on phones) and the carousel is 320dp tall (2026-10-22). Material'sheroKeylineListpicks how many large items fit by comparing the row against that target, so a share of the row (the old 0.6 / 0.74) gave the same count on every window width; a fixed target makes a wider window show more large cards. - "Mixed for you" deliberately stays on square cards: mix covers print their own title ("My Mix 06", "Supermix") along the bottom, so a hero crops it and an overlay duplicates it.
- It shows one large card with narrow neighbours, the title on the art over a
- Spotify-style hover on Home cards (
rememberCardHover+HoverPlayButtoninAdapterItems.kt): pointer hover or keyboard focus tints the card withonSurfaceand raises aprimary/onPrimaryplay button onto the artwork. Phones never hover, so they are unchanged.- Songs and videos play exactly as a tap does.
- Playlists and albums play
Content.playEndpointthroughBaseViewModel.playFromEndpoint(anRD…id is queued as a radio, anything else as a playlist). That endpoint is the card's own play target, read offthumbnailOverlay…musicPlayButtonRenderer.playNavigationEndpoint: songs getRDAMVM…, playlistsPL…, albumsOLAK5u…, and artists none. - App-built new-release albums carry their
OLAK…id. A playlist card without a target plays by its id.
- Do not copy how Vivi and ArchiveTune pick their special sections. Both match the section title text ("cover" and "remix", a translated "community"). That breaks per language; our own Quick picks title match already fails in French ("Sélection rapide" vs "Sélection Rapide").
- What YouTube declares about each shelf.
-
Linux slice: libbz2 bundled, the AppImage excludelist left to the host (2026-10-08, issue #2462):
scripts/mpv-linux/stage.shlistedlibbz2.so.1.0inSYSTEM_LIBS, but that soname exists only on Debian/Ubuntu (Fedora/RHEL shiplibbz2.so.1), so libmpv could not load there and every song timed out. It is now bundled. In the other direction, the 11 AppImage community excludelist libraries this closure pulls in (fontconfig, freetype, harfbuzz, expat, fribidi, X11, X11-xcb, xcb, asound, gpg-error, uuid), plus Xau and Xdmcp which only libxcb needs, are now left to the host. libmpv loads at startup, before the Compose window exists, and glibc reuses an already-loaded soname even underRTLD_LOCAL, so a bundled copy of any of them replaces the host's for the whole process: on Fedora 44, once libmpv could load (a user symlinkedlibbz2.so.1.0), Skia drew a black window.- Rebuilt and re-pinned on 2026-10-10. The new tarball has 38 libs instead of 50 (SHA-256
f583c295…24c4), and it replaced theabcasset. CI only downloads the tarball pinned inmpvNativesChecksums, so a change tostage.shreaches users only after that rebuild, upload and re-pin. Docker 29 (containerd image store) resolves a plainFROM ubuntu:22.04to the host's arm64 image even afterdocker pull --platform linux/amd64, so on Apple Silicon build from a copy of the context whoseFROMnames the amd64 manifest digest, then check thate_machine == 62.
- Rebuilt and re-pinned on 2026-10-10. The new tarball has 38 libs instead of 50 (SHA-256
-
Launch banners from the remote config (2026-10-10): Home can show image-only promo banners, at most one per launch, defined by a
promosarray in the sameremote-config.jsonthat already carries the TIDAL credentials. Each entry isid(required: names the banner),image(language code → portrait banner URL; a missing or blank language falls back toen, and the code is the BCP 47 tag's first part, since Android'sLocale.languagestill saysin/iwfor Indonesian/Hebrew),link(asimpmusic://deep link, routed through the sameApp.ktrouter as web and widget links, or anhttps://page opened withopenUrl; an entry with any other link is skipped, since a tap could not act on it — one check,isAppLinkAvailable(link, …)inutils/AppLinks.kt), and optionalminVersion/requires(onlylocal_trackingis understood; an unknown requirement hides the entry from that build). Entries decode one by one, so one written with a wrong type drops alone — but a JSON syntax error anywhere in the file still fails the whole file, TIDAL included. Model incore/domain/.../model/promo/Promo.kt, selection inutils/PromoSelection.kt(pure, tested injvmTest), UI inui/component/PromoBanner.kt: the picture fills a width capped at 320dp in portrait and 360dp in landscape (less only when a 9:16 picture would pass 80% of the window height), and its own shape sets the height — no fixed frame; the owner rejected the old 9:16 box as inconsistent. It has no placeholder: the square holder made the frame square around a portrait picture on Desktop; tap to open the link, and only the Wrapped glass X closes it — the owner asked that a tap outside and Back do nothing.- Each banner once per app version, one per launch, in the config's order.
promo_shownholds the app version on its first line and the ids shown in that version after it, so a new version starts the line over. A launch takes the first eligible banner not shown yet; closing or tapping it counts, stepping aside for anything does not, so a later launch offers it again. - The picture is just an
AsyncImagein the banner's dialog — Coil loads it like any other image (disk cache first, the network otherwise, through the 24-hour TTL interceptor). There is no prefetch and no cache-only check: an earlier version prefetched pictures and refused any not already on disk, a leftover from when the banner was decided the moment Home opened, and the owner rejected it ("coil giải quyết"). A picture that fails to load closes the banner without counting it, so a later launch tries again; offline with the picture never loaded, that means the dialog opens and closes at once.CommonRepositoryImplstores the rawpromosblock in DataStore after each config fetch ("[]"when the block is gone, which ends a campaign), and the decision reads whatever block is saved when its turn comes — often this launch's own fetch. - It waits its turn and never shares the screen. Once per launch, Home waits for its open counter, 1 s for its own launch dialogs (review, share lyrics, blog, Kotlin Footguns) to settle, then for the update check started at launch, however long it takes (
checkForUpdate()raisesisCheckingUpdatebefore its coroutine starts and always lowers it on completion, so the flag can neither be missed nor stick). The decided banner draws only after 400 ms in which nothing has blocked it: another dialog (Home's own, the log-in alert, and everythingApp.ktraises — update dialog, both notification-permission prompts, the sleep timer — folded intoLocalAppDialogOpen), Now Playing (LocalNowPlayingOpen), or Home not being in front — RESUMED on Android (not mid-transition to another tab, not behind a system prompt), merely STARTED on Desktop (window showing): a Desktop window opened from a terminal or an IDE may start without focus, and the click that focuses it would count as the user starting, so a RESUMED gate there would never let the banner through. The 400 ms belongs to the banner (promoClearForholds the banner it cleared), so it always starts at the decision: nothing left over from before the banner arrived can let it through early, and the update dialog, raised a frame after its check ends, gets there first. - Never once the user is busy.
App.ktsetsLocalUserStartedon the first press or wheel scroll anywhere in itsScaffold(watched inPointerEventPass.Initial, nothing consumed — mini player and nav bar included; dialogs and sheets are windows or layers of their own, so closing one does not count) or when Now Playing opens. Before the banner's turn, that puts it off to a later launch, uncounted; so does leaving Home before the decision finishes. The "decided" flag isrememberSaveable(a rotation is not a new launch) and is set at the START of the attempt: a value written while Home is being left is never saved. - The update dialog no longer offers a release this build is already at or ahead of (
VersionManager.isBehind→isNewerRelease): a tag is offered unless it names this very version (leadingvstripped) or this build's numbers are higher. It used to compare tags for inequality, which told a dev or beta build to "update" down to the latest release, and told every F-Droid-channel user to update on every launch: that channel's tag has no leadingv. Numbers are compared part by part with any-suffixdropped, so a hotfix tag (v1.0.1-hf, a pattern this repo has shipped) has the same numbers as the release it fixes and is still offered by its different name, and a final release is offered over its-beta. - Dev builds read
remote-config-dev.json(AppIdentity.isDevBuild:BuildConfig.DEBUGon Android,-Dsimpmusic.dev=trueset by desktopApp's JavaExec tasks on Desktop) and keep the promo block and the shown list under_devkeys, so testing on a Desktop that also has the release installed leaves the release's banners untouched. The TIDAL keys are NOT split — a Desktop dev run writes the same DataStore as the installed release — so the dev file must carry the same TIDAL values as the release file. - Links the banners use:
simpmusic://analytics(the receipt),simpmusic://library?tab=<chip>, where<chip>isLibraryChipType.toStringValue()(wrappedfor Wrapped), andsimpmusic://taste(the AI taste card: Library on Your library, given a link of its own so the code can tell that the card needs local tracking, whichlibrary?tab=your_librarycannot say). The tab travels asSharedViewModel.requestLibraryTab(), whichLibraryScreenapplies and clears;LibraryViewModelno longer lets its saved chip, read asynchronously at startup, overwrite one picked meanwhile. Whether a link's screen exists for this user is decided in ONE place,utils/AppLinks.ktisAppLinkAvailable(), used by both the router and banner selection so they cannot disagree: Analytics, the Wrapped chip andtasteneed local tracking, the YouTube playlists chip needs a YouTube sign-in, and a path the router does not open (APP_LINK_PATHS, to be kept in step with the router'swhen) is never available. The router toasts "not supported" for an unavailable link; a banner pointing at one is simply not shown — so the config needs norequiresfor any of this (requiresremains for conditions a link cannot express). The router reads both settings BEFOREsetIntent(null), because clearing the intent changes the routing effect's key and the next frame cancels the effect wherever it is suspended, which would drop the navigation after the banner was already counted. These links push the destination like the widget'ssimpmusic://libraryalways has, so the tab bar still highlights Home after one (the bars move their highlight only on a tap). Syncing the bars fromcurrentBackStackEntrywas tried and reverted on 2026-10-10: on the owner's Nothing phone the Home tab stopped responding, cause not pinned down — find it before trying again. - Logs: every step writes under the tag
Promoat Info (the decision and why nothing was picked, put off because the user started, on screen, opened, closed, picture failed), so the Developer option log screen answers "why no banner" — the usual answer beingpromo_shownalready listing the banner for this version.
- Each banner once per app version, one per launch, in the config's order.
-
Add to queue is disabled where the queue has no end (2026-10-10, FR #1469 group): Add to queue appends to the END of the queue, and Play next goes right after the current song. Two kinds of queue never reach an end: a radio or mix, and any queue while Endless queue is on. In those, Add to queue stays visible but disabled, with the reason in brackets, the same way Playback speed & pitch is disabled during crossfade. Play next is never blocked.
- One rule, in
core/domain:QueueData?.addToQueueBlock(endlessQueue)(MediaPlayerHandler.kt) returnsRADIO,ENDLESS_QUEUEor null.RADIOwins, since turning Endless queue off would not lift that block. Radios and mixes are recognised by the id alone, throughString.isEndlessQueueId()(RD…minus curatedRDCLAK…,VLstripped), whichRadioQueueTrim.appliesTonow shares. The type is not checked because a mix opened from its playlist page is queued asPLAYLIST. - Every Add to queue button reads it through
rememberAddToQueueBlock()andaddToQueueLabel()inModalBottomSheet.kt: the song menu, the multi-select sheet, the playlist/album sheet and the local playlist sheet. A new Add to queue entry point must use the same pair. - The swipe on a song row goes through
SharedViewModel.addListToQueue, which plays a single song next whenever Add to queue is blocked (it used to do so only under Endless queue). Its icon switches to Play next while that applies. - The owner decided against a Spotify-style Add to queue that plays before the rest of the queue, against a two-way swipe, and against a setting for the default action. Report those requests as decided, not as missing work.
- One rule, in
-
FOSS builds no longer get Sentry bytecode injected (2026-10-11): the Sentry Android Gradle plugin runs on androidApp in BOTH builds, and its tracing instrumentation rewrites classes at build time, dependencies included — it inserts
AppStartMetrics.onContentProviderCreate/PostCreateinto everyContentProvider.onCreate,androidx.startup.InitializationProvideramong them. FOSS shipscrashlytics-empty, so those calls name classes its APK does not contain: after the 2026-10-10 bump (AGP 9.2.1 → 9.4.1, plugin 6.22.0 → 6.23.0) the FOSS build crashed at launch withNoClassDefFoundError: Failed resolution of: Lio/sentry/android/core/performance/AppStartMetrics;fromInitializationProvider.onCreate. The FOSS branch of thesentry {}block now setstracingInstrumentation.enabled.set(false), which keeps the plugin from registering its class transform at all (if (tracingInstrumentationEnabled)in itsAndroidComponentsConfig.kt).ignoredFlavors.set(setOf("foss"))in that block never excluded anything: the project has no product flavors — Full/FOSS is theisFullBuildproperty — so the plugin has always run on the FOSS release variant.- An un-obfuscated Sentry class name in a release stack trace means the class is not in the APK.
-
Right-to-left languages (2026-10-14, Hebrew/Arabic/Persian): an audit on a Hebrew device found three separate causes. The owner's rule behind all of them: in RTL only text flips, icons keep their shape and anything that shows which way time runs stays left-to-right.
- Hebrew moved folders. Android 15+ with targetSdk ≥ 35 and the Desktop JDK report Hebrew/Indonesian as
he/id(AOSPBaseLocale.USE_NEW_ISO_LOCALE_CODES); Android ≤ 14 still reportsiw/in; Compose Resources match the folder name exactly. Crowdin exportshe/id, sovalues-heandvalues-idare the live folders andvalues-iw/values-inserve old phones only — nothing updates them unless they are copied. - Playback and time controls stay left-to-right through
LeftToRight { }(extension/UIExt.kt, aLocalLayoutDirection.Ltrprovider): every player style's seek bar, times and transport (AppleMusicPlaybackControls,SpotifyPlaybackControls,ExpressivePlaybackControls,PlayerControlLayout), volume rows, the action rows under them, the three artwork pagers, the ±5 s video overlays, fullscreen lyrics/video, the mini player's progress, the Desktop capsule and mini-player window, the Analytics period navigator and the Wrapped reel. Before this, ⏩ sat on the left, every times row (TextAlign.Left/Righton weighted halves) collapsed onto its centre label, and M3E'sWavySeekBarseeked backwards: its touch math isx / widthwhile Material's indicator draws mirrored. A control of this kind added later needs the same wrapper. Text placed inside one (the Analytics date, Wrapped's cards) gets the reading direction provided back, or a right-to-left string comes out reordered. - Compose lays every paragraph out in the layout's direction by default:
TextStyle.resolveTextDirectionmapsTextDirection.UnspecifiedtoRtl, not to the content. Latin titles and URLs therefore lost their punctuation order (".release", "/https://…") and titles mixing Latin with YouTube's localized "(עם …)" came out scrambled and clipped.typo()now setsTextDirection.ContentplusTextAlign.Rightin RTL layouts only — Content alone would also move Latin text to the left edge, since Compose alignsStartby the paragraph, unlike a TextView's gravity. Lyrics are unaffected: every lyric line sets its own direction and alignment. - Smaller fixes:
MoodCategoryCarddrew its badge at a fixed right corner, on top of the title in RTL; the equalizer's curve is drawn left-to-right, so its dB scale and frequency labels are kept left-to-right with it. The GlanceTurntableWidgetcannot pin a direction, and the launcher lays it out in the SYSTEM's direction rather than the app's language, so it reverses its own order whenResources.getSystem()is RTL. - Compose Desktop goes right-to-left too:
GlobalLayoutDirectioncomes fromLocale.getDefault()through AWT'sComponentOrientation. - Left as is: marquee titles scroll the RTL way for Latin text (
basicMarqueefollows the layout, not the text) — the owner declined changing it. Still open: the back button sits on the right while its arrow keeps pointing left. R8 renames every class it has (the Full build turnsAppStartMetricsintoio.sentry.android.core.performance.g); a reference to a missing class keeps its original name.
- Hebrew moved folders. Android 15+ with targetSdk ≥ 35 and the Desktop JDK report Hebrew/Indonesian as
-
Dark theme is no longer AMOLED black (2026-10-15):
isAmoledis a MaterialKolor parameter, not an app setting, and it is gone from bothrememberDynamicColorSchemecalls inTheme.kt; the Wallpaper scheme (PlatformColorScheme.android.kt) also stopped pinningbackground/surfaceto black by hand. In a dark schemeisAmoledswaps exactly four roles —background/surfaceto#000,onBackground/onSurfaceto#FFF— so the dark page is now MaterialKolor's tone 6 (about#131313, tinted by the seed) andtypo()titles, which readonBackground, are tone 90 instead of pure white. Both calls change together: in the dark theme the immersive screens reuse the main scheme, so changing only one splits them by theme.- Never hardcode
Color.Blackas a page or bar colour; usecolorScheme.background. Four blacks only looked right because the page was black: the liquid-glass backdrop base inApp.kt, theSongSelectionTopAppBarfill on the library dynamic playlists (now withcontentColor = onSurface, since the bar defaults to white text), and the fill behind a loading chip inInfiniteBorderAnimationView. The cold-startwindowBackgroundis#131313, the neutral grey at tone 6. - The reverse bites too: a Material default sitting on a fixed-colour surface.
NavigationRaildefaults tosurface, which was black only under AMOLED, so on the pure-black Desktop window (desktopWindowDark) it turned into a seed-tinted grey strip. It is nowcontainerColor = Color.Transparentand shows the window on Desktop and the page on tablets. Anything placed on a surface with a fixed colour (the Desktop window, the player, the Podcast page) must not take a theme surface role, whether passed explicitly or inherited as a default. - Black that stays on purpose: scrims, video and canvas letterboxes, fades over artwork, glass darkening, and the surfaces that are black by design — the Now Playing sheet with its Classic and Apple Music pages, fullscreen lyrics, the Podcast page, and the Desktop window frame (
desktopWindowDark: the window takes the extreme, mirroring pure white in the light theme).
- Never hardcode
-
Live items are marked from the parser down, and downloads refuse them (2026-10-16): downloading a live broadcast used to "succeed" on Android with the HLS playlist text saved as the song (
getStream(isDownloading = true)returned the playlist URL), and failed on Desktop only by accident ("Audio format url is null"). Measured on live WEB_REMIX responses: YouTube puts the LIVE chip (liveBadgeRenderer) on search rows and playlist rows (musicResponsiveListItemRenderer.badges), on Home's live shelf (musicTwoRowItemRenderer.subtitleBadges) and on search's top card — and not on queue rows (next). Live rows carryMUSIC_VIDEO_TYPE_UGClike any upload, somusicVideoTypecannot tell them apart.- Parser:
isLiveonMusicResponsiveListItemRenderer,MusicTwoRowItemRendererandPlaylistPanelVideoRenderer, copied intoSongItem.isLive/VideoItem.isLiveat all 17 places those are built. Search's top card is not parsed: the app never callsSearchSummaryPage. - Models:
Content.isLiveis a field;TrackandSongEntitycarry it asresultType == LIVE_RESULT_TYPE("LIVE"), read through the extension propertiesTrack.isLive/SongEntity.isLiveinModelToEntity.kt. No migration:resultTypewas dead for songs (labels nothing reads, no query filters on it). NotvideoType: both handlers rewrite it from YouTube's value on every play, so a marker there lasts one play.SongsResult/VideosResult/ResultVideouse the same marker;ResultSonghas noresultType, so it got a plainisLive. Keep the extension properties off the entity class, where Room would have to decide whether a getter is a column. - The stored row:
insertSongis IGNORE, soLocalDataSource.insertSongraises the marker itself (markSongLive): on an existing row when the incoming one is live, and on any row whose streamLiveStreamRegistryalready knows is live — resolution (a precache, say) can run before the row exists, and an UPDATE on a missing row is lost. It raises only, because a source without the chip has not said the broadcast ended. Stream resolution is the one writer that also clears it (updateSongLiveStatus), so a finished broadcast downloads normally again.LiveStreamRegistrystill decides playback routing; the row is for the UI and the download gate. - UI: the shared
LiveBadge(ex-private inAdapterItems.kt) sits ahead of the artist inSongFullWidthItems(all 16 song-row call sites, both queue views included),QuickPicksItemandSuggestItems.SongFullWidthItemsORs the list model with the stored row it already observes, which is how a queue row gets the badge once the stream has resolved. - Download gate, three layers: the ⋯ sheet's Download row is disabled with its reason in brackets (
download_disabled_live); bothdownloadTrackimplementations refuse a row marked live and reset its state (every download path ends there); and resolution refuses what nothing had marked yet —getStream(isDownloading = true)emits no URL for a live stream,YouTube.downloadfails with "Live streams cannot be downloaded". - Legacy left as is: rows stored before this stay unmarked until a chip or a stream resolution marks them, and a playlist file already saved by an older build is not cleaned up.
- Parser:
-
"Cached" auto playlist in Library (2026-10-16, Android only): songs whose audio sits in the player cache in full and that are not downloaded — what plays offline without having been downloaded. A fourth small card beside Followed, Most played and Downloaded turns that row into a 2×2 grid on Android; Desktop keeps no song cache on disk (mpv streams) and keeps its single row of three.
CacheRepositorygainedgetFullyCachedSongIds()(audio keys only — the video stream is cached under aMERGING_DATA_TYPE.VIDEOkey — and only thoseisFullyCached, so a track skipped halfway is left out) andremoveCachedSong(); Desktop's stub returns nothing.- Read on demand, never observed: the player cache is no table, so both the card (
LibraryViewModel.refreshCachedPreview) and the list (LibraryDynamicPlaylistViewModel.getCachedSong) read it each time their screen is shown, through the sharedloadCachedSongs. A cached song whose row "clear listening history" swept has no metadata to show and drops out. - There is no duplicate copy to clean. A finished download already removes its player-cache copy (
playerCache.removeResource(id)onSTATE_COMPLETEDinDownloadUtils); the bytes are only written to both caches while the download runs, because the download reads through aCacheDataSourceon the player cache. Downloading from this list therefore costs no network, and the song moves to Downloaded by itself. - The ⋯ sheet takes
onRemoveFromCache, which adds a "Remove from cache" row only when the sheet is opened from this list.
- Read on demand, never observed: the player cache is no table, so both the card (
-
Smart downloads (2026-10-16, Android only, issue #2662): a Settings switch under "Download songs you like" keeps the songs played most downloaded, 25, 50 or 100 of them (default 50; the count row only shows while the switch is on). It needs local tracking to be switched on, and stays switchable off without it so the songs can still be cleared. A pass is asked for on every
MainActivity.onStartand when the switch or the count changes in Settings. It runs at most once a day (smart_downloads_last_runis stamped before the pass starts, and an in-flight pass blocks a second one) and only on an unmetered network (isOnUnmeteredNetwork(), expect/actual; Desktop answers false). Off such a network a Settings change resets the stamp to 0, so the next open on Wi-Fi runs it.- Never from
SharedViewModel.init. The three Glance widgets inject that ViewModel inprovideGlance, so it can be created with the app in the background, where the pass'sstartService(Media3sendAddDownload(foreground = false)) throws. There is no background job for the same reason: Android 12+ refuses a download service started from the background. - Score: every play of the last 30 days in
playback_eventaddsmin(1, listenedSecond / durationSecond)(1 when the length is 0) ×0.5^(ageDays / 14); ties go to the most recent play. Summed in Kotlin overSongPlaySamplerows whosetimestampisLocalDateTime, for the same reason asPlaybackSample. - The marker is a column that already existed: a smart song carries
song.category = SongEntity.SMART_DOWNLOAD, and a downloaded song without it is the user's and is never touched. This only holds because nothing readscategory(the mappers just copy it) and nothing rewrites a wholesongrow (insertSongis IGNORE). Readingcategoryfor something else, or adding a REPLACE/@Upsertonsong, silently breaks Smart downloads. - One pass, in order: drop the marker from marked songs no longer downloaded, and from liked songs while liked songs auto-download; rank; keep only rows YouTube confirms as songs (
MusicVideoType.isAudio) that are not live, so videos, podcasts and unknown types never qualify; mark anddownloadTrackthe top N not downloaded yet; then evict FIFO bydownloadedAt(in-flight downloads sort last, and a song in today's top N is never evicted) withremoveDownloadfirst and the unmark second, so a process death in between leaves a mark the next pass clears. Switching off removes every marked song; a liked one (with auto-download on) only loses the marker. - Album and playlist download states are deliberately not asked. Opening an album whose tracks are all downloaded marks the album downloaded (
AlbumViewModel's download-state collector;PlaylistViewModeldoes the same), so reading "in a downloaded album or playlist" as the user's claim took smart songs the user never claimed and kept them past N for good. - Nothing in a pass may crash the app. The pass and the clear catch everything but cancellation and log it, so a
startServicerefused after the app went to the background just ends the pass.isOnUnmeteredNetwork()reads aSecurityExceptionfromgetNetworkCapabilities(b/163342798, the same guard WorkManager'sNetworkStateTrackerhas) as off-Wi-Fi. The notification check falls back to notifying on a database error. - Smart songs post no per-song "downloaded" or "failed" notification:
MusicDownloadService.TerminalStateNotificationHelperchecks the marker first. The download service's progress notification still shows.
- Never from
-
YouTube comments in Now Playing (2026-10-17, issue #2666): a Comments button in every style, never squeezed between the buttons already there (Classic: left cluster, Info · Output · Comments; Apple Music: the last segment of the capsule, Lyrics · [Output | Listen Together | Comments] · Queue; M3 Expressive: the end cap of the connected group, after Queue) opens
CommentsBottomSheet(ui/component/, state inCommentsViewModel). It is aModalBottomSheetwithskipPartiallyExpanded = false: half open first, dragged to full height. Comments come in YouTube's own Top order (a Top/Newest switch shipped broken and was removed on 2026-10-20), load more as the list nears its end, replies with "Show more replies", and, signed in, post, reply, like and dislike. Signed out, every write opens the sheet's own log-in dialog, whose button goes toLoginDestination.- Source: YouTube Music's own Comments tab.
next(WEB_REMIX, videoId)answers tabs Up next | Lyrics | Comments | Related. Lyrics and Related carry apageType, Comments does not, and its title is translated ("Bình luận"), so it is found by type: the only tab holding asectionListRendererthat reloads fromreloadContinuationData(CommentsPageParser.commentsTabToken). Turned off, or a live stream with only its chat: the tab holds amessageRendererand isunselectable, with no token.next(WEB_REMIX, continuation)answers every comments token: first page, next page, replies. - A thread carries only keys. The comment and its toolbar arrive as
frameworkUpdatesmutations under those keys:commentEntityPayload(text, author,likeCountNotliked/likeCountLiked),engagementToolbarStateEntityPayload(likeState,heartState) andengagementToolbarSurfaceEntityPayload(the like/unlike/dislike/undislikeperformCommentActionEndpointactions and the reply command'screateReplyParams). Commands are read from raw JSON at any depth, because YouTube rewraps them. - Tokens: the next page is the top-level
continuationItemRenderer. A thread's replies token iscommentRepliesRenderer.subThreads[0]on YouTube Music,contents[0]on the WEB client. A replies page lists replies as threads on YouTube Music and as barecommentViewModels on WEB; the parser reads all four. - Writes (
Ytmusic.kt):comment/create_commentwith the header comment box'screateCommentParams,comment/create_comment_replywithcreateReplyParams,comment/perform_comment_actionwith one action token, all throughytClient(WEB_REMIX, setLogin = true). Signed out the toolbar carries aprepareAccountCommandinstead of commands and the comment box has no submit button; a signed-in account without a channel looks the same, and the sheet says to create one. - Known risk, not handled: comments posted through InnerTube by low-reputation accounts can be hidden from everyone else (YouTube.js #878) unless the BotGuard attestation the create response asks for (
RunAttestationCommand, thenatt/log) is run. - Comment text goes through
DescriptionView, which gained an optionalcolor(default unchanged): a timestamp seeks the track, only inside its length, and a link opens normally, as in a description. Hashtags, mentions and bold (commandRuns,styleRuns) stay plain text, as they do in descriptions. - M3 slides the whole sheet down when half open instead of shrinking its content, so a bottom-aligned comment box would sit below the screen. The box is lifted by
sheetState.requireOffset()insideModifier.offset { }(read at layout, no recomposition per frame), and focusing it expands the sheet. - The sheet takes its id off the media item, not
nowPlayingState.track.trackis looked up in the queue list once, when an item starts, and is read again only when the media id changes (onMediaItemTransitionskips a same-id transition, such as the seek a restore ends with). On the queue restored at startup it stays null for the whole first track:updateCatalogemptieslistTracks, refills it in chunks and puts the playing track back last, while the player announces that track in between (Desktop log: track announced at .790, queue whole at .981). Gating the sheet ontrack?.videoIdmade the button do nothing there and then opened the sheet by itself on the next track. A request made with nothing playing is dropped. Opening the sheet logsComments for <id>: <count>under the tagComments. - Parser test:
kotlinYtmusicScraper/src/commonTest/.../pages/CommentsPageParserTest.kt, synthetic JSON in the real shapes.
- Source: YouTube Music's own Comments tab.
-
Home header draws YouTube's own backdrop (2026-10-20): the Home response carries a top-level
background.musicThumbnailRenderer, one pre-blurred 1500×844 image that changes with the mood chip (signed out with no chip it was a dark aurora; signed in on "Relax", bright pool water).BrowseResponse.backgroundalready parsed it for Podcast pages.getHomeDatanow returns it as the third value of itsTriple, andHomeScreendraws it behind the first shelf in both themes. Continuations carry no image, sogetHomeDataContinuestays aPair.- The artwork tint stays underneath as the fallback. A response without the image, the load itself and a failed load all show the old 25° gradient from the first card's dominant colour. A failed load drops the image and its overlay (
onError), since the overlay alone would darken the fallback too. - A page-coloured overlay always sits on the image, 60% at the top left and rising to opaque along the tint's own 25° line. Some images are very bright, and fading with the bottom scrim alone left a flat horizontal band. The overlay takes
pageBackground, so it lightens in the light theme and darkens in the dark one with no branch.
- The artwork tint stays underneath as the fallback. A response without the image, the load itself and a failed load all show the old 25° gradient from the first card's dominant colour. A failed load drops the image and its overlay (
-
Create button on Home (2026-10-22, issue #2596): a "+" (
CreateActionsinui/component/CreateSheet.kt, after Spotify's Create) opens a sheet with Local playlist and Listen Together. Local playlist opensCreateLocalPlaylistSheet, the name sheet that used to be written inline inLibraryScreenand now also serves Library's create tile. On Home, Notifications moved into the avatar menu and Listen Together into the sheet, and the live-room dot (LiveRoomDot, shared withListenTogetherIconButton) moved onto the "+". With liquid glass on, "+" and Settings share one glass pill, like the like/search/more pill on the playlist screens. With glass off, "+" is a grey circle with a border and Settings stays a plain icon. The chips' end padding dropped from 148 to 100 dp (two end buttons). Library has no "+": only Home got one (an earlier "+" there was removed at the owner's request).- Add to queue on an empty queue starts playing it. That is the whole of #2596: no separate "new queue" action. A "Queue" entry in the Create sheet (an empty queue behind a placeholder mini player) was built and removed the same day as useless.
addToQueueBlockreturns null for an empty or null queue, so Endless queue cannot block the first Add to queue, andaddToQueueLabel()(now@Composable, reading the queue) labels the button "Create a new queue and add to play" there (create_new_queue_and_play), in every sheet that has it. - Appending into an emptied player must point it at item 0. Both adapters (
CrossfadeExoPlayerAdapter,MpvPlayerAdapter) keep their own playlist, andclearMediaItems()leaveslocalCurrentMediaItemIndexat -1.addMediaItemnever moved it, so the handler'sprepare()on the first appended item loaded nothing, and Add to queue into an empty queue did nothing on either platform.pointAtFirstItemIfUnset()in bothaddMediaItemoverloads gives ExoPlayer's own behaviour.
- Add to queue on an empty queue starts playing it. That is the whole of #2596: no separate "new queue" action. A "Queue" entry in the Create sheet (an empty queue behind a placeholder mini player) was built and removed the same day as useless.
-
Desktop sidebar replaces the rail (2026-10-22): on Desktop (
isDesktopShell) the window's left edge isDesktopSidebar(ui/component/DesktopSidebar.kt), a second roundeddesktopPanelbeside the content panel, after Spotify's Your Library. Tablets keepAppNavigationRail. Navigation (Home, Mix, Analytics, Search) sits on top; Library leaves the navigation because the "Your library" header IS the Library tab (sameselectTab, sameBottomNavScreen.Library.ordinal). Under it: Favorite / Downloaded / Most Played pinned (counts fromgetLibraryOverview()), then local playlists, saved playlists, saved albums, followed artists and favourite podcasts as one list (LibrarySidebarViewModel, five Room flowscombined), with chips per kind (a chip shows only while its kind has items), an inline search, and Recents / Alphabetical sort. It folds to 72dp (icons and covers only) from 280dp, persisted as the DataStore stringdesktop_sidebar_collapsedthrough the genericgetString/putString, so core was not touched. The playing row is matched by the queue'splaylistId(LOCAL_PLAYLIST_ID + idfor local playlists,browseIdfor albums,podcastIdfor podcasts); the pinned collections queue with a null id and are never marked. That makes FOUR components carrying the tab list (bottom bar, rail, glass bar, sidebar): a new tab goes into all four.- The capsule player lives in a Box around sidebar + content panel, not in the panel. It centres on everything left of Now Playing, and with the sidebar open and Now Playing showing the panel is narrower than the capsule (~770dp), so inside the panel it lost its right-hand buttons. Its glass source (
layerBackdrop(backdrop)) is recorded on the Row holding sidebar and panel for the same reason: recorded on the panel alone, the part of the capsule over the sidebar refracted nothing and flickered. Tablets keep the capsule and the source in the panel, beside the rail. - Now Playing's width is taken by hand:
(Row width − 80dp) × 0.35, read from the Row's own constraints in alayout {}, notfillMaxWidth(0.35f). In a Row a fractional fill takes its share of the width LEFT after the unweighted siblings, so it was always 35% of what the 80dpNavigationRailleft (80dp isNavigationRailItemWidth=NavigationRailCollapsedTokens.NarrowContainerWidthin the pinned material3; the 96dpContainerWidthtoken is not what the rail uses). Next to the sidebar it shrank to ~27% and resized whenever the sidebar folded; with the rail and sidebar moved into the weighted Box it would have grown to 35% of the whole Row. Desktop takes another 8dp off because its 8dp spacer is gone (the content panel's own padding is the gap), so the card keeps its exact size. Do not useLocalWindowInfo.containerSize: it is not the Row's width.
- The capsule player lives in a Box around sidebar + content panel, not in the panel. It centres on everything left of Now Playing, and with the sidebar open and Now Playing showing the panel is narrower than the capsule (~770dp), so inside the panel it lost its right-hand buttons. Its glass source (
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-10-22 Project version: Check latest release on GitHub Maintained by: maxrave-dev and contributors