Active development on dev, tracking 4nx3b/ArchiveTune@dev. Any agent working
here must preserve the invariants below.
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.mdandworklog.md. Always use English.
- Never compile locally. Push to
AGENTS.md(this file) — fork invariants, module layout, build/test contracts, dependency gotchas.worklog.md— what previous tasks did; append an entry for the current task before finishing (format:Task ID/Agent/Task/Work Log/Stage Summary).
-
Tidal source:
app/src/main/kotlin/moe/rukamori/archivetune/tidal/(TidalAudioProvider,TidalAccountManager,TidalInstanceHealthManager,TidalDns,TidalArtworkProvider), Tidal settings/login UI,utils/tidal/; instance racing documented inINSTANCE_RACING.md. Live progressive-DASH streams use thetidal-dash://scheme routed inMusicService. -
Multi-source audio: providers live in top-level packages
tidal/,deezer/(DeezerCrypto + Media3 decrypting DataSource),qobuz/(+QobuzBackupProvidervia the kouzu.in mirror),spotify/; shared contract (DirectStream,TitleMatch, source priority) inaudiosource/. Playback resolution goes throughresolveMultiSourceDataSpecinplayback/MusicService.kt; YouTube is the final fallback. Do not rewire playback around this. -
Spotify is catalog + OPTIONAL experimental direct streaming:
spotifycore/+ appspotify/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 BOTHfile_ids_mp4andfile_ids_mp4_dualmanifest formats and the Dealer handler always prefers the standardfile_ids_mp4entry — never register only the dual format, those files decode to silence; Widevine throughMusicService.buildSpotifyDrmManagerOrNull(per-player manager cached insidecreateMediaSourceFactory, never shared between the primary and crossfade players)/SpotifyLicenseCallbackwith default security level and standard provisioning — do not force L3 or throw on provisioning; anti-mute playback reporting on start/30s/finish viareportPlaybackdriven fromupdateSpotifyPlaybackReportinginonMediaItemTransition) 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 toEchoDecryptingDataSource, 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'sSPOTIFY_OGG_HEADER_ENDbehavior; 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 thesoundcloud-hls://scheme (base64-encoded playlist URL) routed toplayback/SoundCloudHlsDataSource, which synthesizes a progressive TS stream from the playlist segments — the same pattern astidal-dash://. Never return a rawhttps://…m3u8URL from the resolver: the lazy DataSpec resolution feeds HLS playlists to progressive extractors and fails. Search-category playback pinsMediaMetadata.preferredSource+soundCloudUrl(direct permalink → trusted resolve). -
Playback client policy:
AutoChoosePlaybackClientKey+YTPlayerUtilsown 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 thetelegram://branch inMusicService'sSchemeRoutingDataSource, independent of the multi-source chain. Channels materialise as local playlists (LPtg<chatId>); artwork via the Coiltgart://fetcher. api_id/hash viaBuildConfigwith public Telegram Desktop fallback. -
CI signing: workflows sign with the committed
app/persistent-debug.keystorewhen theKEYSTOREsecret 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.ktrenders the canvas through the SAME shared-element system upstream'sAppleMusicPlayerships —FlamingoCanvasStagelives inside the Album branch ofSharedTransitionLayout/AnimatedContentcarryingsharedBounds(ShareAlbumKey)(non-bouncy spring, StiffnessMediumLow, 600ms symmetric cross-fades, AdaptiveCornerShape overlay clip), mirroringAppleMusicMiniHeader's artwork inFlamingoPlayingBar. 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 (visiblestays true — onlyisPlayinggates viacanvasSurfacesPlaying): a retired TextureView records empty snapshots and the bake goes black, which is the "canvas disappears after lyrics/queue" bug.canvasBackdropRevealgates 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-shotonFirstFrameRendered/onPlaybackAvailabilityChangelatches 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 everyFlamingoCanvasBakeRetryCooldownMs(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 (FlamingoBandTopFeatherBrushDstIn 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'sfadeBottom. 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 runtimeModifier.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 aGraphicsLayerviacanvasSnapshotSourceevery 50ms, baked into a blurred bitmap (ImageBlurUtils+ clamp-compensating downscale) and drawn as an opaqueImageover the twin. The moving-blur background (FlamingoBackground.kt) is a Compose port of Flamingo's KenBurnsView (RandomTransitionGeneratorrect 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.txtwere removed and must never be restored. -
SukiSU liquid glass navigation bar:
ui/component/sukisu/SukisuLiquidNavBar.kt+SukisuGlassSupport.ktare a SEPARATE nav-bar style (SukisuLiquidGlassNavBarKey, mutually exclusive with frosted/tint/liquid-glass/bitchord inNavigationBarSettings). They port SukiSU-Ultra's FloatingBottomBar (ghost accent row recorded into aLayerBackdrop+rememberCombinedBackdrop, kyantlensrefraction, gravity-quantized specular highlights,SukisuInteractiveHighlightAGSL spotlight, rubber-band drag onLiquidGlassDragAnimation). The bar's geometry is HARDCODED to the normal navbar's liquid-glass dimensions — 64dp bar, 4dp item padding,FloatingNavigationBarMaxWidthcap,fillMaxWidth+ horizontal system-bar insets, M3ShortNavigationBarItemitems, 56dp tab-width pill — and reads NO navbar sizing preferences (only the app-wideHideNavigationBarLabelsKey). The ghost accent row composes BELOW the interactive row withenabled = falseitems — 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 —falsefor a tab switch; passingtrueunconditionally makes every tap a reselect (scroll-to-top, no navigation). Never merge this path into the normalFloatingNavigationToolbarliquid-glass bar or share tuning state withLocalLiquidGlassTuning. -
Back animation style:
BackAnimationStyleKey(DEFAULT/PREDICTIVE, enum inconstants/PreferenceKeys.kt) is provided app-wide viaLocalBackAnimationStylefrom MainActivity.PredictiveStyleBackHandler+rememberPredictiveSheetBackState(ui/component/PredictiveBack.kt) are the ONLY sanctioned back-handler wrappers for sheets (BottomSheet,BottomSheetPage,BottomSheetMenuuse them); the progressAnimatablemust 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): aPredictiveStyleBackHandlerforPlayerBackTarget.SHEET(sharingplayerSheetPredictiveBackwith the sheet's graphicsLayer via BottomSheet'spredictiveBackStateparam) and a plainBackHandlerforPlayerBackTarget.SUB_PAGEthat routes throughPlayerBackController.routeBack(inline lyrics → style-page epoch → queue sheet → collapse).Player.ktpublishes the target from onederivedStateOfviaSideEffect— never add another plainBackHandlerabove 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.8SeekableTransitionState) 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 intopLevelScreens) keep the classic fade+scale. DEFAULT mode keeps the fade+scale everywhere. -
SukiSU About page:
AboutScreen.ktrenders overSukisuAboutBackground(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 fromLocalStableSystemBarsTopPadding(cutout-aware —WindowInsets.statusBarsalone lets the header collide with notches). The scrolled header treatment is the app-wideScreenHeaderHaze(home-page model:HomeTopFadeBlurultraThin progressive haze + scrim, 220ms intensity animation onlistState.canScrollBackward) — the Scaffold body (SukisuAboutBackground + scroll content) is thehazeSource, the overlay is a sibling after it, and the kyantaboutBarBackdropband is gone. All sections are SukiSU glass cards (AboutGlassCard:drawBackdropblur 60f + blend-mode tints, transparent fill over the shader background; fully-opaquesurfaceContainerfallback 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 singlev<version>text line fromdisplayVersionName(BuildInfo.kt— nightly builds render17.0.<versionCode>-<shortHash> (<N-tag>)fromBuildConfig.NIGHTLY_TAG, fed by the nightly workflow). The links card refracts that background through theaboutGlassBackdropLayerBackdrop; the parallax logo stack tracks element Y positions captured once (onGloballyPositionedguards!= 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) andsettings/update(compactSearchCircleHidden) — terminal info pages carry no search action;compactReserveEndControlfollows 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 simultaneousdrawBackdropblur surfaces during scroll).SukisuAboutBackgroundpauses its 60fps AGSL frame loop and its color-stage spring while itsrenderActiveis false — the About route provides the nav-transition half fromLocalSukisuBgRenderActiveandAboutScreenANDs 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.isTtmlDocumentmust require an XML prefix (startsWith("<tt")ORstartsWith("<") && 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 SHAREDlistStateand anchors it atoffset + keepAliveZonebelow the viewport top —LyricsEnhancedmust NEVER scroll that state itself. The old app-sidescrollLyricIntoFocusanchored 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, clearsawaitingFirstFocusonce 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_MS900ms backstop).SpicyLyricsViewself-follows on its OWN internal state;PlainLyricsViewis unsynced-only. -
Ambient gradients must live INSIDE the glassSource scope:
ThrottledLayerBackdropNode.draw()periodically REPLACES the source node's live drawing with its recorded snapshot (everyThrottledLayerBackdropDefaultIntervalMillis= 32ms — every other frame while animating/scrolling), and the record starts with an OPAQUE surface-coloredcontentPrefixrect. A gradient Box placed as a sibling BEHIND aglassSource-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: theglassSourcemodifier sits on a wrapper Box that CONTAINS [gradient Box + list], so the record carries the gradient (and the glass pills refract it).AlbumScreen,ArtistSongsScreen,ArtistAlbumsScreenandArtistItemsScreenall 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
hazeSourceplaced 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 carriesglassSourcegets.hazeSource(headerHaze)right after it (same order as theglassHeaderSourcehelper), so the blur samples [gradient + list]. Never reintroducehazeSourceon 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 isresolveAcousticAuditSource: 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 intocacheDir/acoustic_audit_tmptemp 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 toMediaExtractor.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_MS45s), 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 throughQuantizedProgress(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 inShowMediaInfo— 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 whennavController.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 staticfromColorsLightmid/bottom lerp) —onSurfaceover a light base,Color.Whiteover 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 ALWAYSColor.White(they sit over media, like the transparent-app-bar whites), and the liquid-glass header pills useheroGlassContentColor(Color.Whitewhile 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 onsurfaceColor.luminance(), which in light mode put near-blackonSurfacetext 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 (onlineartistPage.thumbnail) and the sub-pages' DBthumbnailUrl— whose different URLs split thePlayerPaletteCache. Sub-pages (ArtistItemsScreen,ArtistSongsScreen,ArtistAlbumsScreen) renderArtistPageAmbientBackdrop(ui/screens/artist/ArtistAmbientBackdrop.kt) keyed on the ROUTE's{artistId}path param (exposed asrouteArtistIdon their ViewModels) — for the items screen this is NOT thebrowseIdquery 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 tobrowseId).ArtistPageAmbientBackdropis a port of the main artist page's backdrop model: whenArtistCanvasArtworkCache(process-wide Compose state, written byArtistViewModel.fetchArtistCanvas) has the artist's canvas, a hidden 128dpCanvasArtworkPlayer(decode capped at 256px) renders behind the fully-opaque theme-awareBackdropTonePalette.fromColorsLight(darkTheme=)gradient (dark keeps the historical 0.15-0.66 value clamps; light mirrors them at 0.74-0.98 so standardonSurfacelist 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) passesuseCanvasAmbient = false: its backdrop is the artist THUMBNAIL palette only (sameArtistAmbientSnapshotCache/PlayerPaletteCacheentry 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 onAlbumCanvasEnabledKey, low-data mode,LocalPlayerSheetOverlayActive,LocalPlayerLyricsFullScreenand the list's scroll state. The MAIN artist page seeds NOTHING from the snapshot cache (upstream-main-branch behavior:artistArtworkColorsstarts null and itsLaunchedEffect(thumbnail, isDarkTheme)always re-extracts from the online thumbnail — only the cache PUT remains, feeding the sub-pages); a seeded sub-page (rememberArtworkAmbientColorsskips extraction while colors already exist) never re-extracts from its DBthumbnailUrl, 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);rememberArtworkAmbientGradientColorsbuilds the animated top/mid/bottom from extracted colors or flat surface. The TOP-SONGS sub page carries&songs=trueon the items route (set bybuildArtistItemsRoutefrom the LIST-of-SongItem section shape) and renders NO backdrop at all — plain surface.AlbumScreenrenders its ownrememberArtworkAmbientColors/rememberArtworkAmbientGradientColorsgradient from the album thumbnail (no canvas) and blends the hero into it:MediaDetailHero'sambientScrimColor(computed by AlbumScreen aspageGradientColorAt(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 —AppleMusicPlaylistHerotakes the same colour viascrimColor.SwipeToSongBox's swipe reveal is FULLY transparent: the action strip behind a dragged row paints NO background (the play-next/queue icon is tintedsecondary/primarydirectly 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 passColor.Transparent(swipeContentBackgroundColor), so a swipe over an ambient backdrop reveals the backdrop itself (an opaquesurfacerow was the "black box while swiping"; an opaquesecondarystrip was the "weird coloured box"). HistoryScreen keeps passing its pagecontainerColordeliberately. -
Artist main-page carousels expand inline (no separate items page for the section kinds the user swipes through):
ArtistSection.isInlineCarousel(corepages/ArtistPage.kt) classifies GRID sections with amoreEndpointwhose content is fully shown on the artist page itself — all-PlaylistItem carousels ("Playlists by …"), all-music-video SongItem carousels ("Videos", viaSongItem.isMusicVideoItem), AlbumItem carousels whose title matches the singles keyword family, and library-keyword carousels ("From your library").ArtistViewModel.expandInlineCarouselSectionsfollows the section'smoreEndpointthroughYouTube.artistItems+artistItemsContinuation(page capMAX_INLINE_CAROUSEL_PAGES, per-section coroutines under one cancellableinlineExpandJob, mergeddistinctBy idwith the same explicit/video/blocked filters, published throughpublishInlineSectionItemswith amoreEndpointstaleness guard) so the LazyRow grows to the section's FULL content.ArtistScreenrenders 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/SongSourceDeezerTrackIdare per-SOURCE-CATEGORY preferences. A category-only switch (setSongSourceOverridewith a non-null source) writes ONLYSongSourceOverrideKeyand 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.resolveMultiSourceDataSpecactivates the direct qobuz/backup pins ONLY when the stored category is null or matches (isDirectQobuzTrackrequires categoryQOBUZ,isDirectQobuzBackupTrackrequiresQOBUZ_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, thenapplySongSourceOverridePlayback(cache purge + position-preserving re-prepare). -
Crossfade never fades to silence: the blend loop in
startCrossfadehealth-checks the incoming player every frame — an incomingplayerError, or no position advancement forCROSSFADE_INCOMING_STALL_MS(1.5s) whilecrossfadePlaybackRequested, 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).finishCrossfadeadditionally 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.ktpublishesPlayerBackTarget.SUB_PAGEwhilebottomSheetPageState.isVisibleandrouteBackdismisses it FIRST (above inline lyrics). Without this, back popped the NavHost destination behind the player while the details sheet stayed open.BottomSheetPagedoes NOT usePredictiveStyleBackHandlerfor its own back interception: it owns a manualOnBackPressedCallbackthat is RE-ADDED to theOnBackPressedDispatcheron 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 (contentOnScreenon 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.queueWindowsis the PLAY-ORDER list (Player.getQueueWindows()walks the timeline from the current item withshuffleModeEnabled), so with shuffle ON its positions are NOT media-item indices. AnyseekTo/seekToDefaultPosition/moveMediaItem/removeMediaItemdriven by a queue row must go through the row'sTimeline.Window.firstPeriodIndex(the generic Queue page andAppleMusicQueueSheetalready did this; Flamingo/Apple Music style and BitChord's InlineQueue now build aqueueTimelineIndicesmap / usefirstPeriodIndexdirectly), and drag-reorder under shuffle commits vialocalPlayer.setShuffleOrder(DefaultShuffleOrder(...))on the mapped permutation (nevermoveMediaItem, 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()includesdirectTrackId— 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 fromsetSongSourceOverrideInternal) also wipesstreamCache, and the service dropsmultiSourceMemoByMediaId[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)preserveshasCustomCover(BOTH the local file://content:// cover AND the uploadedstudio_square_thumbnailremote marker — the oldhasLocalCustomCover-only guard let every YTM sync revert an uploaded cover) and never writes null over an existingthumbnailUrl(playlistItem.thumbnail ?: entity.thumbnailUrl).OnlinePlaylistViewModel.persistMissingPlaylistCoverbackfills the playlist/page thumbnail (fallback: first song thumb) into an existing library row whosethumbnailUrlis null, so online playlists stop re-deriving their cover every launch. Cover edits on a library playlist with abrowseIdupload to YouTube (PlaylistCoverRepository.setRemoteCover→uploadCustomPlaylistCover); local-only playlists keep the managedplaylist_coversfile 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
NavigationTitlewithonClick = null— the horizontal LazyRow below already slides through every fetched release; thearrow_forwardaffordance (and itsbuildArtistItemsRoutesub-page) stays only for the other sections. -
Video artwork first-frame kick + background preload:
VideoArtworkPlayer's hold watchdog kicksseekTo(currentPosition)every 2.5s on a READY-but-no-first-frame player (immediately on ON_START/ON_RESUME and onsheetSurfaceActiveturning true), with a bounded budget (VideoFirstFrameKickMaxAttempts) that ends indeclareVideoFailureso 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-drivenappInForegroundflag): 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 lefthasPlaybackFailed=true(artwork fallback, no retry) for the rest of the song, which was the "background video only ever loads audio" bug.STATE_READYonly releases the audio hold early (without a first frame) when the player sheet has no on-screen surface (sheetSurfaceActive == false, passed from Player.kt asstate.progress > 0.5f || isExpandedOrExpanding) — a buffered video behind a collapsed miniplayer IS loaded. WhileawaitingVideoReadywith a visible surface,STATE_READYnever schedules the main-audio resume — onlyonRenderedFirstFrame(or failure) does. The BACKGROUND counterpart isplayback/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-REPEATonMediaItemTransitionto a music video whileApp.isUiForegroundis false (activity-started counter registered in App.onCreate) it pauses the main audio (the hold), resolves the stream through the same internalresolveVideoStreamUrlchain at the USER'S stored quality (VideoQualityPreferredHeightKey→VideoQualityPreference.toPreferredHeight, default HIGH_QUALITY — resolving withnullpicks 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 viaclaimUiTakeover(videoId)inrememberVideoArtworkState'sLaunchedEffect(videoId)— the preloader hands over WITHOUT resuming andbeginAudioHold(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:
updateSessionActivityDeepLinkpins the media-session activity intent toexpand_player=true(never anavigate_toroute) —MainActivity.handleIntentsetspendingExpandPlayerSheetand the composition expandsplayerBottomSheetStateonce 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):
refreshMixerBitPerfectRouterequests 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.UsbBitPerfectOutputonly accepts a mixer whose rate AND encoding match the request (the oldbits >= 24fallback 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_dsplinks 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 onoboe::AudioStreamBuilder(Float format, Exclusive sharing, LowLatency, device pinning) with anAudioStreamErrorCallback-backed disconnect flag so writes fail fast instead of blocking;oboe::Resultvalues mirror the AAudio codes the Kotlin layer already handles. The open()/close() lock discipline usescloseLocked()(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.FlamingoTrackdraws aFlamingoTrackBackingColor(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 whilepageMorphRunning(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'stopInset + 114.dp(the playing bar's bottom edge) — 104dp left the first lyric band rendering behind the bar. -
Lyrics first-focus settle + pending guard:
scrollLyricIntoFocuswaits up toLYRIC_FOCUS_LAYOUT_SETTLE_FRAMES(4) frames afterscrollToItemfor the layout to actually contain the target item with a non-degenerate viewport before computing the anchor delta — a singlewithFrameNanosraced the measure pass and left the opening line glued to the top until the NEXT line re-anchored. The focus collector distinguishesLYRIC_FOCUS_TARGET_PENDING(index not yet published, e.g. -1 during an instrumental intro) fromLYRIC_FOCUS_TARGET_MANUAL(user scrolling / selection): a PENDING emission must NOT clearawaitingFirstFocus— 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 theLYRIC_FIRST_FOCUS_TIMEOUT_MSguard) releases the pre-focus hiding. -
Add-to-playlist sort persistence:
AddToPlaylistSortKey(enum, RECENTLY_CREATED default) backs the dialog's sort chips viarememberEnumPreference; 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, sharedsearchCollection) feed the Artists and Albums chips on the SoundCloud result screen; playlists with embedded tracks play as aListQueue(resolved throughresolveSoundCloudTrackToMetadata), 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'sLocalHapticFeedback(already wrapped at MainActivity's root): the hardcodedVibratorcalls —FlamingoHaptics(click/longClick/doubleClick readPreferenceStore.get(EnableHapticFeedbackKey)) andBitChordHaptics'HapticDevice(the systemHAPTIC_FEEDBACK_ENABLEDwatcher 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)callsapplyPlayedPrefixPreservingShuffleOrder()— 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 oldapplyCurrentFirstShuffleOrderre-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'sPredictiveStyleBackHandlerstays enabled while the sheet content is still composed during the exit animation (sheetContentOnScreenDisposableEffect) —state.isVisibleflips 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 bydismiss(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) andend = 20.dp, withbasicMarquee(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
isYearInMusicScreenis true,isBottomUiCompactis 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:
persistDirectStreamFormatmust run on BOTH the fresh-resolution path (applyDirectStream) AND thedirectStreamCacheHIT path inresolveMultiSourceDataSpec— 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 REALsampleRate/bitDepth/contentLengthend-to-end (Resolved→DirectStream→CurrentStreamInfo/FormatEntity): the manifest parsers readbitDepth, the account-manifest DirectStreams pass manifest sampleRate/bitDepth/bitrate, the main resolve estimatescontentLengthfrom the local file orbitrate × durationwhen the live stream has none — never rely on thepersistDirectStreamFormatlabel fallbacks (they fabricate 96kHz/2304kbps for HI_RES labels). The play-from popup'ssourceQualityDetailderives the TIDAL nominal specs from the user'sTidalAudioQualityKeysetting, 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.
- 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(seesettings.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-ffmpegresolves ONLY from the dmt-media3 maven repo viaexclusiveContentinsettings.gradle.kts; its transitive media3 artifacts are excluded inapp/build.gradle.ktsso the app's pinned media3 stays authoritative.DolbyFfmpegDecoderKeyflipsEXTENSION_RENDERER_MODE_PREFERinMusicService.createRenderersFactory(read once — restart playback to apply) and is mutually exclusive with bit-perfect, USB-exclusive and audio offload (both directions, enforced inAudiophileSettings). - 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
downloadCachespans (files only materialize at export,ExportDownloadedSongsScreen+AudioTagger). Every download also runsDownloadExtrasCoordinatoron queue: lyrics fetched through the user'sLyricsProviderOrderKeypriority into the lyrics DB and canvas resolved through the artwork-provider priority intoCanvasArtworkPlaybackCache(video files cached to disk), so downloaded songs play offline with lyrics and canvas. Exports embed the stored lyrics asFieldKey.LYRICSand the canvas url asFieldKey.CUSTOM1by default.
- 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
.ktfiles. No formatter task — match surrounding style.
settings.gradle.ktsdeclares a GCS mirror of Maven Central beforemavenCentral()— do not reorder (Maven Central 429-rate-limits CI).- JitPack is scoped via
exclusiveContentto an allow-list ofcom.github.*groups (TeamNewPipe, tdlibx, PRDownloader, jaudiotagger, MetrolistGroup…). A newcom.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 inapp/build.gradle.kts,gradle/libs.versions.toml, andplayback/stream/*on sync; resolve them by keeping the fork's native-only shape.
.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.
- 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.