Skip to content

Latest commit

 

History

History
executable file
·
102 lines (86 loc) · 48.3 KB

AGENTS.md

File metadata and controls

executable file
·
102 lines (86 loc) · 48.3 KB

AGENTS.md — ArchiveTune (vossgraves fork of 4nx3b/ArchiveTune)

Active development on dev, tracking 4nx3b/ArchiveTune@dev. Any agent working here must preserve the invariants below.

Mandatory reading order (every task, before any coding)

  1. rules.md (repo root) — the repository owner's standing rules, in full. The non-negotiables:
    • Never compile locally. Push to dev, monitor the CI build for 7 minutes (scripts/poll_ci_sha.sh <head-sha> 7), and fix it if it fails.
    • Senior Engineer Mode + Strict Mode: understand before changing, smallest complete solution, zero regressions, evidence-driven verification, adversarial self-review, closed-loop process (Inspect → Understand → Plan → Implement → Integrate → Verify → Review).
    • Always maintain AGENTS.md and worklog.md. Always use English.
  2. AGENTS.md (this file) — fork invariants, module layout, build/test contracts, dependency gotchas.
  3. worklog.md — what previous tasks did; append an entry for the current task before finishing (format: Task ID / Agent / Task / Work Log / Stage Summary).

Fork invariants — never break

  • Tidal source: app/src/main/kotlin/moe/rukamori/archivetune/tidal/ (TidalAudioProvider, TidalAccountManager, TidalInstanceHealthManager, TidalDns, TidalArtworkProvider), Tidal settings/login UI, utils/tidal/; instance racing documented in INSTANCE_RACING.md. Live progressive-DASH streams use the tidal-dash:// scheme routed in MusicService.

  • Multi-source audio: providers live in top-level packages tidal/, deezer/ (DeezerCrypto + Media3 decrypting DataSource), qobuz/ (+ QobuzBackupProvider via the kouzu.in mirror), spotify/; shared contract (DirectStream, TitleMatch, source priority) in audiosource/. Playback resolution goes through resolveMultiSourceDataSpec in playback/MusicService.kt; YouTube is the final fallback. Do not rewire playback around this.

  • Spotify is catalog + OPTIONAL experimental direct streaming: spotifycore/ + app spotify/ provide metadata, artwork, search, playlist import and track-to-YouTube identification. AudioSourceType.SPOTIFY (default OFF, SpotifyDirectEnabledKey) adds direct streaming from Spotify's CDN with two selectable resolve methods (SpotifyResolveMethodKey, bottom-sheet picker in the Sources page): Stopify (spotify/SpotifyWebStreamer.kt — Dealer WS → device registration → connect-state play command → storage-resolve; the device registers BOTH file_ids_mp4 and file_ids_mp4_dual manifest formats and the Dealer handler always prefers the standard file_ids_mp4 entry — never register only the dual format, those files decode to silence; Widevine through MusicService.buildSpotifyDrmManagerOrNull (per-player manager cached inside createMediaSourceFactory, never shared between the primary and crossfade players)/SpotifyLicenseCallback with default security level and standard provisioning — do not force L3 or throw on provisioning; anti-mute playback reporting on start/30s/finish via reportPlayback driven from updateSpotifyPlaybackReporting in onMediaItemTransition) and Echo Music (spotify/echo/ — desktop device-pairing token refreshed against the live app version from loadspot.pages.dev with a web-persona client-token fallback, hand-rolled extended-metadata protobuf for the audio file list, storage-resolve, PlayPlay key; echo-spotify:// scheme routed to EchoDecryptingDataSource, an AES-128-CTR decrypting DataSource modelled on the Deezer one; Ogg payloads skip the 0xA7 custom header, FLAC starts at 0 — this mirrors librespot's SPOTIFY_OGG_HEADER_END behavior; Echo failures fall back to the Stopify pipeline). Keep the experimental gate + ban warning UI in sync with this.

  • SoundCloud source: soundcloud/SoundCloudAudioProvider (MetroFusePlus port, AudioSourceType.SOUNDCLOUD, default OFF) resolves anonymously via scraped client_id; the playback OkHttp client's interceptor strips the _archivetune_soundcloud* marker params and injects browser headers. Progressive transcodings are played directly; HLS-only tracks ride the soundcloud-hls:// scheme (base64-encoded playlist URL) routed to playback/SoundCloudHlsDataSource, which synthesizes a progressive TS stream from the playlist segments — the same pattern as tidal-dash://. Never return a raw https://…m3u8 URL from the resolver: the lazy DataSpec resolution feeds HLS playlists to progressive extractors and fails. Search-category playback pins MediaMetadata.preferredSource + soundCloudUrl (direct permalink → trusted resolve).

  • Playback client policy: AutoChoosePlaybackClientKey + YTPlayerUtils own bounded YouTube client selection/fallback (incl. video-scoped PO tokens and the yt-dlp layer). Manual mode must remain available.

  • Telegram streaming: telegram/ (TDLib wrapper + TelegramDataSource), browse/settings/login UI. Playback routed by the telegram:// branch in MusicService's SchemeRoutingDataSource, independent of the multi-source chain. Channels materialise as local playlists (LPtg<chatId>); artwork via the Coil tgart:// fetcher. api_id/hash via BuildConfig with public Telegram Desktop fallback.

  • CI signing: workflows sign with the committed app/persistent-debug.keystore when the KEYSTORE secret is absent. Do not let upstream workflows overwrite this.

  • Listen Together: LAN + vivimusic public servers only. No koiverse REST/WS path and no *.koiverse.cloud / raw.githubusercontent.com/koiverse/* call anywhere in the tree.

  • Flamingo player canvas morph: ui/player/flamingo/FlamingoPlayer.kt renders the canvas through the SAME shared-element system upstream's AppleMusicPlayer ships — FlamingoCanvasStage lives inside the Album branch of SharedTransitionLayout/AnimatedContent carrying sharedBounds(ShareAlbumKey) (non-bouncy spring, StiffnessMediumLow, 600ms symmetric cross-fades, AdaptiveCornerShape overlay clip), mirroring AppleMusicMiniHeader's artwork in FlamingoPlayingBar. The canvas ExoPlayer is disposed with the Album branch on page changes and recreated on return (upstream's documented tradeoff; the blurred backdrop instance in the background layer is the retained one). The backdrop twin's surface NEVER retires on page changes (visible stays true — only isPlaying gates via canvasSurfacesPlaying): a retired TextureView records empty snapshots and the bake goes black, which is the "canvas disappears after lyrics/queue" bug. canvasBackdropReveal gates ONLY on the canvas being visually active (canvasVisualActive && !landscape, upstream's model) — NEVER on any readiness callback, neither the stage's nor the twin's: one-shot onFirstFrameRendered/onPlaybackAvailabilityChange latches de-latch permanently when availability flaps false without a re-fire (paused players never re-render a "first frame" on resume), which was the second "canvas disappears after returning from lyrics/queue" bug. The twin bake health gate (5 consecutive failures drops the twin player) RETAINS the last good baked bitmap and retries the snapshot every FlamingoCanvasBakeRetryCooldownMs (2s) — the old zombie loop nulled the bitmap and never recovered, a third disappearance path; a retained frozen blur beats an empty band, and a successful retry re-composes the player and restores live baking. The twin band fades asymmetrically (420ms out when a page takes the backdrop, 650ms back in) and its top edge is feathered (FlamingoBandTopFeatherBrush DstIn over an Offscreen-composited layer) so the frost band melts into the moving-blur background with no hard seam during morphs. The sharp stage's bottom dissolve (FlamingoSharpStageFadeBrush, DstIn from 0.62) and the frost fraction derive from the same constant, as in upstream's fadeBottom. Do NOT reintroduce a custom offset/layout flight between the stage and the bar slot — three such rewrites shipped jank before the upstream port. The canvas backdrop twin NEVER carries a runtime Modifier.blur/RenderEffect on the live TextureView (that is the morph "lines" artifact source): it renders upstream's snapshot model — the tiny twin is recorded into a GraphicsLayer via canvasSnapshotSource every 50ms, baked into a blurred bitmap (ImageBlurUtils + clamp-compensating downscale) and drawn as an opaque Image over the twin. The moving-blur background (FlamingoBackground.kt) is a Compose port of Flamingo's KenBurnsView (RandomTransitionGenerator rect math, MIN_RECT_FACTOR 0.75, AccelerateDecelerateInterpolator) at the fork's tuned 3.5–11s transition pace — keep the pace, keep the math.

  • Protected files: applicationId (moe.rukamori.archivetune) is fork identity — never adopt upstream changes to it. Koiverse.jks*, ArchiveTuneKoiverseServer.txt, DataServer.txt were removed and must never be restored.

  • SukiSU liquid glass navigation bar: ui/component/sukisu/SukisuLiquidNavBar.kt + SukisuGlassSupport.kt are a SEPARATE nav-bar style (SukisuLiquidGlassNavBarKey, mutually exclusive with frosted/tint/liquid-glass/bitchord in NavigationBarSettings). They port SukiSU-Ultra's FloatingBottomBar (ghost accent row recorded into a LayerBackdrop + rememberCombinedBackdrop, kyant lens refraction, gravity-quantized specular highlights, SukisuInteractiveHighlight AGSL spotlight, rubber-band drag on LiquidGlassDragAnimation). The bar's geometry is HARDCODED to the normal navbar's liquid-glass dimensions — 64dp bar, 4dp item padding, FloatingNavigationBarMaxWidth cap, fillMaxWidth + horizontal system-bar insets, M3 ShortNavigationBarItem items, 56dp tab-width pill — and reads NO navbar sizing preferences (only the app-wide HideNavigationBarLabelsKey). The ghost accent row composes BELOW the interactive row with enabled = false items — it must NEVER carry clickables or sit above the interactive row (alpha-0 clickables still intercept touches). onItemClick's Boolean is the clicked screen's CURRENT selected state — false for a tab switch; passing true unconditionally makes every tap a reselect (scroll-to-top, no navigation). Never merge this path into the normal FloatingNavigationToolbar liquid-glass bar or share tuning state with LocalLiquidGlassTuning.

  • Back animation style: BackAnimationStyleKey (DEFAULT/PREDICTIVE, enum in constants/PreferenceKeys.kt) is provided app-wide via LocalBackAnimationStyle from MainActivity. PredictiveStyleBackHandler + rememberPredictiveSheetBackState (ui/component/PredictiveBack.kt) are the ONLY sanctioned back-handler wrappers for sheets (BottomSheet, BottomSheetPage, BottomSheetMenu use them); the progress Animatable must stay hoisted OUTSIDE any conditional composition so the cancel/commit reset survives handler disposal. The player sheet's back stack is owned by MainActivity's two shields composed AFTER the NavHost (they outrank NavHost's built-in predictive handler): a PredictiveStyleBackHandler for PlayerBackTarget.SHEET (sharing playerSheetPredictiveBack with the sheet's graphicsLayer via BottomSheet's predictiveBackState param) and a plain BackHandler for PlayerBackTarget.SUB_PAGE that routes through PlayerBackController.routeBack (inline lyrics → style-page epoch → queue sheet → collapse). Player.kt publishes the target from one derivedStateOf via SideEffect — never add another plain BackHandler above the NavHost that collapses the sheet, it starves both the predictive gesture and style pages. In PREDICTIVE mode the NavHost's own transitions are slide-based so its built-in predictive pop scrubbing (navigation 2.9.8 SeekableTransitionState) has visible geometry: sub-page pushes slide in from the right with the previous screen parallaxing left (exit -it/3), pops slide the current screen out to the right with the previous sliding back from -it/3; tab switches (target or initial in topLevelScreens) keep the classic fade+scale. DEFAULT mode keeps the fade+scale everywhere.

  • SukiSU About page: AboutScreen.kt renders over SukisuAboutBackground (ui/screens/settings/about/) — the OS3 blob-field AGSL shader, 60fps node loop, spring color cycling, full-alpha (never scroll-faded: the header haze must have the moving gradient to blur, like home's atmosphere background). The top bar is a custom Box bar with the title screen-centered (miuix SmallTopAppBar model), inset from LocalStableSystemBarsTopPadding (cutout-aware — WindowInsets.statusBars alone lets the header collide with notches). The scrolled header treatment is the app-wide ScreenHeaderHaze (home-page model: HomeTopFadeBlur ultraThin progressive haze + scrim, 220ms intensity animation on listState.canScrollBackward) — the Scaffold body (SukisuAboutBackground + scroll content) is the hazeSource, the overlay is a sibling after it, and the kyant aboutBarBackdrop band is gone. All sections are SukiSU glass cards (AboutGlassCard: drawBackdrop blur 60f + blend-mode tints, transparent fill over the shader background; fully-opaque surfaceContainer fallback when glass is unavailable — never a semi-opaque in-between) containing plain ripple rows (AboutGlassRow); no material-expressive segmented shapes or Badge pills on this page. The hero shows a single v<version> text line from displayVersionName (BuildInfo.kt — nightly builds render 17.0.<versionCode>-<shortHash> (<N-tag>) from BuildConfig.NIGHTLY_TAG, fed by the nightly workflow). The links card refracts that background through the aboutGlassBackdrop LayerBackdrop; the parallax logo stack tracks element Y positions captured once (onGloballyPositioned guards != 0f). Keep the sukisu physics when touching this page.

  • Compact navbar search pill scope: MainActivity's compact control row hides its end search circle on settings/about, settings/misc (Developer options) and settings/update (compactSearchCircleHidden) — terminal info pages carry no search action; compactReserveEndControl follows the same flag so the compact miniplayer reclaims the space for transport controls there.

  • About page compact list + render gating: the About sections are compact glass rows with inset hairline dividers (AboutRowDivider, start inset 76dp aligned with the text column, never edge-attached); the links card ALSO carries the translation-contributors and license rows (the standalone project-info card is gone — fewer simultaneous drawBackdrop blur surfaces during scroll). SukisuAboutBackground pauses its 60fps AGSL frame loop and its color-stage spring while its renderActive is false — the About route provides the nav-transition half from LocalSukisuBgRenderActive and AboutScreen ANDs it with the logo-follow alpha (sukisuBackgroundAlpha(scrollProgress): full until 25% of the logo height is scrolled, fully gone when the logo has left) so the moving gradient only exists while the hero logo is on screen and the shader stops when it is not. The user explicitly asked for this scroll-fade — do NOT restore a constant full-alpha background here.

  • PaxSenix JSON-envelope TTML lyrics: the PaxSenix proxies return {"type":"TTML","content":"<tt …>"} — a JSON envelope around the TTML document. LyricsPayload.isTtmlDocument must require an XML prefix (startsWith("<tt") OR startsWith("<") && contains the TTML namespace) — the namespace CONTAINS check alone false-positives on the envelope (the escaped content string carries the URL) and the raw JSON got stored and rendered as plain lyrics. LyricsUtils.normalizeLyricsTextadditionally unwraps JSON envelopes whose first char is{/[viaLyricsPayload.unwrap` (pass-through when the value is not valid JSON, so LRC is untouched) — this sanitizes stale DB rows written before the write-path fix.

  • Karaoke lyrics follow has ONE owner: the accompanist KaraokeLyricsView (lyrics-ui 1.0.19) auto-follows the focused aggregate on the SHARED listState and anchors it at offset + keepAliveZone below the viewport top — LyricsEnhanced must NEVER scroll that state itself. The old app-side scrollLyricIntoFocus anchored the same item at 8% of the viewport (a snap to offset 0 first) and fought the library every line change — the active aggregate's topmost accompaniment line (background vocals render ABOVE the main line inside the item) got clipped into the top fade ("the topmost active accompanist lyrics gets cut off"). The app side now only gates the first-focus reveal: awaitKaraokeFirstFocus (frame-waiter, clears awaitingFirstFocus once the library's follow has the target visible and not scrolling; short-circuits after 6 empty frames for the spicy path whose OWN listState keeps the shared one detached; LYRIC_FIRST_FOCUS_TIMEOUT_MS 900ms backstop). SpicyLyricsView self-follows on its OWN internal state; PlainLyricsView is unsynced-only.

  • Ambient gradients must live INSIDE the glassSource scope: ThrottledLayerBackdropNode.draw() periodically REPLACES the source node's live drawing with its recorded snapshot (every ThrottledLayerBackdropDefaultIntervalMillis = 32ms — every other frame while animating/scrolling), and the record starts with an OPAQUE surface-colored contentPrefix rect. A gradient Box placed as a sibling BEHIND a glassSource-decorated list vanishes on every record frame (30Hz flicker while the backdrop fades in, "disappears when scrolling"). The artist main page model is the reference: the glassSource modifier sits on a wrapper Box that CONTAINS [gradient Box + list], so the record carries the gradient (and the glass pills refract it). AlbumScreen, ArtistSongsScreen, ArtistAlbumsScreen and ArtistItemsScreen all wrap their backdrop + list/grid/shimmer this way.

  • hazeSource must sit on the OPAQUE wrapper, never on the transparent list: the album/artist-sub-page song rows are TRANSPARENT over the ambient gradient, so a hazeSource placed directly on the scrolling container makes the header bar (ScreenHeaderHaze/HomeTopFadeBlur, HazeMaterials.ultraThin(pageColor)) sample transparent pixels — haze then fills the bar with its OPAQUE pageColor, painting a hard-edged near-black band (dark theme surface) over the gradient when scrolled. The fix shape: the wrapper Box that also carries glassSource gets .hazeSource(headerHaze) right after it (same order as the glassHeaderSource helper), so the blur samples [gradient + list]. Never reintroduce hazeSource on a list whose rows are transparent.

  • Acoustic quality audit (Spek): acoustics/SpectrogramGenerator.kt (adapted from AURALIS, GPL-3.0 — keep the attribution header) decodes 20s of mono PCM from 1/3 into the track (MediaExtractor + MediaCodec, seek-to-sync), runs a 2048/1024 Hann FFT, detects the frequency cutoff (highest bin averaging above -65dB) and renders the Spek colormap bitmap (bin 0 at the bottom). The audit source resolution is resolveAcousticAuditSource: local library songs (the media id IS a content/file uri) → direct uri; downloaded songs → the download-cache spans of the candidate keys (currentSourceDownloadIds + DownloadSourceConfig.songIdToDownloadIds + ytm: prefix + raw id) materialized into cacheDir/acoustic_audit_tmp temp files (released on dispose, progress-published per span); YouTube ids → SimpMusicPlayer.player() resolves an AUDIO-only format (prefer audio/mp4 itag 140, else max bitrate) and OkHttp DOWNLOADS it into a temp file (48MB cap, callTimeout 90s, resolve+download wrapped in a 120s withTimeoutOrNull, cancellation-safe cleanup) — NEVER hand a googlevideo URL to MediaExtractor.setDataSource(url): the framework's plain HTTP stack retries error -1 for ~37s (no poToken/UA headers, 403s on video-artwork client URLs) and the audit dies. Everything else reports Unavailable (download first). The decode loop is bounded on every axis: wall-clock deadline (DECODE_DEADLINE_MS 45s), consecutive TRY_AGAIN stall budget (DECODE_STALL_ITERATIONS), null-buffer strike limit (getInputBuffer/getOutputBuffer returning null must break, not leak codec buffers into a permanent -1 loop — that was the infinite-loading report), currentCoroutineContext().ensureActive() per iteration, and a seek fallback (duration/3 → 0) when a seek lands past the downloaded tail. ALL progress flows through QuantizedProgress (0.5% steps) with the banded scale spans 0-0.30 / decode 0.30-0.55 / FFT 0.55-0.92 / bitmap 0.93-1.0, and the UI throttle additionally gates writes at ≥0.005 advance; the Spek palette is a hoisted top-level array (no per-pixel allocation), and all per-frame reveal/axis/sweep reads live in the DRAW phase (graphicsLayer/drawWithContent + derivedStateOf for the cutoff-line flip) — no per-frame recomposition of the popup (that was the stuttery scan sweep). The UI (ui/utils/AcousticAuditSection.kt) lives at the bottom of the Details tab in ShowMediaInfo — run row, progress card (determinate bar + scan sweep placeholder), verdict badge (spring scale-in, green/amber/red), spectrogram reveal (clipRect sweep + leading scan line, then the dashed cutoff line + kHz chip fade in), axis rows tied to the reveal, disclaimer. Bitmap sizes: numFrames × 1024 (20s ≈ 860 frames at 44.1kHz).

  • Root-only plain back handlers: playlist/library/artist screens used a plain BackHandler { popBackStack() } fallback that (LIFO) permanently starved the NavHost's built-in predictive back — back on playlists never scrubbed. The fallback is now registered ONLY when navController.previousBackStackEntry == null (the unreachable-root defensive case); normal back presses flow to the NavHost's predictive pop so the gesture drives the slide animation. Never reintroduce an unconditional plain pop handler on a pushed destination.

  • Artist hero content colors derive from the backing, never the theme surface: the hero name/stats (heroContentColor) and the release-card text are chosen from the luminance of the ACTUAL hero backing (staticReleaseBase, the static fromColorsLight mid/bottom lerp) — onSurface over a light base, Color.White over a dark one — because the hero's bottom gradient is theme-aware and can be light in light mode. The hero action circles over the photo scrim are ALWAYS Color.White (they sit over media, like the transparent-app-bar whites), and the liquid-glass header pills use heroGlassContentColor (Color.White while the hero row is visible or the theme is dark, liquidGlassContentColor() once scrolled into the light gradient). The pre-fix code keyed all of these on surfaceColor.luminance(), which in light mode put near-black onSurface text over the dark ambient gradient — the unreadable-hero bug.

  • Artist ambient snapshot cache + sub-page canvas following: ArtistAmbientSnapshotCache (process-wide, mediaId+theme keyed) bridges the artist page's extraction (online artistPage.thumbnail) and the sub-pages' DB thumbnailUrl — whose different URLs split the PlayerPaletteCache. Sub-pages (ArtistItemsScreen, ArtistSongsScreen, ArtistAlbumsScreen) render ArtistPageAmbientBackdrop (ui/screens/artist/ArtistAmbientBackdrop.kt) keyed on the ROUTE's {artistId} path param (exposed as routeArtistId on their ViewModels) — for the items screen this is NOT the browseId query param (that is the SECTION id, e.g. the top-songs playlist; keying the caches on it misses every lookup and leaves a flat backdrop — do NOT revert to browseId). ArtistPageAmbientBackdrop is a port of the main artist page's backdrop model: when ArtistCanvasArtworkCache (process-wide Compose state, written by ArtistViewModel.fetchArtistCanvas) has the artist's canvas, a hidden 128dp CanvasArtworkPlayer (decode capped at 256px) renders behind the fully-opaque theme-aware BackdropTonePalette.fromColorsLight(darkTheme=) gradient (dark keeps the historical 0.15-0.66 value clamps; light mirrors them at 0.74-0.98 so standard onSurface list text stays readable and the tier-0 surface fallback no longer reads as the theme "suppressing" the backdrop) and the same GraphicsLayer record loop (120ms record / 350ms palette) drives the gradient with live canvas colours — canvasColors ?: thumbnailColors, exactly like ArtistScreen. EXCEPTION — the ALL-SONGS page (ArtistSongsScreen) passes useCanvasAmbient = false: its backdrop is the artist THUMBNAIL palette only (same ArtistAmbientSnapshotCache/PlayerPaletteCache entry as the main page), per the user's explicit request — canvas-driven shifting colours stay on the main/items/albums pages. Palette changes ALWAYS animate over the shared 1200ms tween (tween only, NEVER snap on tier changes — the snap spec was the harsh open/scroll "flicker" users reported; the main artist page's crossfade model is the reference behaviour). The hidden stage is gated on AlbumCanvasEnabledKey, low-data mode, LocalPlayerSheetOverlayActive, LocalPlayerLyricsFullScreen and the list's scroll state. The MAIN artist page seeds NOTHING from the snapshot cache (upstream-main-branch behavior: artistArtworkColors starts null and its LaunchedEffect(thumbnail, isDarkTheme) always re-extracts from the online thumbnail — only the cache PUT remains, feeding the sub-pages); a seeded sub-page (rememberArtworkAmbientColors skips extraction while colors already exist) never re-extracts from its DB thumbnailUrl, killing the DB-vs-remote colour crawl. Backdrops are FLAT SURFACE while colors are null (never a tone-shifted fallback — that was the loading flash); rememberArtworkAmbientGradientColors builds the animated top/mid/bottom from extracted colors or flat surface. The TOP-SONGS sub page carries &songs=true on the items route (set by buildArtistItemsRoute from the LIST-of-SongItem section shape) and renders NO backdrop at all — plain surface. AlbumScreen renders its own rememberArtworkAmbientColors/rememberArtworkAmbientGradientColors gradient from the album thumbnail (no canvas) and blends the hero into it: MediaDetailHero's ambientScrimColor (computed by AlbumScreen as pageGradientColorAt(heroBottomFraction) from measured page/hero heights, artist-page model) drops the hero's opaque surface background and fades the scrim's bottom stops into the ambient colour — AppleMusicPlaylistHero takes the same colour via scrimColor. SwipeToSongBox's swipe reveal is FULLY transparent: the action strip behind a dragged row paints NO background (the play-next/queue icon is tinted secondary/primary directly over whatever page background/backdrop is behind) and the row content box paints nothing unless a caller explicitly passes a colour — the artist/album/sub-page song lists all pass Color.Transparent (swipeContentBackgroundColor), so a swipe over an ambient backdrop reveals the backdrop itself (an opaque surface row was the "black box while swiping"; an opaque secondary strip was the "weird coloured box"). HistoryScreen keeps passing its page containerColor deliberately.

  • Artist main-page carousels expand inline (no separate items page for the section kinds the user swipes through): ArtistSection.isInlineCarousel (core pages/ArtistPage.kt) classifies GRID sections with a moreEndpoint whose content is fully shown on the artist page itself — all-PlaylistItem carousels ("Playlists by …"), all-music-video SongItem carousels ("Videos", via SongItem.isMusicVideoItem), AlbumItem carousels whose title matches the singles keyword family, and library-keyword carousels ("From your library"). ArtistViewModel.expandInlineCarouselSections follows the section's moreEndpoint through YouTube.artistItems + artistItemsContinuation (page cap MAX_INLINE_CAROUSEL_PAGES, per-section coroutines under one cancellable inlineExpandJob, merged distinctBy id with the same explicit/video/blocked filters, published through publishInlineSectionItems with a moreEndpoint staleness guard) so the LazyRow grows to the section's FULL content. ArtistScreen renders those headers WITHOUT the arrow (moreEndpoint?.takeIf { !section.isInlineCarousel }); Albums/Top songs/Live performances keep the arrow + separate items page. Do not reintroduce the arrow on inline carousels or cap their items.

  • Per-source track pins are independent (source-switch preferences survive): the DataStore maps SongSourceQobuzTrackId/SongSourceQobuzBackupVideoId/SongSourceTidalTrackId/SongSourceDeezerTrackId are per-SOURCE-CATEGORY preferences. A category-only switch (setSongSourceOverride with a non-null source) writes ONLY SongSourceOverrideKey and never clears another source's pin — picking "YouTube" and later re-picking "Qobuz" must replay the previously chosen Qobuz track. Only "Automatic" (null source) is a full reset and clears all four pin maps. resolveMultiSourceDataSpec activates the direct qobuz/backup pins ONLY when the stored category is null or matches (isDirectQobuzTrack requires category QOBUZ, isDirectQobuzBackupTrack requires QOBUZ_BACKUP) — otherwise a saved qobuz pin would hijack playback while the user explicitly pinned YouTube/Tidal. The With* setters each write only their own map + the category, then applySongSourceOverridePlayback (cache purge + position-preserving re-prepare).

  • Crossfade never fades to silence: the blend loop in startCrossfade health-checks the incoming player every frame — an incoming playerError, or no position advancement for CROSSFADE_INCOMING_STALL_MS (1.5s) while crossfadePlaybackRequested, aborts the crossfade (cancelCrossfade(resetVolume = true) + scheduleCrossfade()) so the outgoing player returns to full volume instead of ramping to 0 over a dead incoming stream (the "muted a few seconds after switching source" report; the source switch purges every cache so the incoming resolution is the most likely thing to stall). finishCrossfade additionally refuses to promote an incoming player that is not advancing audio (isPlaying, or READY when playback was user-paused) — a silent-but-READY incoming is cancelled, never promoted. Pausing mid-blend refreshes the stall baseline so resuming cannot false-trigger the watchdog.

  • Details page back routing: the song Details sheet (bottomSheetPageState / ShowMediaInfo) participates in the player's back routing — Player.kt publishes PlayerBackTarget.SUB_PAGE while bottomSheetPageState.isVisible and routeBack dismisses it FIRST (above inline lyrics). Without this, back popped the NavHost destination behind the player while the details sheet stayed open. BottomSheetPage does NOT use PredictiveStyleBackHandler for its own back interception: it owns a manual OnBackPressedCallback that is RE-ADDED to the OnBackPressedDispatcher on every show (DisposableEffect(dispatcher, isVisible || contentOnScreen) — addCallback jumps it to the TOP of the dispatcher stack, above the NavHost's seekable pop handler and the player shields, whose registration order at composition time could not be relied on across dispatcher generations) and stays registered+enabled through the 300ms exit animation (contentOnScreen on the state) so follow-up swipes are consumed idempotently instead of collapsing the player or popping the page behind.

  • Queue UIs must map DISPLAYED positions to TIMELINE indices: PlayerConnection.queueWindows is the PLAY-ORDER list (Player.getQueueWindows() walks the timeline from the current item with shuffleModeEnabled), so with shuffle ON its positions are NOT media-item indices. Any seekTo/seekToDefaultPosition/moveMediaItem/removeMediaItem driven by a queue row must go through the row's Timeline.Window.firstPeriodIndex (the generic Queue page and AppleMusicQueueSheet already did this; Flamingo/Apple Music style and BitChord's InlineQueue now build a queueTimelineIndices map / use firstPeriodIndex directly), and drag-reorder under shuffle commits via localPlayer.setShuffleOrder(DefaultShuffleOrder(...)) on the mapped permutation (never moveMediaItem, which would shuffle the wrong item). BitChord's clear-queue removes the mapped indices sorted DESCENDING (index-stable) except the current row's.

  • Direct-pick source overrides must bypass variant-collapsed caches: QobuzAudioProvider.Query.cacheKey() includes directTrackId — a metadata-search stream (directTrackId=null) and every individually picked track are DISTINCT cache entries. Without it, all near-identical variants of a song share one key and the FIRST resolved stream is served for 30 minutes no matter which row the user picks in the source dialog ("always plays the same song"). clearTransientCaches() (called on every direct pick from setSongSourceOverrideInternal) also wipes streamCache, and the service drops multiSourceMemoByMediaId[mediaId] there so the 3s resolution memo can never shadow a fresh pick during the replay.

  • Playlist covers survive sync; thumbnails persist on open: DatabaseDao.update(playlistEntity, playlistItem) preserves hasCustomCover (BOTH the local file://content:// cover AND the uploaded studio_square_thumbnail remote marker — the old hasLocalCustomCover-only guard let every YTM sync revert an uploaded cover) and never writes null over an existing thumbnailUrl (playlistItem.thumbnail ?: entity.thumbnailUrl). OnlinePlaylistViewModel.persistMissingPlaylistCover backfills the playlist/page thumbnail (fallback: first song thumb) into an existing library row whose thumbnailUrl is null, so online playlists stop re-deriving their cover every launch. Cover edits on a library playlist with a browseId upload to YouTube (PlaylistCoverRepository.setRemoteCover → uploadCustomPlaylistCover); local-only playlists keep the managed playlist_covers file copy.

  • Singles/EPs section headers carry no sub-page arrow: on the artist page, remote sections whose title word-matches single(s)/ep(s) render NavigationTitle with onClick = null — the horizontal LazyRow below already slides through every fetched release; the arrow_forward affordance (and its buildArtistItemsRoute sub-page) stays only for the other sections.

  • Video artwork first-frame kick + background preload: VideoArtworkPlayer's hold watchdog kicks seekTo(currentPosition) every 2.5s on a READY-but-no-first-frame player (immediately on ON_START/ON_RESUME and on sheetSurfaceActive turning true), with a bounded budget (VideoFirstFrameKickMaxAttempts) that ends in declareVideoFailure so a never-rendering stream falls back to artwork instead of loading forever. The kick/failure budget is ONLY spent while the video surface is actually on screen AND the app is foregrounded (sheetSurfaceActive + the lifecycle-driven appInForeground flag): with the sheet collapsed or the app backgrounded there is no surface to render a first frame to, so the loop idles at 500ms and NEVER declares failure — the old loop killed the load after ~40s of no first frame and left hasPlaybackFailed=true (artwork fallback, no retry) for the rest of the song, which was the "background video only ever loads audio" bug. STATE_READY only releases the audio hold early (without a first frame) when the player sheet has no on-screen surface (sheetSurfaceActive == false, passed from Player.kt as state.progress > 0.5f || isExpandedOrExpanding) — a buffered video behind a collapsed miniplayer IS loaded. While awaitingVideoReady with a visible surface, STATE_READY never schedules the main-audio resume — only onRenderedFirstFrame (or failure) does. The BACKGROUND counterpart is playback/VideoBackgroundPreloader.kt (service-owned singleton): the Compose pipeline cannot react to media changes while the app is backgrounded (Recomposer frame-parked → stale videoId), so on every non-REPEAT onMediaItemTransition to a music video while App.isUiForeground is false (activity-started counter registered in App.onCreate) it pauses the main audio (the hold), resolves the stream through the same internal resolveVideoStreamUrl chain at the USER'S stored quality (VideoQualityPreferredHeightKey → VideoQualityPreference.toPreferredHeight, default HIGH_QUALITY — resolving with null picks the 1080 ceiling while the UI player defaults to device-max, a different itag/URL whose cached bytes the UI then never hits), prepares a surface-less headless ExoPlayer into the shared video cache, and releases the hold on READY/timeout/error. The UI pipeline claims the hold on foreground recomposition via claimUiTakeover(videoId) in rememberVideoArtworkState's LaunchedEffect(videoId) — the preloader hands over WITHOUT resuming and beginAudioHold(resumeOverride = wasPlaying) seeds the UI hold's resume flag; if no claim arrives within 3s of foregrounding, the preloader resumes itself so a leaked pause can never outlive the load.

  • Session activity opens the player: updateSessionActivityDeepLink pins the media-session activity intent to expand_player=true (never a navigate_to route) — MainActivity.handleIntent sets pendingExpandPlayerSheet and the composition expands playerBottomSheetState once the connection exists. Notification clicks must open the full-screen player, not an album/artist page.

  • Bit-perfect mixer wire-encoding match (lossy buzz fix): refreshMixerBitPerfectRoute requests the preferred BIT_PERFECT mixer in the encoding the pipeline will ACTUALLY deliver — float when an engine or the engaged float DSP routes float (or the decode is 24/32-bit), PCM_16BIT otherwise — and re-prepares when the wire encoding flips mid-session. UsbBitPerfectOutput only accepts a mixer whose rate AND encoding match the request (the old bits >= 24 fallback applied a float mixer to 16-bit lossy data — the reinterpretation buzz on opus/AAC with bit-perfect + float DSP and no engine).

  • Oboe native output engine: archivetune_dsp links Google Oboe (com.google.oboe:oboe, prefab — already packaged for lastwave) instead of raw AAudio C. AaudioExclusiveStream (native/dsp/aaudio_output.*) keeps its exact JNI surface but is built on oboe::AudioStreamBuilder (Float format, Exclusive sharing, LowLatency, device pinning) with an AudioStreamErrorCallback-backed disconnect flag so writes fail fast instead of blocking; oboe::Result values mirror the AAudio codes the Kotlin layer already handles. The open()/close() lock discipline uses closeLocked() (never recurse the non-recursive mutex).

  • Flamingo slide-bar + slider contrast + bake gating: FlamingoHideSlideBarKey (Appearance settings, Apple Music style only, below the design-style picker) hides the 32×4.5dp grabber pill on top of the artwork. FlamingoTrack draws a FlamingoTrackBackingColor (black 30%) rounded backing under the whole track with a white-35% inactive half so the white active portion stays distinguishable over ANY artwork brightness — the original black-10%/white-50% pair still blended into a single line over bright canvas video and artwork (the slider screenshots). The canvas twin bake loop suspends capture while pageMorphRunning (the last bake persists through the morph — it is fading out anyway), keeping morph frames free of snapshot+blur work. The Flamingo lyrics page's top spacer matches the queue page's topInset + 114.dp (the playing bar's bottom edge) — 104dp left the first lyric band rendering behind the bar.

  • Lyrics first-focus settle + pending guard: scrollLyricIntoFocus waits up to LYRIC_FOCUS_LAYOUT_SETTLE_FRAMES (4) frames after scrollToItem for the layout to actually contain the target item with a non-degenerate viewport before computing the anchor delta — a single withFrameNanos raced the measure pass and left the opening line glued to the top until the NEXT line re-anchored. The focus collector distinguishes LYRIC_FOCUS_TARGET_PENDING (index not yet published, e.g. -1 during an instrumental intro) from LYRIC_FOCUS_TARGET_MANUAL (user scrolling / selection): a PENDING emission must NOT clear awaitingFirstFocus — the old code faded the list in at its initial scroll position and then animated to the active line, which was the "first active lyrics line cut off when opening the lyrics page" report. Only MANUAL (or the LYRIC_FIRST_FOCUS_TIMEOUT_MS guard) releases the pre-focus hiding.

  • Add-to-playlist sort persistence: AddToPlaylistSortKey (enum, RECENTLY_CREATED default) backs the dialog's sort chips via rememberEnumPreference; the on-open reset no longer clobbers the user's last choice.

  • Provider search result screens are opaque: SoundCloud/Apple Music/Spotify/YouTube search-result roots paint MaterialTheme.colorScheme.surface — they used to be transparent, so the query-replacement navigation (popUpTo inclusive) crossfaded two transparent screens over the window and the background flickered between searches.

  • SoundCloud search categories: SoundCloudAudioProvider.searchUsers/searchPlaylists (anonymous client_id v2, shared searchCollection) feed the Artists and Albums chips on the SoundCloud result screen; playlists with embedded tracks play as a ListQueue (resolved through resolveSoundCloudTrackToMetadata), artists and track-less playlists open their SoundCloud permalink externally (the Apple Music search precedent).

  • Haptics toggle gates every vibration source: the in-app "Vibration" toggle (EnableHapticFeedbackKey) must gate ALL vibration paths, not just Compose's LocalHapticFeedback (already wrapped at MainActivity's root): the hardcoded Vibrator calls — FlamingoHaptics (click/longClick/doubleClick read PreferenceStore.get(EnableHapticFeedbackKey)) and BitChordHaptics' HapticDevice (the system HAPTIC_FEEDBACK_ENABLED watcher is ANDed with the app preference) — respect it too. SpatialFlowHaptics (the beat-synced "Music haptics" engine) keeps its own separate toggle and strength setting by design.

  • Shuffle-enable preserves the played prefix: MusicService.onShuffleModeEnabledChanged(true) calls applyPlayedPrefixPreservingShuffleOrder() — the linear timeline prefix up to the current item stays in order (shuffle was just OFF, so the queue was advancing linearly) and ONLY the still-upcoming remainder is shuffled. The old applyCurrentFirstShuffleOrder re-randomized the WHOLE queue on every enable, pulling already-played songs back into the up-next so tapping shuffle in a queue page "played different songs" than it showed; it remains ONLY for initial queue loads that start with shuffle enabled (current-anchored full shuffle). REPEAT transitions never re-trigger shuffle bookkeeping.

  • BottomSheetPage back-swallow through exit: BottomSheetPage's PredictiveStyleBackHandler stays enabled while the sheet content is still composed during the exit animation (sheetContentOnScreen DisposableEffect) — state.isVisible flips false the instant back commits, and a handler keyed only on it lets rapid follow-up swipes fall through to the NavHost and pop the page behind (the "details popup takes several swipes and closes the settings page" report). Extra swipes during the exit are consumed by dismiss (idempotent); the handler disables when the content actually leaves composition.

  • SettingsPageTopBar marquee title: the centered title is padded start = 64.dp (clear of the 40dp back circle + 12dp inset + gap) and end = 20.dp, with basicMarquee(iterations = Int.MAX_VALUE) and NO ellipsis — long integration titles ("Last.fm / Libre.fm") autoscroll instead of colliding with the back button. Every settings sub-page shares this component.

  • Year-in-music page hides the compact bottom UI: while isYearInMusicScreen is true, isBottomUiCompact is forced false and the navbar scroll-hide connection ignores gestures (yearInMusicScreenUpdated), so neither the compact control row nor its liquid-glass side pills render on the immersive stats page; leaving the page restores the normal ADAPTIVE/ALWAYS_COMPACT behaviour.

  • Direct-stream format persistence on every path: persistDirectStreamFormat must run on BOTH the fresh-resolution path (applyDirectStream) AND the directStreamCache HIT path in resolveMultiSourceDataSpec — cache-hit plays used to skip it, leaving a stale FormatEntity so the player's quality tag only appeared after a manual "play from" source pick. TIDAL streams carry REAL sampleRate/bitDepth/contentLength end-to-end (Resolved → DirectStream → CurrentStreamInfo/FormatEntity): the manifest parsers read bitDepth, the account-manifest DirectStreams pass manifest sampleRate/bitDepth/bitrate, the main resolve estimates contentLength from the local file or bitrate × duration when the live stream has none — never rely on the persistDirectStreamFormat label fallbacks (they fabricate 96kHz/2304kbps for HI_RES labels). The play-from popup's sourceQualityDetail derives the TIDAL nominal specs from the user's TidalAudioQualityKey setting, not a hardcoded 16-bit/44.1kHz.

  • No comment blocks in code (rules.md Rule 3): never add // or /* */ comments beyond the GPL header — code must be self-explanatory through naming. Files created or modified by agents are kept comment-clean.

Modules & submodules

  • Gradle modules: :app :core :moriextractor :audio:decent-usb-audio-driver :spotifycore :canvas :jiosaavn :lastfm :musixmatch :shazamkit :morideobfuscator :lyrics:kugou :lyrics:lrclib :lyrics:betterlyrics :lyrics:unison :lyrics:youlyplus (see settings.gradle.kts).
  • Canvas settings live on their own sub-page: settings/player/canvas (CanvasSettingsScreen) — canvas sources, resolvers, the procedural static-artwork motion (canvas/procedural/, Sekai Tune port: Kawarp + Square Tunnel AGSL, audio-reactive) and artwork priority. Ambient mode was fully removed (route, screens, strings); AOD true-ambient and the video-player ambient glow are separate legacy features and remain.
  • Dolby/FFmpeg decoder (audiophile page): media3-decoder-ffmpeg resolves ONLY from the dmt-media3 maven repo via exclusiveContent in settings.gradle.kts; its transitive media3 artifacts are excluded in app/build.gradle.kts so the app's pinned media3 stays authoritative. DolbyFfmpegDecoderKey flips EXTENSION_RENDERER_MODE_PREFER in MusicService.createRenderersFactory (read once — restart playback to apply) and is mutually exclusive with bit-perfect, USB-exclusive and audio offload (both directions, enforced in AudiophileSettings).
  • Submodules: core → 4nx3b/core (NewPipeExtractor-based InnerTube client), lyrics → 4nx3b/lyrics, IconPack → rukamori, morideobfuscator → rukamori. Commit + push inside a submodule first, then pin the gitlink; never leave a dirty or unpushed pointer.
  • Downloads: Media3 DownloadManager over downloadCache spans (files only materialize at export, ExportDownloadedSongsScreen + AudioTagger). Every download also runs DownloadExtrasCoordinator on queue: lyrics fetched through the user's LyricsProviderOrderKey priority into the lyrics DB and canvas resolved through the artwork-provider priority into CanvasArtworkPlaybackCache (video files cached to disk), so downloaded songs play offline with lyrics and canvas. Exports embed the stored lyrics as FieldKey.LYRICS and the canvas url as FieldKey.CUSTOM1 by default.

Build & test

  • JDK 21, Android SDK (compileSdk 37), Gradle wrapper 9.6.1.
  • Flavor matrix: gms/foss × mobile/tv × universal/arm64/x86_64. Debug check: ./gradlew assembleGmsMobileUniversalDebug.
  • Fork contract tests (palette/crossfade/artwork/multi-source/presence/telegram — sources in app/src/test/): ./gradlew :app:testGmsMobileUniversalDebugUnitTest. Run after any merge or feature change.
  • Keep the GPL header banner on new .kt files. No formatter task — match surrounding style.

Dependency gotchas

  • settings.gradle.kts declares a GCS mirror of Maven Central before mavenCentral() — do not reorder (Maven Central 429-rate-limits CI).
  • JitPack is scoped via exclusiveContent to an allow-list of com.github.* groups (TeamNewPipe, tdlibx, PRDownloader, jaudiotagger, MetrolistGroup…). A new com.github.* dependency fails until its group is added.
  • The embedded Python/yt-dlp layer (Chaquopy) was removed on 2026-08-26: YouTube stream resolution is native-only via the compiled InnerTube core (NativeStreamRepository + BotGuard/QuickJS PO tokens). Upstream still carries Chaquopy — expect merge conflicts in app/build.gradle.kts, gradle/libs.versions.toml, and playback/stream/* on sync; resolve them by keeping the fork's native-only shape.

Automated upstream sync

.github/workflows/upstream-sync.yml merges hourly via scripts/upstream_sync.sh + scripts/ai_resolve.py, following these invariants. If you change a fork feature or protected file, update the invariants in both this file and the script's verification section.

Agent hygiene

  • Never commit credentials, tokens, keystores or agent session state (.swarm/, .opencode/, .jcode/ stay untracked).
  • Local working notes (ARCHIVETUNE_SESSION_NOTES.md, ARCHIVETUNE_CODE_MAP.md) are scratch memory — never ship them.