Skip to content

Latest commit

 

History

History
1114 lines (954 loc) · 277 KB

CLAUDE.md

File metadata and controls

1114 lines (954 loc) · 277 KB

CLAUDE.md - SimpMusic Project Guide for AI Agents

🌐 Language Rule

Response language: Always respond in English.

📋 Project Overview

SimpMusic is a FOSS (Free and Open Source Software) YouTube Music client for Android and Desktop, built with Compose Multiplatform.

Main Purpose

  • 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)

Basic Information

  • Package name: com.maxrave.simpmusic
  • Primary language: Kotlin
  • UI Framework: Jetpack Compose / Compose Multiplatform
  • Architecture: Clean Architecture + MVVM
  • Build system: Gradle (Kotlin DSL)

🏗️ Architecture

Clean Architecture Layers

┌─────────────────────────────────────┐
│  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)              │
└─────────────────────────────────────┘

📁 Module Structure

Root Modules

1. composeApp/

  • 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 platforms
    • androidMain/: Android-specific code
    • desktopMain/: Desktop-specific code
  • Can run Desktop app directly from this module

2. androidApp/

  • Android-specific module to build Android app
  • Depends on composeApp as 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.music for vivo's Origin Island. It has no code of its own: its source set points at androidApp's sources, resources and manifest. Its build.gradle.kts mirrors androidApp's, so keep the two in step.

3. core/

Contains core modules organized by functionality:

core/common/
  • Shared utilities
  • Extension functions
  • Constants
  • Helper classes
core/domain/
  • Domain models
  • Use cases
  • Repository interfaces
  • Business logic rules
core/data/
  • Repository implementations
  • Data sources (Remote & Local)
  • Database schemas (Room)
  • Data mappers
core/media/
  • media3/: Media3 ExoPlayer integration (includes CrossfadeExoPlayerAdapter for DJ-style crossfade on Android, and audio/EqualizerAudioProcessor for 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
core/service/

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

4. crashlytics/ & crashlytics-empty/

  • crashlytics/: Full version with Sentry crash reporting
  • crashlytics-empty/: FOSS version without tracking

5. cast/ & cast-empty/

6. lastfm/ & lastfm-empty/

  • lastfm/: direct Last.fm scrobbling for the Full build. KMP (android + jvm + ios), package org.simpmusic.lastfm. Signs api_sig with okio's MD5; talks to ws.audioscrobbler.com/2.0/ over form-urlencoded POST
  • lastfm-empty/: FOSS no-op stub with the identical public API — isLastfmAvailable() returns false, which hides the whole settings block. A FOSS build ships no API secret, so it ships no Last.fm code either
  • Selected via isFullBuild in core/data/build.gradle.kts (playback hooks) and composeApp/build.gradle.kts (UI); credentials come from LASTFM_API_KEY/LASTFM_SECRET in local.properties via BuildKonfig, and are handed in with configLastfm(key, secret) at startup — the same shape as configCrashlytics(context, dsn)
  • Auth is Last.fm's web flow on every platform: open last.fm/api/auth/?api_key=X with no token, the user approves in their own browser, Last.fm redirects to the callback with ?token=, then auth.getSession. The app never sees a password. Do not switch to the desktop flow (auth.getToken first, 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 Conveyor url-schemes + WindowsProtocolRegistrar on Desktop; the login screen also accepts the callback URL pasted by hand, for hosts where no scheme handler exists

7. cast/ & cast-empty/

  • cast/: Google Cast support for the Full build (media3-cast + play-services-cast-framework, CastOptionsProvider, CastIconButton Compose wrapper for MediaRouteButton)
  • cast-empty/: FOSS no-op stub with identical public API (package org.simpmusic.cast), keeping GMS out of F-Droid builds
  • Selected via the isFullBuild Gradle property (same pattern as crashlytics) in core/media/media3/build.gradle.kts and composeApp/build.gradle.kts androidMain
  • Playback handoff lives in core/media/media3 (cast/CastHandoffManager.kt + cast/CastStreamResolver.kt): the session player is CastPlayer.Builder().setLocalPlayer(forwardingPlayer).build(); while remote, CrossfadeExoPlayerAdapter routes transport/getters to the receiver and pushes a resolved-URL queue window (googlevideo URLs resolved up-front via StreamRepository); crossfade/EQ/precache are force-disabled while casting

🛠️ Key Technologies

Android/Mobile

  • 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

Desktop

  • 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:mpvSetupAll into mpv-natives/<os>-<arch>/

Networking & APIs

  • 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/n challenges
  • Spotify Web API: Canvas and lyrics
  • OpenAI/Gemini API: AI features

Data & Storage

  • Room Database: Local persistence
  • DataStore: Preferences
  • Caching: Offline playback support

Third-party Integrations

  • SponsorBlock: Skip sponsors
  • ReturnYouTubeDislike: Vote information
  • LRCLIB: Lyrics provider
  • BetterLyrics: Additional lyrics provider (added in v1.0.4)
  • Sentry: Crash reporting (Full version only)

📝 Development Guidelines

Code Style

  • Kotlin coding conventions: Follow Kotlin official guidelines
  • Compose best practices: Single source of truth, unidirectional data flow
  • Clean Architecture: Strict layer separation, dependency rule

Module Dependencies

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.

Working with UI

  • Use Jetpack Compose for all new UI
  • Follow Material Design 3 guidelines
  • State management with StateFlow or State<T>
  • Side effects with LaunchedEffect, DisposableEffect

Working with Data

  • 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

Research Before Implementation (MANDATORY)

Before implementing code, researching code, or answering technical questions, the AI agent MUST follow this research workflow:

Step 1: Look up official documentation

  • 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

Step 2: Evaluate pros, cons, and alternatives

  • 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

Step 3: Study OSS best practices

  • 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.)

Step 4: Make a decision and justify

  • 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.

Verification After Code Changes

  • 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.

Testing

  • Unit tests for Domain layer (Use cases)
  • Repository tests with fake data sources
  • UI tests with Compose Testing

🎯 Common Tasks

1. Add New UI Feature

Location: composeApp/src/commonMain/kotlin/

  • Create Composable function in appropriate package
  • Use ViewModel for state management
  • Follow Material 3 design patterns

2. Add New API Endpoint

Location: core/service/kotlinYtmusicScraper/

  • Implement endpoint in corresponding service
  • Create data model for response
  • Map to domain model

3. Add New Database Entity

Location: core/data/src/main/java/.../database/

  • Define Entity with Room annotations
  • Create DAO interface
  • Update Database class
  • Create migration if needed

4. Add New Use Case

Location: core/domain/src/main/java/.../usecase/

  • Create use case class
  • Inject repository dependencies
  • Implement business logic
  • Return Result/Flow

5. Work with Media Playback

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

6. Add New Lyrics Provider

Location: core/service/lyricsService/

  • Implement lyrics fetcher interface
  • Add fallback logic
  • Handle synced/unsynced lyrics

7. AI Features

Location: core/service/aiService/

  • OpenAI integration
  • Gemini integration
  • AI lyrics translation
  • Song recommendations

8. Add a New Icon

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>.kt

Keep 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:

  1. package com.example.test → package com.maxrave.simpmusic.ui.icon
  2. public val <symbol_name>: ImageVector → val SimpIcons.<PascalName>: ImageVector
  3. Rename the backing field _<symbol_name> → _<PascalName>, and name = "<symbol_name>" → "<PascalName>"
  4. 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
  5. Replace the closing .build() with .buildSimpIcon() (in SimpIcons.kt): it is build() plus the fit into Material's 20×20 live area described in the traps below. SimpIconsFitTest fails on a file that still ends with .build()

Use it: SimpIcons.PlayArrow — plus a per-icon import, import com.maxrave.simpmusic.ui.icon.PlayArrow.

Traps that have already cost time here

  • Each icon needs its own import. val SimpIcons.X is an extension property, so importing the SimpIcons object alone does not bring it into scope. This is also what lets R8 drop unused icons — do not "simplify" it into a map or a when, that would ship all of them.
  • ImageVector is not a Painter. Icon/Image have overloads for both, but AsyncImage (placeholder/error), anything drawing inside a DrawScope, and custom composables typed Painter do not — wrap with rememberVectorPainter(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.xml and the holder*.png placeholders 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 like favorite_border and thumb_up_alt do still exist; person_add_alt_1 does 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 and ThumbsUpDown reach 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 under SimpIcons. Groups is the exception, named in KEEP_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 skips buildSimpIcon(), an icon outside KEEP_AS_DRAWN ends 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, Lyrics shrank to ×0.72 and read far smaller than QueueMusic. 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. IconButton clips its content to its shape (a circle by default); sized 24dp, that circle reaches 12dp from the centre, while a glyph filling the 20×20 square reaches 14.1 (comment 13.3). Classic's Comments button clips to 28dp with requiredSize(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.

9. Prepare a Release (changelogs)

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>.txt stays the short store text (F-Droid caps it at 500 characters) and is written by the owner. The full changelog lives in changelogs/.
  • 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 #N inside 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 --comment silently drops the comment.
  • vi-VN addresses the reader as "bạn".

📍 Important Files and Locations

Configuration

  • build.gradle.kts (root): Root build configuration
  • gradle/libs.versions.toml: Version catalog for dependencies
  • settings.gradle.kts: Module inclusion

Main Application

  • composeApp/src/commonMain/kotlin/: Shared Compose code
  • composeApp/src/androidMain/kotlin/: Android-specific code
  • composeApp/src/desktopMain/kotlin/: Desktop-specific code

Database

  • core/data/src/main/java/.../database/: Room database schemas
  • Migrations in Database class

Network

  • core/service/kotlinYtmusicScraper/: YouTube Music API
  • core/service/spotify/: Spotify API
  • core/service/ktorExt/: Ktor utilities

Resources

  • composeApp/src/commonMain/composeResources/: Shared resources
  • composeApp/src/androidMain/res/: Android resources
  • Crowdin integration for translations

🔧 Build Variants

Android

  • Full: With Sentry crash reporting (module: crashlytics)
  • FOSS: No tracking (module: crashlytics-empty)

Desktop

  • Windows: .msi installer
  • macOS: .dmg (ARM and x86-64)
  • Linux: .AppImage (DEB and RPM removed post-1.0.4)

🚨 Important Notes

Privacy & Data Collection

  • FOSS version: NO tracking
  • Full version: Only Sentry crash reporting
  • "Send back to Google" feature: Optional, only when user enables

Platform-specific Considerations

Android

  • Min SDK: Check androidApp/build.gradle.kts
  • Target SDK: Latest stable
  • Android Auto support
  • Background playback with MediaSession

Desktop

  • Required Dependencies:
    • libmpv: audio + video playback (bundled via mpvSetupAll; falls back to a system-wide libmpv when mpv-natives/ has not been staged)
  • 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 Intel libmpv itself does), and Conveyor rejects a lower LSMinimumSystemVersion. No mpv artifact covers both architectures below 15.
  • Features:
    • Deep link support (simpmusic:// and simpmusic.org)
    • Mini Player window (always-on-top, resizable, draggable)
    • Crash dialog
    • Custom title bar (macOS only; Windows and Linux keep the native title bar)
  • Limitations:
    • No offline playback

External APIs

  • 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

🎵 Media Playback Architecture

Desktop Player (libmpv — replaced VLCJ 2026-07-27)

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-verified
  • MpvPlayer.kt — one handle per media item; vo=libmpv + software render context
  • MpvVideoFrameSource.kt — mpv SW render API → immutable BufferedImage snapshots published via StateFlow, drawn by plain Compose Image (MpvVideoFrames in media-jvm-ui); replaced the SwingPanel-embedded MpvVideoSurfacePanel on 2026-08-01
  • MpvPlayerAdapter.kt — the MediaPlayerInterface implementation; separate YouTube audio/video URLs are merged into ONE source with an edl://...;!new_stream;... URL (mpv's equivalent of Android's MergingMediaSource)
  • Natives bundled per platform in mpv-natives/<os>-<arch>/, staged by mpvSetupAll (Linux slice is compiled from source — scripts/mpv-linux/)
  • Supports crossfade transition with dual-player approach

Crossfade Transition (Desktop)

  • 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

Android Player (Media3/ExoPlayer)

Crossfade & DJ-style Transition (added in v1.0.4)

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)
  • CrossfadeFilterAudioProcessor for audio processing
  • Edge cases: disabled for video, repeat one, last track

🤝 Contributing

Code of Conduct

See CODE_OF_CONDUCT.md

Pull Request Guidelines

  1. Fork and create branch from dev
  2. Follow coding conventions
  3. Test thoroughly before submitting
  4. Update documentation if needed
  5. PR title: Clear and descriptive
  6. PR description: Explain changes and reasoning

Translation

📚 References

Inspiration & Credits

  • InnerTune: YouTube Music data extraction inspiration
  • SmartTube: YouTube streaming URL extraction
  • SponsorBlock: Sponsor skip functionality
  • LRCLIB: Lyrics provider

External Documentation

Community


🎯 Quick Start for AI Agents

When working with this project:

  1. Always check layer dependencies: Don't violate Clean Architecture rules
  2. Use existing patterns: Review current code to follow established patterns
  3. Platform-aware: Code in commonMain must work for both Android and Desktop
  4. Test thoroughly: Especially critical for media playback and network code
  5. Consider privacy: FOSS version must NOT have tracking
  6. Check external API stability: YouTube Music API may change at any time

When Encountering Issues

  • 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

Platform-Specific Code Patterns

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
}

📜 Changelog Summary (post-1.0.4)

Architecture Changes

  • Desktop: GStreamer → VLCJ: Completely replaced GStreamer with VLCJ for desktop audio playback
  • DEB/RPM builds removed: Desktop Linux now only ships AppImage

New Features (v1.0.4)

  • Android Crossfade & DJ-style transition: CrossfadeExoPlayerAdapter with 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

New Features (v1.0.1 - v1.0.3)

  • 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

New Features (post-1.0.4, dev branch)

  • Icons unified on Material Symbols (2026-08-03): material-icons-core/material-icons-extended are gone, and so are the XML icon drawables — every icon is now a generated ImageVector under ui/icon/, addressed as SimpIcons.<Name>. Two migrations fed into this: 59 icons replacing Icons.* (117 call sites), then 25 more replacing painterResource(Res.drawable.baseline_*) (167 call sites, 44 XML files deleted). RippleIconButton, LiquidGlassIconButton and ActionButton changed from taking DrawableResource/Painter to ImageVector. 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 the holder* bitmaps deliberately stay as resources.

  • Deep link support: simpmusic:// and simpmusic.org URL 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-empty module pair gated by isFullBuild; unified Media3 CastPlayer wraps the session ForwardingPlayer; CastHandoffManager pushes 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/nowplayingcenter 0.0.3 (forked JMTC). The native SMTCAdapter.dll was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates RPC_E_CHANGED_MODE, MediaPlayer kept 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), and MediaType.Music is set before display properties so title/artist render (not just the app name). Enabled in JvmMediaPlayerHandlerImpl for Platform.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, MacOsVlcDiscoverer and VlcModule are deleted; VlcModule.kt became DesktopPlayerModule.kt (loadVlcModule() → loadDesktopPlayerModule()). The vlcj dependency, the vlc-setup Gradle plugin, every vlcSetup* task, the vlc-natives/ tree and the VLC Conveyor inputs are all gone. appResourcesRootDir now points at mpv-natives/. libmpv is the only desktop backend.

  • Bundled libmpv (2026-07-27): two entry points, deliberately split. :composeApp:mpvBundleAll runs on a Mac, once per mpv bump — it turns upstream mpv builds into loadable slices in mpv-natives/<os>-<arch>/, packs them into tarballs and prints their SHA-256. Those are published to maxrave-dev/simpmusic-files. :composeApp:mpvSetupAll is what CI runs: it downloads those tarballs, verifies them against the digests pinned in mpvNativesChecksums, 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 Gradle dependsOn.

    • Sources: shinchiro mpv-dev-*.7z (Windows — the only one shipping a real libmpv-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 the mpv executable; that PIE binary exports the full client API and is renamed to libmpv.dylib, with load-command paths repointed to @loader_path.
    • Do NOT lift IINA.app/Contents/Frameworks instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs _pl_log_create_349, bundled libplacebo.338.dylib exports _pl_log_create_338) and that libmpv fails dlopen under both RTLD_NOW and RTLD_LAZY.
    • Every ._* sidecar must be stripped after unpacking (mpvSetupAll does 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 ._name back into the xattrs of name and 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>.
  • Linux libmpv built from source (2026-07-28): the AppImage route is gone — scripts/mpv-linux/Dockerfile now compiles libplacebo 7.351 + FFmpeg 7.1.1 + mpv 0.41.0 on Ubuntu 22.04, and mpvSetupLinuxCi runs that container and copies /out. This deleted ~186 lines of DwarFS extraction, closure pruning and rpath rewriting from composeApp/build.gradle.kts.

    • Why the AppImage could never work: every prebuilt Linux mpv targets "run mpv as its own process". mpv-AppImage ships its own glibc + ld-linux, and its "libmpv.so.2" was really the mpv PIE executable — glibc refuses to dlopen a 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.sh deliberately does not bundle libc/libm/libstdc++/ld-linux, sets DT_RPATH (not DT_RUNPATH — RUNPATH is not inherited by transitive dependencies), and fails the build unless a dlopen + mpv_initialize smoke test passes.
    • mpv built with -Dlua=disabled has no ytdl_hook, so the ytdl option genuinely does not exist there; MpvPlayer uses optionalOption() to treat MPV_ERROR_OPTION_NOT_FOUND as success.
  • Last.fm scrobbling (2026-07-30, Full build only): lastfm/lastfm-empty module pair gated by isFullBuild, following the cast/crashlytics shape. LastfmScrobbler (in core/data/.../lastfm/) lives in commonMain and is driven by both player handlers, because Android and Desktop run entirely separate ones. It sends track.updateNowPlaying where the Discord RPC is updated, and track.scrobble off 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 in ignoredMessage: 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=X with no token; Last.fm mints it and redirects to the registered callback with ?token=. Desktop flow: call auth.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.kt hands it straight to SharedViewModel.completeLastfmLogin(), and LastfmLoginScreen closes 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 the navigateUp() 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.kt is 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 with entries.sortedBy { it.key }.
    • format must be excluded from api_sig. Parameters are sorted by name, concatenated <name><value>, secret appended, MD5'd — but signing format (or callback) yields "Invalid method signature supplied" (code 13) on every request.
    • Error codes worth branching on: 9 invalid 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: timestamp is the time the track started (the method page says started, the scrobbling guide says finished — every scrobbler in the wild sends the start), and duration is always sent (optional on one page, required on the other).
    • Responses are parsed as loose JsonObjects, not @Serializable classes: 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): MpvLibrary passes OPTION_OPEN_FLAGS = 2 (RTLD_NOW without RTLD_GLOBAL) only when not on Windows. JNA forwards the value verbatim to LoadLibraryEx, where 2 means LOAD_LIBRARY_AS_DATAFILE: the DLL maps as plain data, imports never resolve, and GetProcAddress returns nothing — surfacing as the misleading Error 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-schemes belongs at the top level of app in conveyor.conf. Conveyor binds it on AppConfig, not on MacConfig/WindowsConfig/LinuxConfig — compare MacConfigAccess.getUrlSchemes() (reads appConfig) with the getFileAssociations() beside it (reads mac). It had been written as mac.url-schemes / windows.url-schemes / linux.url-schemes since May 2026; HOCON accepts unknown keys silently, so all three sat inert and every packaged build shipped with no CFBundleURLTypes at all — macOS never routed simpmusic:// either, not just the Last.fm callback. Proven by diffing two mac-app builds that differed only in where the key was written. The same misplacement had parked desktop-file.Categories / Comment[en] / StartupWMClass beside the "Desktop Entry" group instead of inside it, so those never reached the generated .desktop either. Two more links in the same chain: the argv filter in runDesktopApp matched a fixed list (simpmusic://, http://, https://) and therefore discarded wordbyword://lastfm-auth?token=… on Windows and Linux — it now matches any scheme:// — and the AppImage's own .desktop (written by packageConveyorAppImage, which is what actually reaches users since AppRun installs it into ~/.local/share/applications) now declares x-scheme-handler/wordbyword alongside simpmusic.

  • Bundled glib disabled java.awt.Desktop on Linux (2026-07-31): mpv-natives/linux-x64/lib/libglib-2.0.so.0 is glib 2.72 (built on Ubuntu 22.04) and is missing from SYSTEM_LIBS in scripts/mpv-linux/stage.sh, so it ships in the bundle and claims the glib soname the moment JNA loads libmpv at startup. AWT's XDesktopPeer.init() can then no longer dlopen the system libgio-2.0.so.0: on a glib 2.80 host (Ubuntu 24.04) it dies with libgobject-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 an if with no else so it silently did nothing, while Compose's LocalUriHandler calls Desktop.getDesktop() on its first line and threw UnsupportedOperationException straight 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 calling Desktop.isDesktopSupported() at the top of runDesktopApp — XDesktopPeer caches that probe, so running it before libmpv loads lets the system gio/gobject win the soname race. OpenUrl.jvm.kt additionally 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 to SYSTEM_LIBS, which needs the Linux tarball rebuilt, republished and re-pinned in mpvNativesChecksums.

  • Crossfade skips video tracks (2026-08-01): both CrossfadeExoPlayerAdapter (Android) and MpvPlayerAdapter (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 commit 9da155d7 because its old shape ignored the watch-video setting — is back on both platforms as isCurrentTrackVideo() (watchVideoEnabled && isVideo(), symmetric with isNextTrackVideo()), 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 + SwingPanel embedding) became MpvVideoFrameSource — the mpv SW render loop is unchanged, but finished frames are published as immutable BufferedImage snapshots on a StateFlow and drawn by a plain Compose Image (MpvVideoFrames in media-jvm-ui, converted with toComposeImageBitmap() off the UI thread; the UI reports its size via setTargetSize()). 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 now currentVideoFrames: 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): MpvPlayer now pins ao to "avfoundation," when Platform.isMac(), because ao_coreaudio leaks a process-wide CoreAudio listener onto a freed struct ao and takes the whole JVM down the next time an audio device appears or disappears — Sentry-visible as EXC_BAD_ACCESS on the HALC_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.c init() registers AudioObjectAddPropertyListener(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.c then does goto fail → ao_uninit(), and buffer.c's ao_uninit() calls driver->uninit() only when driver_initialized is set — a flag ao.c sets only after a successful init. So unregister_hotplug_cb() never runs while talloc_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.
  • 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 for sleepFadeTailMs = 800 before stopping. The attenuation rides a line of its own, deliberately not volume: that one is the user's level and is reported back through onVolumeChanged, so ramping it would drag the UI slider down and — if the process died mid-fade — leave a silent app behind. MediaPlayerInterface gained var sleepFadeFactor: Float for it.

    • Android applies it as a new SleepFadeAudioProcessor in the Media3 chain (arrayOf(crossfadeFilter, sleepFade)), one instance per ExoPlayer, all reading the same @Volatile field. Desktop adds a third mpv level: MpvPlayer now blends masterPercent × sleepPercent into ao-volume while fadePercent keeps carrying the crossfade on the software volume. 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 (commitIncomingAsCurrent joins 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 a finally at 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.
  • 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 from resolveAutoCrossfadeDurationMs() (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 like PLAYLIST elsewhere; AlbumViewModel uses it for play, but not for shuffle — once shuffled the running order is gone). The handler snapshots the album's mediaIds into MediaPlayerInterface.albumTrackIds at 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 _queueData directly and never call setQueueData, so the snapshot stays album-only while listTracks grows. Routing those appends through setQueueData would 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.PLAYLIST because QueueEntity has no column for it.
  • 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 from handleTrackEndInternal() on EOF. The EOF path returns early when isCrossfading is 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() and forEachLiveHandle (current + secondary + precached). secondaryPlayer is the easy one to miss — it is removed from precachedPlayers before being promoted, so it belongs to neither collection, and since ao-volume is shared process-wide on Windows a missed handle does not just stay wrong, it undoes the others. Speed used to be applied only to currentPlayer and only re-asserted in endCrossfadeAudio(), 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 rubberband filter, which the codebase already drove for AutoMix key matching (MpvPlayer.setPitchScale, label simpDjPitch). It is applied only while crossfade is off: crossfade owns mpv's af chain and clears it after every transition, so the two cannot both drive it — the UI already locked the control in that case. installCrossfadeChain returns whether mpv accepted the filter; a build without rubberband logs a warning instead of firing af-command at a filter that is not there.

  • Seeking mid-crossfade (2026-08-14): seekTo(positionMs) was the only transport command that did not handle isCrossfading — 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 from secondaryPlayer while the seek went to currentPlayer. Both now commit the incoming track as current first, the way pause() does.

  • All mpv property writes run on the player thread (2026-08-14): volume, sleepFadeFactor, seekTo(positionMs) and playbackParameters used to write from the caller's thread. MpvPlayer.release() flips isReleased synchronously and then spawns Mpv-Release to mpv_terminate_destroy, so a caller that passed the isReleased check could still be inside mpv_set_property when the core died — the same use-after-free already documented in the release() join comment. Confining every write to the one thread that releases handles closes the window; MpvPlayer.applyVolume() additionally holds volumeLock, which is the only way to cover the event pump's AUDIO_RECONFIG path (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_event and 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) → their notification + 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.liked is the entire Favorites feature and song.downloadState is 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 = 0 also 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 IN over a nullable column silently matches nothing. local_playlist.youtubePlaylistId is 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 needs WHERE <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 — including List<Map<String,String>> columns, where the id is still a quoted value. But 1 748 videoIds contain _, so every pattern escapes \, _, % via nested replace() and declares ESCAPE '\'.
    • 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_playlist cascades 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. The queue table 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.
  • @RawQuery cannot VACUUM — Room routes it to a read-only connection (2026-08-16): KSP generates raw() as performSuspending(__db, isReadOnly = true, inTransaction = false), Room takes a reader connection for that, and readers are opened with PRAGMA query_only = 1. VACUUM there fails with "attempt to write a readonly database" — while PRAGMA wal_checkpoint is accepted on the very same connection, which is exactly why the long-standing DatabaseDao.checkpoint() works and hid this. vacuum() therefore lives on MusicDatabase as useWriterConnection { it.execSQL("VACUUM") }, with checkpoint() immediately before it so the WAL the deletes just filled is folded back in first. execSQL opens 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.updateFollowedStatus only flipped artist.followed, stranding that artist's notification and followed_artist_single_and_album rows 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): onIsLoadingChanged wrote 2–3 times in a row — Loading unconditionally, maybe Ready, then Loading again from stopBufferedUpdate. _simpleMediaState is a StateFlow collected from another thread, so it conflates and the UI settled on the last write: Loading when buffering had just finished, Ready when it had just started. Since the adapters call it with false immediately after announcing STATE_READY, every track start and every resume ended with a spinner over playing audio. Compounded by startBufferedUpdate() not cancelling its predecessor, so each track leaked another 500 ms Loading emitter — the sibling startProgressUpdate() had already been fixed for exactly this (#2152). Desktop additionally compared bufferedPercentage * duration (percent × ms) against currentPosition (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's Ended branch pinned current = -1L, and the only formatter renders any negative as NA:NA — with the Progress branch ignoring negatives and the Loading branch restoring total without touching current, the player showed a correct duration next to NA:NA. And play() at end of queue called into a handle parked at EOF, which does nothing, so the button looked ignored; both adapters now rewind first.
  • 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 (bottomNavScreens for selection, barTabs for the sliding capsule; Search is a separate FAB) — and all of them must be updated or the tab exists but never renders. BottomNavScreen.ordinal is an identity, not a position: AppNavigationRail compared selectedIndex == index in one place and screen.ordinal in another, which only worked while the two numbers coincided; reordering exposed it.

  • videoType was never read from the API (2026-08-16): no parser ever read YouTube's own musicVideoType; every call site invented its own label, and ResultVideo.toTrack() even smuggled the view count into the column (videoType = this.views), which ArtistScreen then relied on to render "432K views". So song.videoType held "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 from watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType across every parser, carried on SongItem/Track/Content, and written through the existing update paths (updateVideoTypeSongEntity) rather than a migration. Track gained a real views field so the artist page stops depending on the smuggling.

    • Every comparison goes through MusicVideoType in core/domain — the one module both kotlinYtmusicScraper and core/data already depend on, so putting it in either would invert an existing edge. It normalizes first, keeping only real MUSIC_VIDEO_TYPE_* values, because rows written by older builds still hold the invented labels above; comparing those raw would call all of them videos.
    • null is "YouTube did not say", never "audio". isVideoSong resolves an unknown to false (matching Metrolist), ytmusicapi resolves the same unknown to "video" — callers that care must branch on isKnown first 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.musicVideoType reads 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 bare watchEndpoint, which is exactly the migrated shape.
  • Wrapped queue rows were being dropped — 82% of a logged-in radio (2026-08-16): YouTube.next() read it.playlistPanelVideoRenderer only, so every row YouTube ships as a playlistPanelVideoWrapperRenderer resolved to null and vanished in the mapNotNull that 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. Reading Content.track (bare renderer, else primaryRenderer) 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.20260304 and 1.20260811), and both body shapes (plain NextBody and the isAudioOnly + params=wAEB one) — every one returned zero wrappers, while the authenticated capture returned 48/50. All three radio paths use youTube.next() with setLogin = true, so the app has been getting the wrapped shape.
    • Content.counterpart is 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, hardcoded RDAMVM$videoId) is dead code — nothing calls it.
  • 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 SongRepositoryImpl because that is where the three queue sources meet: getRadioFromEndpoint, getRelatedData and getContinueTrack.

    • 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 no counterpart to 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, from NextPage.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. preferAudioWhenRadioAudioOnly logs 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.
    • Each source needs its own radio test, and two of them are not obvious. getContinueTrack must also accept RRDAMVM… — YouTube's other spelling for a single video's radio, which isRadioQueueId deliberately does not match because it never appears as a queue's own playlistId; and its else branch continues a real playlist, so only the !fromPlaylist branch may filter. getRelatedData is always radio but not directly: next(videoId) alone answers with just two rows — the song and an automixPreviewVideoRenderer pointing at its RDAMVM… radio — which YouTube.next then follows and splices in.
    • getRadioFromEndpoint is 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.
  • 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 now height((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 as wDP * 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).
    • HazeProgressive kills the process on skiko. haze 1.7.2's progressive path calls ShaderBrush.createShader(Size), whose mangled signature does not match the Compose this build pins (material3-multiplatform 1.12.0-alpha01 / skiko 0.148.1) — NoSuchMethodError thrown 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 with startIntensity = 0f. The fade is now a Modifier.blur copy of the artwork masked by a DstIn gradient, 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.) Plain hazeEffect is 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 removes Matrix33.makeTranslate and crashes compottie.
    • mpv decides the video's fit, not Compose. MpvVideoFrames reports its box size through setTargetSize, and mpv scales and letterboxes each frame into exactly that size — the black bars are already pixels by the time Compose sees them, so no ContentScale can remove them. MediaPlayerView.jvm.kt had therefore been ignoring cropToBounds outright, with a comment that Desktop never renders the portrait canvas. Cropping is now mpv's panscan property (0.0 letterbox … 1.0 cover), exposed as MpvPlayer.setPanscan() and applied from a LaunchedEffect so flipping the flag re-scales the running video instead of re-creating the handle.
  • The header gate is orientation, not platform (2026-08-17): the migration above shipped with val isMobilePortrait = true hardcoded 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 now val isPortrait = screenInfo.wDP < screenInfo.hDP and 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 — its HazeProgressive guard (about skiko, not layout) went with the switch to a Modifier.blur fade.

    • The old black angledGradientBackground layout is deleted from all four screens, since "not portrait" now means "landscape header" rather than "legacy layout". That also retired PauseCircle/PlayCircle/Sensors/ElevatedButton and, in ArtistScreen, CollapsingToolbarParallaxEffect plus 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 true broke 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), and ContentScale.FillWidth vs Crop. (Changed 2026-09-22: landscape was a fixed height(hDP / 2) strip that cropped the banner.) The scrim is fillMaxHeight(0.7f) of the frame in both — measured off the frame, since the landscape frame no longer follows the window.
  • Liquid glass reaches Desktop, and its expect/actual is gone (2026-08-17): io.github.kyant0:backdrop is a KMP artifact declared in commonMain.dependencies, so Gradle had been resolving backdrop-desktop all along — the JVM side was simply never written. LiquidGlass.jvm.kt returned Modifier unchanged and LiquidGlassContainer.jvm.kt fell back to clip(shape), so every glass surface on Desktop was a plain rounded box. Both actual pairs are now deleted: PlatformBackdrop is a typealias for Kyant's LayerBackdrop in 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 class has no relationship to Kyant's Backdrop, so drawBackdrop() 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 reading PlatformBackdrop.
    • 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 a matchParentSize() 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 flat White @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 is highlight = { Highlight.Default }, and Highlight.Default carries HighlightStyle.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.Plain is the uniform one (Color.White.copy(alpha = 0.38f), BlendMode.Plus). liquidGlass/LiquidGlassContainer/LiquidGlassIconButton/drawInteractiveGlass therefore take highlight: Highlight = Highlight.Default — the default keeps Kyant's own behaviour, so Android and the portrait branch are untouched. The small round buttons pass Highlight(width = 1.dp), NOT Highlight.Plain (corrected 2026-08-26). Plain is 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:422 and 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 a lensScale parameter, moving the backdrop source between a matchParentSize() 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 between TopStart/TopEnd was the one useful experiment: the glass followed the widget, proving it was the surface's own geometry rather than its position.
    • 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_glass setting at all — three call sites read isLiquidGlassEnabled == 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 for LiquidGlassIconButton/liquidGlass by default rather than treating glass as the risky option. Kyant's desktop path does go through SkikoRuntimeShader, the same neighbourhood where haze 1.7.2's HazeProgressive throws NoSuchMethodError — that remains true of haze, not of Kyant's backdrop.
    • LiquidGlassAppBottomNavigationBar still has an empty JVM actual — Desktop navigates with the rail, not the bottom bar. It is unrelated to the above and was not touched.
  • 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) speaking MetrolistGroup/metroproto over a Ktor WebSocket to metroserver. Nothing in the protocol layer may be "improved": the wire format is not ours.

    • protobuf without protoc. Messages are kotlinx-serialization-protobuf classes with @ProtoNumber; a protoc-generated Java layer would be JVM-only and strand Desktop and iOS.
    • encodeDefaults must be false. With it on, any payload carrying a null message field throws 'null' is not supported for optional properties in ProtoBuf — and PlaybackActionPayload.trackInfo is 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_capabilities were read off metroserver's protocol.go and are pinned by a test.
    • The server forces IsPlaying=false on EVERY change_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 hardcoded playWhenReady = true and letting the transport pause it is a race the guest wins — it starts playing in a room the host has paused.
    • playWhenReady, not isPlaying, everywhere intent is meant. A buffering track reports isPlaying = false while 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_sync the moment it is in.
    • Loading gaps are absorbed by position, not by waiting. Each client resolves its own stream; the host publishes position with every command and ServerClock compensates the flight time, with a seek past SEEK_TOLERANCE_MS = 750 (Metrolist's HARD_SYNC_THRESHOLD_MS). The buffer barrier only runs when someone stalls mid-track — change_track clears BufferingUsers server-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_sync so resuming lands where the room is now, not where this device stopped. Matches Metrolist's manager.
    • MediaPlayerInterface.crossfadeSuppressed turns 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 own maxresdefault.jpg fallback as video, so room tracks are built from the room's own TrackInfo with MERGING_DATA_TYPE.SONG forced — otherwise the guest gets video with no sound where the host has audio.
    • UI never sees the protocol. composeApp talks to ListenTogetherRepository in domain; ListenTogetherRepositoryImpl in data is 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.
  • Apple Music lyrics style (2026-08-25): a second lyrics renderer, chosen by LYRICS_STYLE in DataStore (CLASSIC default | APPLE_MUSIC) from Settings → User interface, and deliberately independent of NOW_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, and FullscreenLyricsSheet. LyricsView reads the setting itself via koinInject rather 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 in em: 0.4em line padding, ~1.2em leading, 0.5em/1.5em/0.3em for 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.blur is backed by RenderEffect (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, and LyricsView re-checks it: a DataStore restored from a backup can carry APPLE_MUSIC onto a phone that cannot draw it.
    • Modifier.blur(radius) alone CLIPS. The single-argument overload uses BlurredEdgeTreatment.Rectangle, so softened glyphs get sliced off square at the line's own bounds. Unbounded is 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 with margin: -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, FullscreenLyricsSheet must not add its own gutter on top. It keeps a 50dp column for Classic; under this style it contributes 50 - AppleMusicLyricPaddingX so 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 the Shadow that by definition spills outside them. Glow intensity therefore rides in shadow.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 Text per 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 into characterElements for 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: an if around 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 is du/2000 cubed below the reference duration and square-rooted above it, ×0.6 capped 1.2 for scale, ×0.5 capped 0.8 for bloom, ×1.6/×1.5 on 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.095em per line, capped 0.45em. Distance is asymmetric, which IS AMLL's: lines already sung carry a +1 so the page behind the singer recedes faster than the page ahead.
    • Blur drops to zero while the user drags the list (collectIsDraggedAsState, not isScrollInProgress — 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, so animateScrollToItem(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.
  • Compose Hot Reload + MCP (2026-08-17): org.jetbrains.compose.hot-reload 1.2.0 is applied in desktopApp (root has apply false; foojay-resolver-convention in settings provisions the JBR). Run with ./gradlew :desktopApp:hotRunJvm --auto — plain jvmRun does NOT hot-reload; the plugin creates separate tasks. mainClass resolves automatically from compose.desktop.application.mainClass, and the existing tasks.withType<JavaExec> block already covers ComposeHotRun (it extends JavaExec), so mpv.bundled.path reaches hot runs. The MCP server (:desktopApp:hotMcpServerJvm, registered in Claude Code local scope) exposes status/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), while take_screenshot captures 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_enabled in DataStore — drives two entirely different backends. Desktop writes mpv's af; Android runs EqualizerAudioProcessor (a Media3 BaseAudioProcessor) 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 own equalizer filter 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 af has 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 in af used to vanish with them. MpvPlayer now keeps eqEntry and crossfadeEntries apart and applyAudioFilters() 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 @Volatile field on every buffer, and ExoPlayer.Builder appears 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 empty af, so applyPlaybackLevels() re-asserts the curve on all four creation sites. secondaryPlayer is the one to miss: while being promoted it belongs to neither currentPlayer nor precachedPlayers.
    • Never override isActive() to true in a BaseAudioProcessor. The base answers it from pendingOutputAudioFormat, which configure() assigns immediately before the call — so the default already means "active iff onConfigure accepted the format", and it stays active across curve changes because activity is only reconsidered on configure. Forcing true claims the processor is in the chain while handing back NOT_SET for a format it cannot read. SleepFadeAudioProcessor still does this; harmless only while enableFloatOutput stays off.
    • Every band keeps a stage even at 0 dB. With A = 1 the 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.
  • AutoEq profile import (2026-08-22): new core/service/autoEqService plus 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.md is read straight off raw.githubusercontent — 851 kB, 8850 profiles — parsed to rows so a search is an indexed LIKE rather 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**i with q = 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 a Slider's range is pinned to the end of the track while holding a different number.
  • System equalizer removed entirely (2026-08-22): the ACTION_OPEN/CLOSE_AUDIO_EFFECT_CONTROL_SESSION broadcasts, MediaPlayerListener.shouldOpenOrCloseEqualizerIntent and every notifyEqualizerIntent call site, the OpenEq expect/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.audioSessionId stays: it looks like part of this, but LoudnessEnhancer (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.5 under smoothScrimBrush — 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 a DropdownMenu — 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 < hDP picks 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.Week joins Day and Month).
    • FiveImagesComponent kept, 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, so landscape = true makes #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 one MosaicTile.
    • 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 used MonthNames.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 a LastXDays shortcut. Stepping is therefore a different argument, not new plumbing.

    • One snapshot per period, not a dozen flows. AnalyticsRepository.getPeriodStats(start, end) is a plain suspend fun returning AnalyticsPeriodStats, 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. getPlaybackSamplesInRange therefore returns raw (timestamp, listenedSecond) samples with the timestamp declared LocalDateTime, so Room's converter — chosen by TARGET TYPE — decodes it and no timezone arithmetic exists in the path (declaring it Long bypasses 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-fix Long shape 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_event alone: 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.
    • getArtistPlayCountsInRange is unbounded on purpose. queryTopArtistsInRange caps 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. getNewArtistCountInRange takes MIN(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.year exists, but the join runs through the nullable playback_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.
  • Four traps found while finishing the Analytics screen (2026-08-22), each of which produced plausible output rather than an error:

    • playback_event.timestamp is NOT a UTC instant — it is the local wall clock encoded as one. Converters.dateToTimestamp writes every LocalDateTime with toInstant(TimeZone.UTC), so the only correct decode is TimeZone.UTC, which is what Converters.fromTimestamp does. Reading the column as a raw Long (declaring the field Long bypasses the converter, which Room selects by TARGET TYPE) and then decoding it with currentSystemDefault() 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 "use TimeZone.UTC" — that still leaves a zone to pick wrong — but to declare the field LocalDateTime and 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 palette as null unless its state is Success, and generate() sets Loading before its suspension point. So the page background resolves to Color.Black for the whole duration of every generation — and if the effect is cancelled mid-flight the state stays Loading forever. Analytics keyed its effect on bitmap AND the artwork URL; the URL goes null on every reload and then back to the same value, which cancels the generate and then makes the paletteGeneratedFor guard skip the retry. Symptom: the same song sometimes tinted the page and sometimes left it black. AlbumScreen keys on bitmap alone, 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$s and %1$d and nothing else — no flags, no width, no %%. %1$02d:00 – %2$02d:00 rendered 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.
    • ForceDarkContent is 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 the MiniPlayer has the same gap for a different reason: it is a sibling of the NavHost, so a CompositionLocal provided inside cannot reach it.
    • FiveImagesComponent gained a shape (the block is clipped as ONE object — the tiles stay flush) and, more importantly, hole-free arrangements for counts 1–5. The original if (images.size < 3) return silently 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 (SPOTIFY default | M3_EXPRESSIVE; an Apple Music style is the planned third). NowPlayingScreen.kt went 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 in content/NowPlayingContentState.kt (NowPlayingContentState / NowPlayingContentActions). The old UI moved VERBATIM into content/NowPlayingContentSpotify.kt (two sanctioned cleanups only: the twice-duplicated metadata row became NowPlayingTrackInfoRow, and isUserLoggedIn() — a runBlocking executed inside composition on every recomposition — became a shell-collected flow). The new style is content/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): LinearWavyProgressIndicator and MaterialExpressiveTheme carry @Material3ExpressiveApi, which is a @RestrictTo marker, NOT @RequiresOptIn — no opt-in needed; MaterialTheme.motionScheme is stable. The wavy seekbar is the indicator plus a transparent pointerInput layer (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 in MaterialExpressiveTheme around the content subtree, everything reading tonal roles (no Color.White semantics, no black-gradient drawBehind); falls back to the app seed until the artwork has been scored. The seed was state.startColor until 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, and cropToBounds is 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's showControlLayout/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 Symbols music_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 inherent a that 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 (е → ye word-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".
    • RomanizationLanguage lives in core/domain, not beside the romanizers. data depends on lyricsService with implementation, so the dependency stops there and composeApp cannot see it — the layering rule working as intended. The UI therefore goes through LyricsRomanizerRepository in 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 (promeg vs promeG) so the transitive resolve dead-ends; and it drags in tinypinyin-android-asset-lexicons, an Android-asset artifact that breaks :lyricsService:jvmMain and jvmTest. pinyin4j (Maven Central, 316 KB, only junit at test scope) replaces it and is strictly better here — it carries tone marks, which TinyPinyin does not. Its trap: WITH_TONE_MARK throws BadHanyuPinyinOutputFormatCombination unless vCharType is WITH_U_UNICODE.
    • kuromoji needs a packaging exclude. kuromoji-ipadic pulls kuromoji-core, and BOTH jars ship META-INF/CONTRIBUTORS.md and META-INF/LICENSE.md, which fails mergeDebugJavaResource. Excluded as META-INF/*.md in androidApp — by pattern, because excluding only CONTRIBUTORS.md just moves the failure to LICENSE.md. Note the pre-existing META-INF/LICENSE… list there is under jniLibs.excludes and 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 a Char — 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.
    • SettingAlertState picks its dialog body with an if / else if chain testing message FIRST — passing message alongside multipleSelect renders the text and no list at all. The four nullable fields are mutually exclusive despite the type saying otherwise.
  • 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 use QueueBottomSheet, which renders the WHOLE queue and passes the index itemsIndexed hands it, computing nothing. AppleMusicQueueView instead did drop(offset) and added offset back at click time, making every row's identity depend on state.currentOrderIndex, a value DERIVED in the shell. It now does withIndex().drop(offset) so the queue-wide index travels WITH its track: a wrong currentOrderIndex can 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 through rememberUpdatedState (re-keying it per track rebuilt the snapshotFlow, whose first emission is the stale settledPage that distinctUntilChanged cannot suppress), and it now requires a latched pendingUserSwipe set 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 (onArtworkBitmap is only fed from inside that pager), fixed with a loader outside the Crossfade. And the canvas flashed sideways on returning from LYRICS because MediaPlayerView's legacy path seeds its width to the SCREEN width and only corrects it once onVideoSizeChanged reports the truth — cropToBounds = true takes its size from Media3's presentationState instead, 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_event table. No new data collection and no network call beyond artwork: every figure already existed on AnalyticsPeriodStats. Lives in ui/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 below REQUIRED_ACTIVE_DAYS = 30.

    • Shell / content split, as with the Now Playing styles. WrappedScreen owns everything constant — progress segments, year label, close button, footer, timer, capture — and each card fills the slot and draws nothing else. WrappedYear.cards drops the cards a given year cannot fill, so the segment count is derived, never a hardcoded ten.
    • One new query, and only one. queryTopArtistsInRange reads event_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 to playback_event. Added as queryTopArtistsWithTimeInRange returning a new TopPlayedArtistTime — adding a column to TopPlayedArtist would have broken the two existing queries that do not select it. A track credited to several artists hands its full listenedSecond to 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() TextStyle builder, and Box + border "pills", and was rejected on sight. Colour now comes only from MaterialTheme.colorScheme inside MaterialExpressiveTheme(colorScheme = rememberDynamicColorScheme(seed = top track's artwork, isDark = true, style = Vibrant)) — the construction NowPlayingContentM3Expressive already uses; type only from MaterialTheme.typography; surfaces from LiquidGlassIconButton / liquidGlass over rememberBackdrop and real Material 3 buttons. A tokens object carrying a literal palette is itself the defect — it licenses every call site downstream to hand-roll. WrappedTokens now holds geometry and durations and nothing else.
    • displayLarge is the wrong style to enlarge. In this app's typo() it is 20sp Normal in body colour, so displayLarge.copy(fontSize = …) yields a grey, light hero that needs a weight and a colour argued back onto it. titleLarge is 25sp Bold in the title colour, so .copy(fontSize = …) alone is enough — the idiom already live at ListenTogetherScreen.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-h544 from a track, …=w120-h120 from an artist and …=w226-h226 from an album, with i.ytimg.com/vi/<id>/maxresdefault.jpg as 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 normalisation JvmMediaPlayerHandlerImpl already applies when handing artwork to the media session.
    • Wrapped counts as fullscreen. isInFullscreen in App.kt was derived from FullscreenDestination alone, 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: WrappedYear carries 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.WRAPPED gates on local tracking exactly as the YouTube chip gates on being logged in — one line in the chip loop and a branch in the shared LaunchedEffect(currentFilter), no second mechanism. Each month is a LibraryDynamicPlaylistType.MonthlyRecap(year, month) round-tripping as recap_<year>_<mm>, so LibraryDynamicPlaylistScreen serves it with play, shuffle and download already attached, and a MonthlyRecapItem : PlaylistType puts it in the shared GridLibraryPlaylist rather 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 Box over GridLibraryPlaylist with its height reserved in contentPadding and 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. GridLibraryPlaylist now takes a header slot rendered as item(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 a posterModifier so its own Save/Share buttons stay out of the PNG.
  • 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 of BraveNewPipeUtils, a 136/134 fallback ladder and an if (itag == 774) find { 141 } branch in StreamRepositoryImpl, it.itag == 251 in YouTube.getNParam. They now all name ITAG in core/common/Config.kt, which also owns highQualityTwinOf() — 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).

    • QUALITY gained a fourth entry so the user can pick the family: High Opus - 256kps (774) and High 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 to items[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() replaces itags.getOrNull(items.indexOf(...)), whose -1 on 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.dev used to solve every signature and n parameter, which makes playback depend on someone else's server staying up. PipePipe already had the hook — YoutubeApiDecoder.setLocalDecoder() takes a YoutubeJavaScriptDecoder, an interface it ships with no implementation — so nothing in the fork needed changing. The implementation is FaradayJsDecoder (androidMain + jvmMain) over FaradayCipherEngine and a cipher/ package copied from MetrolistGroup/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 single StreamInfo.getInfo and 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, decodeBatch treats the local answer as final, and the app's own headCheckRandomStream() then dropped it straight to BravePipe: api.pipepipe.dev was never asked, precisely in the case it exists for. newPipePlayer now calls pipePipeStreams() twice — once after setLocalDecoder(faradayDecoder), once after setLocalDecoder(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 (decodeBatch at ifnull 24, getPlayerMetadata at ifnull 79, read from the pinned f8982ca9e7 jar). Do not try to reach that state with a stand-in decoder that throws: getPlayerMetadata calls getPlayerData with 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.json maps 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 is raw.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.json suffix, validatedSourceUrlOrNull() wants the path under /MetrolistGroup/faraday/. Metrolist's own app fails the second (it points at ZemerTeam/zemer-cipher), so its top tier silently returns null and never runs — a whole feature disabled with no error anywhere.
    • QuickJS maxStackSize defaults 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 no hs_err file either, because the crash is below the JVM: -XX:ErrorFile is set and still writes nothing, dmesg is restricted, and apport discards it because a Gradle-provisioned JDK "does not belong to a package". The only thing that located it was Logger.w markers around each native call — the last line printed was callFunction.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; newSingleThreadContext cannot be used because it offers no way to size the stack.
    • Throwing is how a request is handed back. YoutubeApiDecoder.decodeBatch tries 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. So decodeBatch throws unless every challenge is solved. getPlayerData is the opposite: getPlayerMetadata calls 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 the sts is missing, because a wrong timestamp poisons the whole response.
    • setLocalDecoder is the only public member of YoutubeApiDecoder. getLocalDecoder, decodeBatch and clearCache are package-private, and disableLocalDecoder(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.newPipePlayer re-registers on every extraction, turning "disabled forever" into "skipped for one track". headCheckRandomStream() failing now also calls invalidate() — 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 · cached or BravePipe. Deliberately not a column on NewFormatEntity: a format row is cached and reused, so a persisted source would keep naming the path taken the first time. SharedViewModel re-reads it on every format emission, because the first one lands before extraction finishes.
  • Japanese romanization dictionary moved out of the APK (2026-08-28): kuromoji-ipadic's dictionary — 8 .bin classpath 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 (androidApp packaging resources.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): KuromojiDictionary streams kuromoji-ipadic-0.9.0-dict.tar.gz from the abc release of maxrave-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 into filesDir/kuromoji-ipadic/. Desktop keeps the dictionary bundled (13 MB is noise beside a 230 MB DMG); iOS stays no-op.

    • TokenizerBase.Builder's protected resolver field is a decoy. ipadic's loadDictionaries() assigns resolver = SimpleResourceResolver(getClass()) unconditionally at its own top (verified in 0.9.0 bytecode), so planting a resolver in a subclass init is silently overwritten. DirectoryDictionaryBuilder overrides loadDictionaries() 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.android builds 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 then japanese() 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, not synchronized) 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 still DOWNLOADING and the READY after 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) flows LyricsRomanizerRepository → 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. LyricsView keys each line's romanization on a derivedStateOf { 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_languages lives in the settings file, so it rides along in every backup the app writes (manual, and the scheduled AutoBackupWorker); the pack lives only in this install's filesDir, 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 call LyricsRomanizerRepository.ensureJapaneseDictionary() (non-suspending, returns at once): a LaunchedEffect in LyricsView keyed 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 suspending downloadJapaneseDictionary(), 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, not japaneseDictionaryState afterwards. After a failure ensure does 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 logging CoroutineExceptionHandler) instead of SettingsViewModel'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 claiming DOWNLOADING with getAndUpdate, not a Mutex — KuromojiDictionary stages into fixed .part/.staging paths, so two concurrent downloads would clobber each other — and the outcome is published in a finally. Three things keep the state from sticking at DOWNLOADING: nothing runs between the claim and the launch (the log line moved inside the job, since a throw there would strand the claim), every fetch ends within KuromojiDictionary.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 one Logger.w line with the next retry delay, including a rethrown cancellation KuromojiDictionary itself 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 — AMTokenManager scrapes the web-player JWT out of music.apple.com/assets/index~*.js, buildAMHeaders signs 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 plain CanvasResult persisted into the existing song.canvasUrl / song.canvasThumbUrl columns, 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.getCanvas picks the source with a when, 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=albums returns 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. pickAlbumMatch therefore 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=albums answers 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 searches types=songs&include[songs]=albums&extend=editorialVideo, which returns the parent album already extended, in the same one request. The album path keeps types=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 flat resources.albums map. 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. searchAMSongsWithAlbums pairs 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.durationInMillis comes back in the same response, and SongEntity.durationSeconds is 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. pickMatch requires 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 at limit = 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 on Char.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 .mp4 is correct.
    • Nothing in the player changed: MediaPlayerView builds a DefaultMediaSourceFactory and media3-exoplayer-hls was already a dependency, so ExoPlayer recognises .m3u8 on its own, REPEAT_MODE_ONE already 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.video is 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=max and so took the very top — 2048x2732 10-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. selectAMRendition now resolves the master down to one media playlist before the url is ever stored: filter to avc1, take the narrowest rendition at or above 720 px, cheapest of those. Measured identical across six albums — 830x1106 @ ~2.85 Mbps tall, 768x768 @ ~2.1 Mbps square.
      • 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: 486x648 costs 1516 kbps while the larger 664x886 costs 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.
    • 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 on spotifyLoggedIn would 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.
  • 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 handlers combine them into one AudioEffects (off ⇒ null, values survive the switch) → Android EchoAudioProcessor + ConvolutionReverbAudioProcessor sampling a @Volatile value per buffer, Desktop a third fxEntries tier composed by MpvPlayer.applyAudioFilters() (still the ONLY writer of af) and re-applied per handle. Chain order is equalizer, 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 wrote playbackSpeed/pitch straight into DataStore, bypassing the Now Playing lock that disables speed and pitch while crossfade is on (crossfade owns mpv's af chain, so applyPitch() ignores the value there) — anything that touches speed or pitch must honour that lock.

    • Delay is ffmpeg aecho, and DelayEffect.taps() in core/domain is the ONLY definition of the tap layout. aecho is feed-forward (the delay line stores the input, so there is no feedback path): a "feedback" of fb is expanded into N = ceil(ln 0.05 / ln fb) taps (cap 12) at k·T with decays mix·fb^k, inGain = 1 − mix, and outGain = 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 WHOLE af string, equalizer included. EchoKernel is bit-exact with af_aecho.c against 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.0 truncated 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 with Float.toString(), which round-trips; mpvNumber's %.4f costs 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 = 1 is 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"); afir is convolution with the IR arriving as a SECOND INPUT, and its dry/wet are input/output GAINS, not a blend — hence asplit[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 weights is a runtime command, so the mix slider goes through af-command simpFxReverb weights "…" amix without rebuilding; irnorm=-1 because the generator already normalised. mpv accepts the graph because f_lavfi.c counts only UNCONNECTED pads (force_bidir wants exactly two) and amovie is a source, and m_option.c's read_subparam splices […] out verbatim while counting bracket balance, which is what makes [dry][wet] legal inside an af entry. Measured: afir adds NO timeline latency (impulse out at sample 0 at every partition size) and costs less than the ten-band EQ. Android's PartitionedConvolver (overlap-save, P = 4096, float Hermitian half-spectra, double arithmetic, CATHEDRAL ≈ 7 MB stereo) matches afir to 1e-7; a dry FIFO keeps (1−m)·x + m·(x⊛h) aligned, onQueueEndOfStream flushes 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) then av_opt_set_from_string (the filter's options) — and each strips one backslash layer, so inside an amovie filename : 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. amovie opens the file at PARSE time, so a missing file fails the whole af write; a null path therefore means "no reverb entry". Tiered fallback in MpvPlayer.setAudioEffects: [echo, reverb] → [echo] (latches the process-wide reverbFiltersUnavailable, 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 bundles libglib-2.0.so.0 (the java.awt.Desktop cure finally lands). Trap on Apple Silicon: docker buildx is absent, so --platform linux/amd64 is silently ignored by the legacy builder and mpvSetupLinuxCi produces an arm64 slice whose ELF check (e_type only) passes — pin the amd64 ubuntu:22.04 base by digest (or install buildx) and check e_machine == 62. Built in 8 min under colima (vz + Rosetta); the tarball (16,909,206 B, SHA-256 6cc64efb…) replaced the Linux asset on the abc release of simpmusic-files on 2026-09-02 and is pinned in mpvNativesChecksums. 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 broken commonMain file; the JetBrains MCP is the only gate.
  • Lyrics timing offset (2026-09-12, half of issue #2338): one signed lyrics_offset_ms in 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 into Lyrics. That is not a style choice — the state write at SharedViewModel.kt:1288 is immediately followed by insertLyrics (Room cache) and, on the translated branch at :1248, by insertSimpMusicTranslatedLyrics, 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 now the line does (currentTimeMs = current.current), so one subtraction covers both. Shifting the data would additionally require rewriting the <mm:ss.xx> markers inside Line.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 > 0L guard already reads as "no line yet" — correct in heard time.
    • Four displays, and three of them cannot be handed a pre-shifted clock: NowPlayingScreen, MiniPlayerLayout and LyricsView's fullscreen sheet use the same TimeLine for the seek bar and elapsed readout, which must keep reporting where the player is. Only MediaPlayerViewWithSubtitle (both platforms) uses it for lyrics alone. So the subtraction is per read site, not a second flow. LyricsView covers the most ground by itself: both styles, rich sync, and the share picker's opening line; all three player styles share NowPlayingScreen's currentLyricLineIndex, so the inline line is one edit.
    • SettingAlertState renders its text field ONLY inside the message != null branch (SettingScreen.kt:2974). A state carrying a textField and no message opens an empty dialog with no error anywhere. Confirm is disabled while verifyCodeBlock fails, 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.
    • setLyricsOffsetMs deliberately does not re-call its getter the way its neighbours in SettingsViewModel do: those getters collect forever, so re-calling one per write leaks another collector for the life of the ViewModel.
  • 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. buildDisplayLines returns the sheet's own lines plus a synthetic rich-synced Line carrying <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 and Line.endTimeMs is frequently absent (the parser writes Long.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.lines at 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. currentWordIndex was 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. rememberSmoothPlayhead carries the position forward between ticks at frame rate; it is returned as State and read inside a derivedStateOf, 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_MS and closes in FLARE_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 is CHAR_RISE_MS on 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.
  • Desktop: the published position is smoothed in the handler (2026-09-12): mpv moves time-pos only 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-pts is nil during seeks). Everything driven by position inherited that staircase. JvmMediaPlayerHandlerImpl now 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 currentPosition made 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.Progress is a data class in a StateFlow, 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.
  • System equalizer returns as an option (2026-09-13): Android gets an Equalizer type choice in Settings → Audio — equalizer_type in DataStore, BUILT_IN (default) or SYSTEM — and the two never run together. The system side is the pre-2.0.0 code restored verbatim (app f7f35e96^, core 7ddeb379, identical to v1.7.0): MediaPlayerListener.shouldOpenOrCloseEqualizerIntent; in the adapter, the notifyEqualizerIntent calls (OPEN when the current player moves into PLAYING; CLOSE on pause, stop, end of queue and cast handoff) and the per-player onEvents hook, 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-constructed ExoPlayerAdapter too, for parity); the handler's sendOpen/CloseEqualizerIntent with CLOSE in release(); the OpenEq expect/actual pair and the Open system equalizer row. The only new logic is the gate: with BUILT_IN the handler drops every open/close request; with SYSTEM it 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 for ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, and AOSP MusicFX's ControlPanelReceiver returns 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() leaves playWhenReady true, and Media3 delivers onEvents through sendMessageAtFrontOfQueue — after the adapter's own CLOSE — so it computes shouldBePlaying and sends OPEN. Found in review, not on a device; closed-source EQ apps (SoundAlive, Wavelet, Poweramp EQ) are unverified.
    • onEvents is the path that is easy to miss. It calls listeners.forEach { it.shouldOpenOrCloseEqualizerIntent(…) } directly instead of going through notifyEqualizerIntent, 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.
  • Fullscreen lyrics becomes content + host, with a landscape layout (2026-09-13): the page is now FullscreenLyricsContent (new ui/component/FullscreenLyricsContent.kt), hosted the way Now Playing is. FullscreenLyricsSheet keeps its name but is only the Android host — the same ModalBottomSheet (container, scrim, insets, shape unchanged) around the content, animating its own hide when the page asks to close. On Desktop, NowPlayingScreenContent hosts the content in a Popup (focusable = true) instead, at IntOffset.Zero and sized to LocalWindowInfo.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 in ui-desktop 1.12.0-alpha01) and is not part of commonMain's expect constructor. A Popup also appears in a single frame, which read as a flash over the whole window, so the page runs through AnimatedVisibility(visibleState = MutableTransitionState(false).apply { targetState = true }) — fade plus scale from 0.96, 300ms in and 220ms out — and closing only flips the target: showFullscreenLyrics drops once the transition is idle at false, or the Popup would leave mid-exit. The page clips itself to 12dp corners: DesktopApp makes the window transparent and clips its content to RoundedCornerShape(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 as containerSize.height > getScreenSizeInfo().hPX, since getScreenSizeInfo() subtracts exactly that bar. The host block moved below state/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: orientation is not in the activity's configChanges, 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 passes sheetMaxWidth = Dp.Unspecified in landscape only: Material 3 caps a ModalBottomSheet at 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 from MiniPlayer.

    • Portrait is the old page, moved verbatim. Two mechanical adjustments only: the tap-to-show-controls clickable moved from the sheet onto the content root, and the root paints black itself — the sheet's containerColor used to sit under the gradient's translucent stops, and a Popup has no container. The artist tap now just calls onDismiss(); 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 same LyricsView portrait 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: NowPlayingTrackInfoRow and ExpressiveTrackInfoRow (now internal, with showCanvasThumbnail so 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, hqdefault fallback, tint) and AnimatedLyricsGradientBackground. The three playback-controls functions are ColumnScope extensions that emit straight into the caller's Column instead of wrapping themselves: the Spotify and M3E panels pass isElementVisible in through sliderModifier, and that modifier compares the slider against its PARENT layout, so a wrapper would silently change what it measures against. TimedLineIndex / activeIndexAt became internal so 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, so AppleMusicTransportRow / ExpressiveTransportRow and their *PlaybackControls wrappers take showShuffleAndRepeat = false by default and only FullscreenLyricsContent passes true. Apple's row switches from its 58dp centred cluster to SpaceBetween when 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's PlayerControlLayout already had both.
  • "More" on home sections, and a generic Browse page (2026-09-19): a home section that carries a moreContentButton now 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 (only clickTrackingParams differ), and a section without the button has no title endpoint either. The endpoint lands on HomeItem.moreEndpoint (browseId, params, pageType), read in HomeParser.

    • 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 carries MUSIC_PAGE_TYPE_PLAYLIST. navigateToMoreEndpoint (in AdapterItems.kt) sends album/audiobook, playlist, artist/user-channel and podcast pages to their existing screens (PlaylistRepositoryImpl adds the VL prefix itself), and everything without a page type — the FEmusic_* ids: listen_again, library_landing, moods_and_genres, new_releases_videos… — to the new BrowseDestination. params travel 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 all FEmusic_moods_and_genres holds; each opens MoodDestination(params) from its own endpoint. Everything is flattened into one grid with distinctBy { 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, so YTItem.toHomeContent() (in Mapping.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() in BrowseScreen.kt packs 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 from Content.homeCardAspectRatio(), which mirrors HomeContentCard's dispatch. The cards take fillMaxWidth (default false, 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 stretched GridCells.Adaptive cells 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. LazyVerticalGrid cannot 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_releases through parseNewRelease, not from FEmusic_home, so they needed their own wiring: YouTube.newRelease() now keeps each shelf's own More endpoint (ExplorePage.releasedMoreEndpoint from the first shelf, musicVideoMoreEndpoint from the last — the same shelves the two lists are read from), and parseNewRelease puts them on the two sections. The video one leads to FEmusic_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, and FEmusic_new_releases_albums answers 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; parseNewRelease now 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 (commit da4f838a) added a fallback for newReleaseAlbums throwing ClientRequestException; 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. parseNewRelease therefore 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.
    • getAlbumMore also calls YouTube.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 hand getAlbumMore that shelf instead.
    • BrowseViewModel is a new Koin viewModel {} definition, and Koin definitions are the global state the Compose Hot Reload entry above says CHR cannot rebuild — restart hotRunJvm before opening the Browse page after pulling this in.
  • "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/setKeepServiceAlive on DataStoreManager + KEEP_SERVICE_ALIVE
    • the SettingsViewModel state
    • the whole block in SimpleMediaService.onCreate
    • media3-ui from core/media/media3, which that block alone used

    The block ran a second PlayerNotificationManager notification beside Media3's own, and restarted while (isActive) { startForeground(); delay(30 s) } on every notification post, on SERVICE_SCOPE (Dispatchers.Main).

    • It was the app's only uncaught startForeground. media3-session's MediaNotificationManager catches ForegroundServiceStartNotAllowedException around 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?.sessionActivity at service creation, before MainActivity binds and sets it, so tapping that notification did nothing.
    • A stored keep_service_alive value simply stays unread in existing DataStore files.
    • Still open next door: #2071, the uncaught ContextCompat.startForegroundService fallback in startService() (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 (RadioQueueTrim in core/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 point loadMore re-points it at the last track's radio and now also re-types it PlaylistType.RADIO (in BOTH of its Endless branches), because from there on it IS a radio.

    • PlaylistType.RADIO does not mean "a radio". Favorites, Downloaded, Most played, Monthly recap, Your top tracks and the Analytics lists are queued as RADIO with playlistId = null, and a playlist's Shuffle is queued as RADIO with the playlist's own id. The first version gated on the type alone and trimmed all of them. RadioQueueTrim.appliesTo now requires the type AND a YouTube radio id: RD…, minus RDCLAK… (a curated playlist, which has an end).
    • reorderShuffledQueue was the stall in #2504, not the appends themselves. loadMoreCatalog adds 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: loadMoreCatalog updates queueData once 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: crossfadeFromIndex indexes 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.remoteToPlaylist holds 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 by mediaId.
    • queueData is cut by the removal's own answer, never by a guess. The adapter calls onRemoved(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 cuts listTracks right there, only if those ids are exactly its front (RadioQueueTrim.afterFrontRemoved). An AtomicBoolean in-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.notifyListeners QUEUES 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 and queueData was not, for good.
      • With queueData longer 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 reorderShuffledQueue bails on a size mismatch, so nothing heals them.
    • Shuffle is excluded on purpose. currentMediaItemIndex and the indices removeMediaItems takes count the UNSHUFFLED playlist, while getCurrentMediaTimeLine() and listTracks are 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_HISTORY is 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 from onCrossfadeStateChanged(false). That retry is posted, never run inline. Android calls onCrossfadeStateChanged from 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.
      • playNext records 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 before getSongInfo suspended, which silently undid a trim that landed meanwhile.
      • The handlers' removeMediaItem/moveItemUp/moveItemDown check the position against the current queue and edit it inside update.
      • 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/replaceMediaItem and Desktop's replaceMediaItem re-check bounds inside their launch (#2156's crash shape).
      • reorderShuffledQueue writes 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#occurrence instead of index + 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: service core/service/loginSync owns the transport and nothing else — invite/QR, AES-GCM, LoginSyncClient (commonMain, ktorExt's engine) and LoginSyncServer + reachableAddresses() (jvmMain, ktor-server-cio); it moves one opaque payload. domain holds LoginSyncHostRepository (Desktop) and LoginSyncSenderRepository (Android) plus model/loginsync types. data holds what the payload means — LoginBundle and LoginSyncStore in commonMain — and the two impls in jvmMain / androidMain, each bound by loadLoginSyncModule() on its own platform (iOS binds nothing); core/data itself imports no Ktor. composeApp holds only LoginSyncHostViewModel / LoginSyncSenderViewModel and 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 GoogleAccountEntity row — each carries its own cookie in cache and netscapeCookie — plus the current cookie/page_id/auth_user/data_sync_id/logged_in), Spotify sp_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. CommonRepositoryImpl rewrites ytdlp-cookie.txt from 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 new sp_dc also 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) with simpmusic-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 commonMain can use the JDK (it targets only Android and JVM — Platform.kt already calls System.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/jvmTest has 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 is NonCancellable so a cancelled caller cannot leave the port open. Do not go back to JDK com.sun.net.httpserver: with its default executor every handler runs on the dispatcher thread, and HttpServer.stop() joins that thread — the first version called it from onDispose on 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. CAMERA is paired with uses-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 + Card on rememberSurfaceDarkColors()): 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" (shouldShowRequestPermissionRationale false after a denial) an Open settings button, re-checked on resume. Trust and service picking stay popups over the sheet. PreviewView must use ImplementationMode.COMPATIBLE: the default SurfaceView ignores Compose clipping, so the rounded viewfinder renders with square corners.
    • Android 17 local network permission: ACCESS_LOCAL_NETWORK is 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 (AOSP ParsingPackageUtils.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 .local name, or — after a DNS lookup off the main thread — an address in AOSP's IPV4_LOCAL_PREFIXES 169.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. isSiteLocalAddress alone reported "no network" on a corporate LAN numbered from 21.x, and excluding point-to-point interfaces threw away the Tailscale address (100.x on tailscale0) — 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, Windows COMPUTERNAME, 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.
  • 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 in Config.OFFICIAL_PACKAGE_NAMES (com.maxrave.simpmusic, .dev) and the signing certificate's SHA-256 must match AllowedAPKSigningKeys in 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() (fdroiddata master, 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 in App(). It starts true and only a successful fetch with none of our keys turns it false, so a failed fetch (or no certificate read at all) never blocks — the app plays offline. A failing copy gets UnofficialBuildScreen inside App()'s own AppTheme (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 to true and nothing there calls the check).

    • The two halves catch different things. Signature-killer tools (MT Manager, ApkSignatureKiller) hook PackageManager into 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's debuggable flag instead — a modder can set that.
    • MainActivity.onCreate reloads viewModelModule, so SharedViewModel is rebuilt whenever the activity is recreated (language or light/dark change) and the signature half re-fetches from true; 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 with secrets.SIGNING_KEY, the release workflow with BASE_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.
  • 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: androidx b4d7ca565f (2025-12-16) removed MACOS_X64 from sqlite-bundled's native target list while retiring the deprecated Kotlin/Native macosX64 target (KT-78660), and the JVM JNI libraries are compiled from that same list; androidx-main still lacks it. sqliteIntelMacNative in composeApp/build.gradle.kts downloads sqlite-bundled-jvm 2.6.2 (SHA-256 pinned), extracts only that dylib and adds it to jvmMain resources — NativeLibraryLoader falls back to getResourceAsStream("natives/<os>_<arch>/…") on its classloader, so everything else stays on 2.7.0 and no runtime code changed. Safe because both versions bind through RegisterNatives in JNI_OnLoad and 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 bumping sqlite, 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 unused n_song plural uses bare %d, which Compose Resources prints verbatim). Hidden while the count is zero; opening LibraryDynamicPlaylistScreen as the new LibraryDynamicPlaylistType.ArtistLiked(channelId) (route artist_liked_<UC…>). Liked only, not downloads: the row says "Liked songs" and downloads already have their own list. DatabaseDao.getLikedSongsByArtist is a Room Flow (unlike the paged getLikedSongs), so liking from the page updates the count at once; it matches the channel id as a quoted token inside the artistId JSON with the same replace() + ESCAPE '\' treatment as deleteUnfollowedArtists — 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 NULL favoriteAt. The row navigates with the route's channelId, 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.newPipePlayer paired formats to URLs with streamsList.find { it.first == itag }, so every dubbed podcast played its alphabetically-first dub. Each googlevideo URL names its own track in the xtags query value (acont=original:drc=1:lang=en-US, acont=dubbed-auto:lang=vi); extractor/AudioTrackOrder.kt stable-sorts the (itag, url) pairs before that find: a track in the preferred language, then the original, then the rest. A single-track video carries no xtags at 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.

    • contentLength must come from the chosen URL's clen. StreamRepositoryImpl requests range=0-<contentLength>, and it read that length off the first Format with 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 as player(…, 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. descriptive tracks (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 in NewFormatEntity is 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 to haze-blur and the presets to haze-blur-materials (package dev.chrisbanes.haze.blur.materials, no opt-in). The mutable Modifier.hazeEffect(state) { … } DSL is gone — a hazeEffect still 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 now hazeBlur(HazeInput.Sources(hazeState), style): Sources is the 1:1 replacement for 1.x's state-based effect (Backdrop is only a native path behind an off-by-default flag, Android 37.2). The 13 tinted top bars share barBlurStyle(tint, tintAlpha) and the 9 plain ones ultraThinBarStyle(), both in UIExt.kt. Neither forces blurEnabled. haze blurs by default on Android 12+ and on Desktop, and everywhere else draws the style's fallbackColorEffect, 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; RecordedHazeBlurStyle has structural equals), so building one per recomposition is free — no remember needed.
    • performanceMode is left at Default, which is now Adaptive (downsamples under load); pass HazePerformanceMode.Quality if 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: composenativetray 1.3.3 (the last release under io.github.kdroidfilter, built on Compose 1.10.3) calls Image.encodeToData$default, which skiko 0.150 no longer has — NoSuchMethodError on the first composition, from Tray(…) rendering its icon, while Gradle reports BUILD SUCCESSFUL. The tray moved to dev.nucleusframework:composenativetray 2.1.6 (Compose 1.12.0), package dev.nucleusframework.composenativetray. Tray/Item/Divider kept their shape.
    • SingleInstanceManager did not disappear in tray 2.0 — it moved to dev.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 the nucleus-notification version) to compile against it. isSingleInstance(onRestoreRequest = …) still exists and still runs before startKoin (#2044); its callbacks now receive the restore-file Path. The README steers to nucleusApplication { } instead — do not take that route without moving startKoin, since that check would then run after Koin has already opened DataStore.
  • 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, and expressiveCardSlotRatio() 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, NOT artworkZoneHeightDp: 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's rememberPresentationState(player).videoSizeDp. Desktop reads mpv's video-params/aspect, observed by MpvPlayer and published as MpvVideoFrameSource.aspectRatio — the frames cannot say it themselves, since mpv letterboxes them to the target size. Living on the frame source, it is re-pointed by MpvPlayerAdapter.currentVideoFrames on every handle swap for free.
    • The Full build's session player only reports what changed. There MAIN_PLAYER is a Media3 CastPlayer (ForwardingSimpleBasePlayer → SimpleBasePlayer): it re-reads the wrapped player on onEvents and fires a callback only for fields that differ from its last snapshot. A view composed mid-video — Now Playing reopened after fullscreen — therefore never hears onVideoSizeChanged, onTracksChanged or onMediaItemTransition for the current item. MediaPlayerViewWithSubtitle started at videoRatio = 16f / 9 and waited for that event, so the video came back stretched to 16:9 ever since Cast wrapping landed (core c310577, 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's rememberPresentationState) before listening.
  • 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 from canvasThumbUrl) 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 .m3u8 Apple 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 PlayerSurface is used with SURFACE_TYPE_SURFACE_VIEW (media3-ui/MediaPlayerView.kt), and a SurfaceView is composited under the window: no Compose alpha, DstIn mask or offscreen layer reaches its pixels. Masked like the cover, the clip ended in a hard edge right under the title. Instead AppleMusicMainView draws a second AppleMusicMeshBackdrop — same mesh, same size, same seam, so it matches the page beneath it pixel for pixel — ABOVE the pager, faded in with appleMusicFadeBetween over 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: a Modifier.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 at alpha × (1 − p)^1.5 (sampled at 24 stops), with alpha from 16% on a black sleeve to 65% on a white one, driven by AppleMusicMesh.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 holds AppleMusicMesh.topEdge — the artwork's top grid row left unshifted, since every row but row 0 is carried sideways — through AppleMusicMeshBackdrop(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. TopBandBlur is gone from this style; the wash stays. Classic/M3E's AppleMusicAnimatedArtworkPage keeps 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 through toReadableBitmap() (expect/actual) because Coil hands back HARDWARE bitmaps on Android, which throw when read or drawn into a software canvas.
    • Every fade is smoothstep — appleMusicVerticalFadeEdges now builds 24 stops on the curve smoothScrimBrush uses; 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 in labelSmall white — 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 own rememberSweepPlayhead, not LyricsView's rememberSmoothPlayhead: 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) is White @55%, not typo()'s body colour: under ForceDarkContent that 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. audioCodecLabel is 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: AudioOutputRouter lists outputs through AudioManager + AudioDeviceCallback and routes with ExoPlayer.setPreferredAudioDevice on every player (createExoPlayerInstance hands 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's audio-device-list/audio-device, applied to every live handle and re-applied in applyPlaybackLevels. Only the pinned driver's devices are offered: mpv lists every driver together, and naming a coreaudio/… device would switch a macOS handle back to the driver that crashes the process on hotplug (see "macOS desktop audio moved to ao=avfoundation"). Cast receivers come from rememberCastRoutes in the cast module (MediaRouter with CastContext.mergedSelector; selectRoute starts the session and unselect(UNSELECT_REASON_STOPPED) ends it, per Google's "Custom Cast Dialogs"), with a no-op twin in cast-empty.
  • 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 by PlayerResponse.VideoDetails.isLive — NOT isLiveContent, 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 as lengthText — so StreamRepositoryImpl.getStream records every resolution in LiveStreamRegistry (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 and SharedViewModel (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_safari and 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_VR 1.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. ANDROID 21.26.364 (YouTubeClient.ANDROID, versions from yt-dlp's INNERTUBE_CLIENTS) played 130 s straight with segments 200 throughout. Ytmusic.liveStreamPlayer sends no cookie and a fresh anonymous visitorData from visitorData() (sw.js_data): with none every client is bot-checked, and ANDROID_VR also refused the session's own stored one. YouTube.player() checks isLive BEFORE the OK-status gate — videoDetails is present even on a refused response — and takes liveStreamHlsUrl() first, then Extractor.liveHlsUrl() (PipePipe/BravePipe tiers returning StreamInfo.hlsUrl, kept apart from newPipePlayer because every tier there rejects a result without regular audio+video itags) as a fallback. tempRes's own hlsManifestUrl is deliberately NOT a fallback: it is the web kind. The response goes out through asLiveStream(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. DefaultMediaSourceFactory picks progressive vs HLS when the MediaItem is set; the URL is only resolved later, in ResolvingDataSource. So the progressive resolver throws LiveStreamDetectedException once the registry knows, CrossfadeExoPlayerAdapter.onPlayerError loads the track again, and MergingMediaSourceFactory then builds an HlsMediaSource on simpmusic-live://playlist/<id>, resolved through a plain-HTTP ResolvingDataSource (no cache, no 5 MiB chunks — segment URLs are real). LiveStreamAwareLoadErrorHandlingPolicy stops 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 in media3/.../mediasourcefactory/LiveStreamPlayback.kt.
    • Desktop: mpv plays HLS itself, so extractPlayableUrl hands it the playlist without the edl:// merge (the playlist carries audio and video). The Linux FFmpeg build disables no demuxers and enables the hls protocol.
    • reconnect_streamed=1 breaks live HLS in mpv. The app's stream-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, which handleTrackEndInternal turns 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=30 alone is fine. MpvPlayer.create(liveStream = true) drops only reconnect_streamed, chosen through PlayableSource.isLive.
    • SharedViewModel's Progress branch treats total <= 0 as still loading, which a live stream on Android (ExoPlayer duration C.TIME_UNSET) would be forever — TimeLine.isLive exempts 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 read TimeLine.lengthLabel() / elapsedLabel() (AllExt.kt) and show LIVE. No lyrics provider is asked for a live track: SharedViewModel.getLyricsFromFormat returns 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 musicTwoRowItemRenderer with subtitleBadges: [{liveBadgeRenderer: {label: "Live"}}] (read off a signed-in response). Badges.liveBadgeRenderer → MusicTwoRowItemRenderer.isLive → Content.isLive → a red chip ahead of the channel name in HomeItemVideo. The badge only decorates the card; playback still learns liveness from stream resolution.
  • Chart: one odd shelf blanked the whole chart (2026-10-01): parseChart built playlists with contents.map { … ?: return null }. A return inside 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 as Chart.podcasts in the Top artists layout, opening PodcastDestination.

  • 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 (in NowPlayingContentAppleMusic.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, M3E surface), so scrolling past it shows no seam. Only a Spotify canvas still fills the page.

    • isAnimatedArtwork() and fullscreenCanvas() live on the shared contract (NowPlayingContentState.kt). The shell's showHideMiddleLayout and every auto-hide trigger read fullscreenCanvas(), 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.5 falloff 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, and typo()'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 hue PaletteStyle.Vibrant blows 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) draws FlatTabBar/FlatSearchButton, and the whole bar grows from the left instead of a circle jumping to the middle. App.kt takes that path whenever getPlatform() == Platform.Android || liquid glass is on.
  • Settings grouped like Apple Music (2026-10-03): every section of SettingScreen is a SettingGroup (ui/component/SettingItem.kt): an uppercase header (the old headers' labelMedium size, Normal weight instead of Bold), the rows on one 20dp-rounded card, and an optional footer note under the card (SponsorBlock's intro, the import tools link). Rows separate themselves: SettingItem draws 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 is surfaceContainer (surfaceContainerHigh read too heavy), except on a light Desktop, whose panel already is surfaceContainer; there it is surfaceContainerLowest (white). The equalizer, delay and reverb panels inside it step up to surfaceContainerHigh so 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. SettingItem is 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 opens ShareTasteSheet, the lyrics share sheet's shell (ShareLyricsSheetHeader/ShareLyricsPalette/ShareLyricsPill became internal for it, and the card's signature row became ShareCardSignature). Layers: TasteInput/TasteProfile/TasteException and TasteRepository in domain, TasteRepositoryImpl in data, AiClient.describeTaste plus TasteReading.kt (prompt, data block, parser, with a commonTest) in aiService, TasteViewModel and ui/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: TasteViewModel gathers names through the queries WrappedViewModel already uses (30 songs, 25 artists, 10 albums, the previous 12 months' top artists, four day-part shares, replay and concentration). Plays credited to a blank channelId are 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"). hasEarlierHistory is 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 and parseTasteReading takes the outermost {…}; providers on the custom OpenAI-compatible path do not all accept json_schema (#2297), and a refused format fails the whole request.
    • Failures come back as TasteException sorted 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/FilledTonalButton must pass color = LocalContentColor.current. The theme typography is typo(), whose styles carry colours, and MaterialTheme provides bodyLarge as LocalTextStyle; a button only merges labelLarge (no colour) on top, so a bare Text keeps onSurfaceVariant instead of the button's content colour — grey on the filled Share button, grey instead of primary on 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 ReceiptLong button sits left of the day-range pill (both orientations), shown only once the period's top tracks and stats have loaded; it opens ShareReceiptSheet (ui/screen/home/analytics/ListeningReceipt.kt). No AI, no new query, nothing in core: every figure comes from the AnalyticsUiState the 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 beside monthFullName), since kotlinx-datetime has no localized names. Every word on the receipt is a string resource, English as source.
    • The name is the DataStore AccountName key 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 inside clipRect, 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 from poppins_medium like every bold in the app — fontFamily() declares only that file, and poppins_bold.ttf is 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, EntryCard in WrappedEntryCard.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 — ListeningReceiptPaper laid 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. ShareTasteSheet passes its palette through the belowImage slot; 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.
  • 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 opens NowPlayingBottomSheet for that track with queuePosition set, 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. QueueBottomSheet hosts the sheet itself and so takes a navController plus onNavigateToOtherScreen (MiniPlayer gained an optional navController, passed by App.kt); the Apple Music queue goes through NowPlayingContentActions.onQueueItemMore to 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. reorderShuffledQueue keeps listTracks in the shuffled play order, and tapping a row already translated it (getUnshuffledIndex in playMediaItemInMediaSource), 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 new MediaPlayerInterface.moveShuffledItem(from, to), which reorders only the adapter's own shuffleOrder (default no-op; implemented in CrossfadeExoPlayerAdapter and MpvPlayerAdapter). Turning shuffle off still restores the original order. The Android swap (drag to reorder) takes the same branch; Desktop's swap already loops over move up/down.
    • Both adapters used to reshuffle the WHOLE order on every edit while shuffle was on — moveMediaItem and removeMediaItem both ended in createShuffleOrder(). Removal now drops just that track from shuffleOrder (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. moveMediaItem still reshuffles, but the handlers no longer call it while shuffle is on; replaceMediaItem also still does.
    • MediaPlayerHandler.moveItemToPlayNext(position) targets the slot right after currentOrderIndex(): current + 1 from ahead, current from behind (the track leaving its slot shifts everything after it). Up/down/play-next share one moveQueueItem, which updates queueData only if the track is still at from — 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.
  • 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 rememberFilePickerLauncher created its native dialog in a LaunchedEffect the 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 unsigned calf_filepicker_native.dll that Calf extracted to %USERPROFILE%\.cache\calf-filepicker on Windows (its loader tries System.loadLibrary first, but conveyor.conf keeps extract-native-libraries off, so the package held no copy); its Linux .so needed GLIBC 2.39; and ProGuard renamed jodd.net.MimeTypes, whose getResourceAsStream(simpleName + ".properties") then missed ("Mime types file missing") on every OS — reproduced by loading f.b.a straight from the shipped 2.2.0 jar. The dependency is gone. Desktop opens files with AWT's FileDialog (showOpenFileDialog in FilePicker.jvm.kt) — GetOpenFileName on Windows, NSOpenPanel on macOS, the GTK chooser on Linux (AWT draws its own where GTK is missing) — and Android with SAF OpenDocument, which is what restore used before Calf (bb1e7156). filePickerResult(onResultUri) hands back a content Uri on Android and a plain path on Desktop: exactly what restoreNative opens, and what the new readPickedFile (playlist import, beside restoreNative in the SettingsViewModel files) opens.

    • FilenameFilter does work in the Windows FileDialog, whatever the javadoc says: OpenJDK's dialog hook handles CDN_INCLUDEITEM and asks WFileDialogPeer.checkFilenameFilter about 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.readScope only tryLocks, and DataStoreImpl reads anyway when the lock is busy. A read that arrives mid-save goes to disk, because the cached version is behind between incrementAndGetVersion() and the cache update inside that save. That read opens the real file with FileInputStream, which the JDK opens without FILE_SHARE_DELETE (io_util_md.c). On Windows, Files.move(REPLACE_EXISTING) begins with DeleteFile(target) (WindowsFileCopy.move), so the save fails. DataStore swallows the real error and blames "multiple instances". The save had been launched on SERVICE_SCOPE, which has no handler, so CrashDialog ended the process. createDataStoreInstance() on JVM now builds the store as PreferenceDataStoreFactory.create(storage = ReadsWaitForWritesStorage(FileStorage(PreferencesFileSerializer) {…})), which holds one Mutex across readScope and writeScope. 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 adds ATOMIC_MOVE and stops swallowing the error. The unlocked FileInputStream read is still on androidx-main, and b/203087070 carries a report of the crash on 1.3.0-alpha10. What ATOMIC_MOVE would 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 DeleteFile and MoveFileEx gets FileNotFoundException ("cannot find the file specified"). FileReadScope.readData then checks file.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. skipSegmentsJob asked sponsorBlockEnabled.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, because getSponsorBlockCategories() reads each of the nine categories. The check now reads sponsorBlockCategories, a MutableStateFlow that a collector in init keeps current from those settings (null while SponsorBlock is off or not loaded yet), so no tick touches DataStore. Android's MediaServiceHandlerImpl had 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() and writeScope, 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.
  • 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 vivoAndroidApp therefore builds androidApp unchanged under com.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"] points manifest, java, kotlin and res at androidApp. Both java and kotlin are set, because androidApp keeps its Kotlin under src/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 .dev suffix so it can be tried on a vivo phone.
    • Its src/release/res/xml/shortcuts.xml overrides androidApp's, and src/debug holds a copy. androidApp's copy hard-codes targetPackage="com.maxrave.simpmusic", which would open the wrong app, or nothing, from the vivo build. A debug build needs the override too, since it keeps com.spotify.music (no .dev suffix), so keep the two copies in step.
    • It passes the unofficial-build check only because both layers hold. com.spotify.music is in Config.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 --vivo flag switches the module and output directory and names the file SimpMusic-full-vivo-….apk. CI job build-vivo-release in android-release.yml uploads it as app-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, because AppIdentity takes the real applicationId.
      • The rolled-back alternative (a Shizuku switch that wrote vivo's list from the regular app) needed Developer options, which users rejected.
  • 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.kt gates it on nativeTitleBar = !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 PowerShell Get-CimInstance (then wmic) inside remember {} during the first composition, so every Windows launch waited for it before the window appeared. The -Dcompose.window.no-transparent override went with it, since all it did was force Windows onto the native bar. getScreenSizeInfo() subtracts nothing there, because native decorations sit outside containerSize. 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/e is also written to files by Kermit's kermit-io RollingFileLogWriter (new dependency, same 2.2.0 as Kermit). Logger.enableFileLogging(dir) in core/common adds two writers: app.log takes Info and above (1 MB per file, 3 files), error.log takes 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 in SimpMusicApplication.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 files tail -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 to shareImage (which gained a mimeType parameter, PNG by default).

    • Two formats are coupled on purpose. Logger's LogLineFormatter writes L/Tag: message after the writer's ISO timestamp, and AppLogFiles.kt's HEAD_LINE regex parses exactly that; a stack trace is every following line without a timestamp. Logger.logFiles() mirrors RollingFileLogWriter's own naming (<name>.log live, <name>-N.log older). 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-io 2.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 runtime NoSuchMethodError. Re-check on a Kermit, Ktor or datetime bump.
    • Crashes (uncaught exceptions) do not pass through Logger, so they are not in error.log yet.
    • Warn is not a debug level. Since the files exist every Logger.w lands on disk, and the codebase had used w as its debug level (426 calls against 53 i): 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 to Logger.d, and BaseViewModel.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: a GoogleAccountEntity (its cache/netscapeCookie are 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/setUsedAccount and LoginSyncStore for YouTube, 401/403 from the Ytmusic and Spotify clients (HttpResponseValidator, host and path only), SpotifyAuth.refreshToken (an isAnonymous token 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 no distinctUntilChanged(), 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.
  • 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/NetworkFirstInterceptor from composeApp/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 through CacheNetworkResponse.readFrom and carries that same timestamp in the RAM value's simpmusic#image_response_millis extra. Warming RAM from disk must not replace that timestamp with the current time. Unknown/invalid timestamps are stale but remain usable for fallback. CacheNetworkResponse is an internal Coil API: re-check its metadata reader on a Coil bump. The injected clock and ttlMillis = 0 keep 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 through DiskCache.Editor to the original key only after decoding succeeds. It repairs the RAM entry's source key and removes the temporary entry in finally, 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 separate ImageLoader(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.kt has 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.
  • 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 (SleeveFan in LibraryTilingBox.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 (already LIMIT 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 private tasteCardColor(), 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).
  • 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), and itemSize is COLLECTION_STYLE_ITEM_SIZE_MEDIUM on every shelf, so it carries no information. The header button is "More" when it holds a browseEndpoint and "Play all" when it holds a watchEndpoint. The Play all endpoint has videoId + params and no playlistId, and /next turns it into a finite TLGG… queue with no continuation. HomeParser used to read only the browse form, so Play all was dropped. These now land on HomeItem.itemsPerColumn and HomeItem.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 (HomeDiscoverSection in HomeSectionStyles.kt, M3 HorizontalCenteredHeroCarousel, which the pinned material3 ships). It applies to any card shelf of songs only that has no More page and no itemsPerColumn.
      • It shows one large card with narrow neighbours, the title on the art over a colorScheme.scrim gradient in ForceDarkContent. 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's heroKeylineList picks 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.
    • Spotify-style hover on Home cards (rememberCardHover + HoverPlayButton in AdapterItems.kt): pointer hover or keyboard focus tints the card with onSurface and raises a primary / onPrimary play 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.playEndpoint through BaseViewModel.playFromEndpoint (an RD… id is queued as a radio, anything else as a playlist). That endpoint is the card's own play target, read off thumbnailOverlay…musicPlayButtonRenderer.playNavigationEndpoint: songs get RDAMVM…, playlists PL…, albums OLAK5u…, 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").
  • Linux slice: libbz2 bundled, the AppImage excludelist left to the host (2026-10-08, issue #2462): scripts/mpv-linux/stage.sh listed libbz2.so.1.0 in SYSTEM_LIBS, but that soname exists only on Debian/Ubuntu (Fedora/RHEL ship libbz2.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 under RTLD_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 symlinked libbz2.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 the abc asset. CI only downloads the tarball pinned in mpvNativesChecksums, so a change to stage.sh reaches users only after that rebuild, upload and re-pin. Docker 29 (containerd image store) resolves a plain FROM ubuntu:22.04 to the host's arm64 image even after docker pull --platform linux/amd64, so on Apple Silicon build from a copy of the context whose FROM names the amd64 manifest digest, then check that e_machine == 62.
  • Launch banners from the remote config (2026-10-10): Home can show image-only promo banners, at most one per launch, defined by a promos array in the same remote-config.json that already carries the TIDAL credentials. Each entry is id (required: names the banner), image (language code → portrait banner URL; a missing or blank language falls back to en, and the code is the BCP 47 tag's first part, since Android's Locale.language still says in/iw for Indonesian/Hebrew), link (a simpmusic:// deep link, routed through the same App.kt router as web and widget links, or an https:// page opened with openUrl; an entry with any other link is skipped, since a tap could not act on it — one check, isAppLinkAvailable(link, …) in utils/AppLinks.kt), and optional minVersion / requires (only local_tracking is 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 in core/domain/.../model/promo/Promo.kt, selection in utils/PromoSelection.kt (pure, tested in jvmTest), UI in ui/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_shown holds 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 AsyncImage in 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. CommonRepositoryImpl stores the raw promos block 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() raises isCheckingUpdate before 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 everything App.kt raises — update dialog, both notification-permission prompts, the sleep timer — folded into LocalAppDialogOpen), 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 (promoClearFor holds 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.kt sets LocalUserStarted on the first press or wheel scroll anywhere in its Scaffold (watched in PointerEventPass.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 is rememberSaveable (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 (leading v stripped) 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 leading v. Numbers are compared part by part with any -suffix dropped, 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.DEBUG on Android, -Dsimpmusic.dev=true set by desktopApp's JavaExec tasks on Desktop) and keep the promo block and the shown list under _dev keys, 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> is LibraryChipType.toStringValue() (wrapped for Wrapped), and simpmusic://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, which library?tab=your_library cannot say). The tab travels as SharedViewModel.requestLibraryTab(), which LibraryScreen applies and clears; LibraryViewModel no 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.kt isAppLinkAvailable(), used by both the router and banner selection so they cannot disagree: Analytics, the Wrapped chip and taste need 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's when) 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 no requires for any of this (requires remains for conditions a link cannot express). The router reads both settings BEFORE setIntent(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's simpmusic://library always has, so the tab bar still highlights Home after one (the bars move their highlight only on a tap). Syncing the bars from currentBackStackEntry was 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 Promo at 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 being promo_shown already listing the banner for this version.
  • 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) returns RADIO, ENDLESS_QUEUE or null. RADIO wins, since turning Endless queue off would not lift that block. Radios and mixes are recognised by the id alone, through String.isEndlessQueueId() (RD… minus curated RDCLAK…, VL stripped), which RadioQueueTrim.appliesTo now shares. The type is not checked because a mix opened from its playlist page is queued as PLAYLIST.
    • Every Add to queue button reads it through rememberAddToQueueBlock() and addToQueueLabel() in ModalBottomSheet.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.
  • 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/PostCreate into every ContentProvider.onCreate, androidx.startup.InitializationProvider among them. FOSS ships crashlytics-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 with NoClassDefFoundError: Failed resolution of: Lio/sentry/android/core/performance/AppStartMetrics; from InitializationProvider.onCreate. The FOSS branch of the sentry {} block now sets tracingInstrumentation.enabled.set(false), which keeps the plugin from registering its class transform at all (if (tracingInstrumentationEnabled) in its AndroidComponentsConfig.kt).

    • ignoredFlavors.set(setOf("foss")) in that block never excluded anything: the project has no product flavors — Full/FOSS is the isFullBuild property — 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 (AOSP BaseLocale.USE_NEW_ISO_LOCALE_CODES); Android ≤ 14 still reports iw/in; Compose Resources match the folder name exactly. Crowdin exports he/id, so values-he and values-id are the live folders and values-iw/values-in serve old phones only — nothing updates them unless they are copied.
    • Playback and time controls stay left-to-right through LeftToRight { } (extension/UIExt.kt, a LocalLayoutDirection.Ltr provider): 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/Right on weighted halves) collapsed onto its centre label, and M3E's WavySeekBar seeked backwards: its touch math is x / width while 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.resolveTextDirection maps TextDirection.Unspecified to Rtl, 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 sets TextDirection.Content plus TextAlign.Right in RTL layouts only — Content alone would also move Latin text to the left edge, since Compose aligns Start by the paragraph, unlike a TextView's gravity. Lyrics are unaffected: every lyric line sets its own direction and alignment.
    • Smaller fixes: MoodCategoryCard drew 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 Glance TurntableWidget cannot 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 when Resources.getSystem() is RTL.
    • Compose Desktop goes right-to-left too: GlobalLayoutDirection comes from Locale.getDefault() through AWT's ComponentOrientation.
    • Left as is: marquee titles scroll the RTL way for Latin text (basicMarquee follows 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 turns AppStartMetrics into io.sentry.android.core.performance.g); a reference to a missing class keeps its original name.
  • Dark theme is no longer AMOLED black (2026-10-15): isAmoled is a MaterialKolor parameter, not an app setting, and it is gone from both rememberDynamicColorScheme calls in Theme.kt; the Wallpaper scheme (PlatformColorScheme.android.kt) also stopped pinning background/surface to black by hand. In a dark scheme isAmoled swaps exactly four roles — background/surface to #000, onBackground/onSurface to #FFF — so the dark page is now MaterialKolor's tone 6 (about #131313, tinted by the seed) and typo() titles, which read onBackground, 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.Black as a page or bar colour; use colorScheme.background. Four blacks only looked right because the page was black: the liquid-glass backdrop base in App.kt, the SongSelectionTopAppBar fill on the library dynamic playlists (now with contentColor = onSurface, since the bar defaults to white text), and the fill behind a loading chip in InfiniteBorderAnimationView. The cold-start windowBackground is #131313, the neutral grey at tone 6.
    • The reverse bites too: a Material default sitting on a fixed-colour surface. NavigationRail defaults to surface, which was black only under AMOLED, so on the pure-black Desktop window (desktopWindowDark) it turned into a seed-tinted grey strip. It is now containerColor = Color.Transparent and 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).
  • 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 carry MUSIC_VIDEO_TYPE_UGC like any upload, so musicVideoType cannot tell them apart.

    • Parser: isLive on MusicResponsiveListItemRenderer, MusicTwoRowItemRenderer and PlaylistPanelVideoRenderer, copied into SongItem.isLive/VideoItem.isLive at all 17 places those are built. Search's top card is not parsed: the app never calls SearchSummaryPage.
    • Models: Content.isLive is a field; Track and SongEntity carry it as resultType == LIVE_RESULT_TYPE ("LIVE"), read through the extension properties Track.isLive/SongEntity.isLive in ModelToEntity.kt. No migration: resultType was dead for songs (labels nothing reads, no query filters on it). Not videoType: both handlers rewrite it from YouTube's value on every play, so a marker there lasts one play. SongsResult/VideosResult/ResultVideo use the same marker; ResultSong has no resultType, so it got a plain isLive. Keep the extension properties off the entity class, where Room would have to decide whether a getter is a column.
    • The stored row: insertSong is IGNORE, so LocalDataSource.insertSong raises the marker itself (markSongLive): on an existing row when the incoming one is live, and on any row whose stream LiveStreamRegistry already 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. LiveStreamRegistry still decides playback routing; the row is for the UI and the download gate.
    • UI: the shared LiveBadge (ex-private in AdapterItems.kt) sits ahead of the artist in SongFullWidthItems (all 16 song-row call sites, both queue views included), QuickPicksItem and SuggestItems. SongFullWidthItems ORs 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); both downloadTrack implementations 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.download fails 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.
  • "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. CacheRepository gained getFullyCachedSongIds() (audio keys only — the video stream is cached under a MERGING_DATA_TYPE.VIDEO key — and only those isFullyCached, so a track skipped halfway is left out) and removeCachedSong(); 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 shared loadCachedSongs. 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) on STATE_COMPLETED in DownloadUtils); the bytes are only written to both caches while the download runs, because the download reads through a CacheDataSource on 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.
  • 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.onStart and when the switch or the count changes in Settings. It runs at most once a day (smart_downloads_last_run is 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 in provideGlance, so it can be created with the app in the background, where the pass's startService (Media3 sendAddDownload(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_event adds min(1, listenedSecond / durationSecond) (1 when the length is 0) × 0.5^(ageDays / 14); ties go to the most recent play. Summed in Kotlin over SongPlaySample rows whose timestamp is LocalDateTime, for the same reason as PlaybackSample.
    • 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 reads category (the mappers just copy it) and nothing rewrites a whole song row (insertSong is IGNORE). Reading category for something else, or adding a REPLACE/@Upsert on song, 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 and downloadTrack the top N not downloaded yet; then evict FIFO by downloadedAt (in-flight downloads sort last, and a song in today's top N is never evicted) with removeDownload first 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; PlaylistViewModel does 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 startService refused after the app went to the background just ends the pass. isOnUnmeteredNetwork() reads a SecurityException from getNetworkCapabilities (b/163342798, the same guard WorkManager's NetworkStateTracker has) 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.TerminalStateNotificationHelper checks the marker first. The download service's progress notification still shows.
  • 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 in CommentsViewModel). It is a ModalBottomSheet with skipPartiallyExpanded = 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 to LoginDestination.

    • Source: YouTube Music's own Comments tab. next(WEB_REMIX, videoId) answers tabs Up next | Lyrics | Comments | Related. Lyrics and Related carry a pageType, Comments does not, and its title is translated ("Bình luận"), so it is found by type: the only tab holding a sectionListRenderer that reloads from reloadContinuationData (CommentsPageParser.commentsTabToken). Turned off, or a live stream with only its chat: the tab holds a messageRenderer and is unselectable, 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 frameworkUpdates mutations under those keys: commentEntityPayload (text, author, likeCountNotliked/likeCountLiked), engagementToolbarStateEntityPayload (likeState, heartState) and engagementToolbarSurfaceEntityPayload (the like/unlike/dislike/undislike performCommentActionEndpoint actions and the reply command's createReplyParams). 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 is commentRepliesRenderer.subThreads[0] on YouTube Music, contents[0] on the WEB client. A replies page lists replies as threads on YouTube Music and as bare commentViewModels on WEB; the parser reads all four.
    • Writes (Ytmusic.kt): comment/create_comment with the header comment box's createCommentParams, comment/create_comment_reply with createReplyParams, comment/perform_comment_action with one action token, all through ytClient(WEB_REMIX, setLogin = true). Signed out the toolbar carries a prepareAccountCommand instead 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, then att/log) is run.
    • Comment text goes through DescriptionView, which gained an optional color (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() inside Modifier.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. track is looked up in the queue list once, when an item starts, and is read again only when the media id changes (onMediaItemTransition skips 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: updateCatalog empties listTracks, 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 on track?.videoId made 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 logs Comments for <id>: <count> under the tag Comments.
    • Parser test: kotlinYtmusicScraper/src/commonTest/.../pages/CommentsPageParserTest.kt, synthetic JSON in the real shapes.
  • 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.background already parsed it for Podcast pages. getHomeData now returns it as the third value of its Triple, and HomeScreen draws it behind the first shelf in both themes. Continuations carry no image, so getHomeDataContinue stays a Pair.

    • 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.
  • Create button on Home (2026-10-22, issue #2596): a "+" (CreateActions in ui/component/CreateSheet.kt, after Spotify's Create) opens a sheet with Local playlist and Listen Together. Local playlist opens CreateLocalPlaylistSheet, the name sheet that used to be written inline in LibraryScreen and 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 with ListenTogetherIconButton) 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. addToQueueBlock returns null for an empty or null queue, so Endless queue cannot block the first Add to queue, and addToQueueLabel() (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, and clearMediaItems() leaves localCurrentMediaItemIndex at -1. addMediaItem never moved it, so the handler's prepare() on the first appended item loaded nothing, and Add to queue into an empty queue did nothing on either platform. pointAtFirstItemIfUnset() in both addMediaItem overloads gives ExoPlayer's own behaviour.
  • Desktop sidebar replaces the rail (2026-10-22): on Desktop (isDesktopShell) the window's left edge is DesktopSidebar (ui/component/DesktopSidebar.kt), a second rounded desktopPanel beside the content panel, after Spotify's Your Library. Tablets keep AppNavigationRail. Navigation (Home, Mix, Analytics, Search) sits on top; Library leaves the navigation because the "Your library" header IS the Library tab (same selectTab, same BottomNavScreen.Library.ordinal). Under it: Favorite / Downloaded / Most Played pinned (counts from getLibraryOverview()), then local playlists, saved playlists, saved albums, followed artists and favourite podcasts as one list (LibrarySidebarViewModel, five Room flows combined), 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 string desktop_sidebar_collapsed through the generic getString/putString, so core was not touched. The playing row is matched by the queue's playlistId (LOCAL_PLAYLIST_ID + id for local playlists, browseId for albums, podcastId for 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 a layout {}, not fillMaxWidth(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 80dp NavigationRail left (80dp is NavigationRailItemWidth = NavigationRailCollapsedTokens.NarrowContainerWidth in the pinned material3; the 96dp ContainerWidth token 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 use LocalWindowInfo.containerSize: it is not the Row's width.

🔄 CLAUDE.md Auto-Update Rule (MANDATORY)

After completing any of the following types of changes, the AI agent MUST update this CLAUDE.md file:

  1. Architecture changes: Module additions/removals, dependency changes (e.g., library swaps like GStreamer → VLCJ), build system changes
  2. New major features: New modules, new service integrations, new platform capabilities
  3. API/Technology migrations: Swapping core libraries, changing data flow patterns
  4. Build/CI changes: New build variants, changed packaging formats, CI workflow changes
  5. 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