Imported from Malinskiy/mellow (
AGENTS.md). Install upstream withnpx skills add Malinskiy/mellow. Copyright stays with the author.
AGENTS.md — Agentic Coding Guidelines for Mellow
Project Overview
Mellow is a native Android music player for Jellyfin. Kotlin, Jetpack Compose, Media3, offline-first Room database.
Code Structure
mellow/
├── app/ # Application entry, DI wiring, navigation
├── core/
│ ├── common/ # Shared utilities, result types, extensions
│ ├── model/ # Domain models (pure Kotlin, no Android deps)
│ ├── network/ # Jellyfin SDK wrapper (jellyfin-sdk-kotlin)
│ ├── database/ # Room DB, DAOs, entities, migrations
│ ├── data/ # Repository implementations (offline-first bridge)
│ └── player/ # Media3 player, MediaLibraryService, Android Auto
├── feature/
│ ├── home/ # Home screen (recent, favorites, quick access)
│ ├── library/ # Library browser (albums, artists, genres)
│ ├── player/ # Now playing, queue, lyrics
│ ├── search/ # Global search
│ └── settings/ # App configuration
└── sync/ # WorkManager-based library sync
Module Dependency Rules (HARD BLOCKS)
core/modelhas ZERO Android dependencies (pure Kotlin data classes)core/networkNEVER imports Room classescore/databaseNEVER imports jellyfin-sdk classescore/datais the ONLY module that bridges network ↔ databasefeature/*modules NEVER depend on each other- Only
appmay depend on all modules (wiring)
Offline-First Architecture (CRITICAL)
Room is the source of truth. The Jellyfin API is a sync target.
- Read path: Always read from Room. Background sync keeps it fresh.
- Write path: Write to Room immediately. Sync to server when online.
- Favorites, ratings, play counts all work offline and sync later.
Never bypass Room to read directly from the API in feature code.
Build Commands
# Build debug APK
./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
# Run all unit tests
./gradlew test
# Run specific module tests
./gradlew :core:database:test
./gradlew :core:player:test
# Run Android instrumented tests
./gradlew connectedAndroidTest
# Lint check
./gradlew lint
# Check all (build + test + lint)
./gradlew check
Gradle Wrapper
If the wrapper is missing, generate it:
gradle wrapper --gradle-version 8.12
Code Style Guidelines
Kotlin
-
Error Handling:
- Use
MellowResult<T>(sealed interface incore/common) for all repository methods - Never throw exceptions from repositories — wrap in
MellowResult.Error - ViewModels convert
MellowResultto UI state
- Use
-
Imports:
- Group: stdlib, kotlinx, android/androidx, third-party, project
- Use explicit imports (no wildcard
*)
-
Naming:
camelCasefor functions, properties, local variablesPascalCasefor classes, interfaces, type aliases, composablesSCREAMING_SNAKE_CASEfor constants- Prefix unused variables with
_
-
Constructors and IO:
- NEVER perform IO in constructors or
init {}blocks - Use suspend functions for all IO operations
- NEVER perform IO in constructors or
-
Coroutines:
- Use
Flow<T>for observable data streams from Room - Use
StateFlow<T>in ViewModels for UI state - Use
viewModelScopefor coroutine launches in ViewModels - Never use
GlobalScope
- Use
-
Formatting:
- Follow standard Kotlin style (ktlint defaults)
- Max line length: 120 characters
Jetpack Compose
-
Composable Functions:
@Composablefunctions that emit UI start with uppercase (e.g.,AlbumCard)- Accept
Modifieras first optional parameter:fun AlbumCard(modifier: Modifier = Modifier, ...) - Use
rememberandderivedStateOffor computed values
-
State Management:
- ViewModels expose
StateFlow<UiState> - Screens collect state with
collectAsStateWithLifecycle() - One-shot events use
Channel<Event>consumed viaLaunchedEffect
- ViewModels expose
-
Lists:
- Use
LazyColumn/LazyVerticalGridfor all scrollable lists - Use Paging 3 with
collectAsLazyPagingItems()for large datasets - Always provide stable
keyparameter
- Use
-
Images:
- Use Coil
AsyncImagefor all network images - Provide
placeholder(blurhash) anderrordrawables - Set explicit
contentScaleandcontentDescription
- Use Coil
-
Animations:
- Use
CrossfadeorAnimatedContentfor icon/content swaps — they skip animation on first composition automatically - Use
animateFloatAsState/animateDpAsStatefor state-driven value changes — also skips initial composition - Use
Animatableonly for imperative fire-and-forget effects (ripples, press feedback) triggered by user taps - Never use
LaunchedEffect(stateKey)+Animatable.animateTo()for state-driven animations — it animates on first composition. UseCrossfade/animateXAsStateinstead - Spring easing for pop/overshoot:
spring(dampingRatio = 0.45f, stiffness = 400f) - All animated icons live in
core/designsystem/component/:AnimatedPlayPause.kt,AnimatedHeartIcon.kt,AnimatedDownloadIcon.kt - Icon SVG paths rendered via
PathParser().parsePathString()on Canvas — Phosphor 256x256 viewport
- Use
Route / ComponentGroup / Component Architecture (Adaptive UI)
The UI layer uses a three-tier pattern for adaptive layouts across phone, tablet, and foldable devices:
Route → ComponentGroup → Component
-
Route — A navigation destination. Owns the ViewModel, collects state, handles nav events. Lives in
MellowNavHost.kt.- Example:
AlbumDetailRoutenavigates to album detail, createsAlbumDetailViewModel, collects state, passes to ComponentGroup.
- Example:
-
ComponentGroup — A layout composition of Components for a specific device class/posture. No ViewModel, no navigation — pure layout orchestration.
- Example:
AlbumDetailExpandedGroupplaces album art/info in a left pane and track list in a right pane. - ComponentGroups select which Components to show and in what arrangement (side-by-side, stacked, etc.).
- Example:
-
Component — A pure
@Composablethat renders a single UI concern. Accepts data + layout/chrome enums. No ViewModel, no nav, no device detection.- Example:
AlbumDetailComponent(layout: AlbumDetailLayout, chrome: DetailChrome, album: Album, tracks: List<Track>, ...) - Layout enums:
AlbumDetailLayout.Stacked/SplitScreen/SplitPane - Chrome enums:
DetailChrome.FullScreen/Pane/Sheet
- Example:
Rules:
- Components NEVER call
hiltViewModel(), accessNavController, or readLocalWindowWidthClass/LocalFoldableState - ComponentGroups MAY read device state (
LocalWindowWidthClass,LocalFoldableState) to select layout - Routes own the ViewModel lifecycle and provide data downward
- Each Component is independently testable via screenshot tests at any resolution
- ViewModels expose
setXxxId()for embedded/pane usage where nav args aren't available (e.g.,AlbumDetailViewModel.setAlbumId())
Current migration status:
AlbumDetailViewModelsupportssetAlbumId()for embedded pane usage (list-detail in Library)AlbumDetailComponentextracted withAlbumDetailLayoutandDetailChromeenums — no internal device detection- Target: every screen split into Route + ComponentGroup(s) + Component(s) with per-component screenshot tests
Room Database
-
Entities:
- All entities in
core/database/entity/ - Use
@Upsertfor sync operations (not INSERT OR REPLACE) - Include
lastSynced: Longon every entity
- All entities in
-
DAOs:
- Return
Flow<List<T>>for observable queries - Return
PagingSource<Int, T>for paginated queries - Suspend functions for writes
- Return
-
Migrations:
- Export schemas (
room { schemaDirectory(...) }) - Write migration tests for every schema change
- Never use
fallbackToDestructiveMigration()
- Export schemas (
Media3
-
MellowMediaService:
- Extends
MediaLibraryService(notMediaBrowserServiceCompat) - Creates ExoPlayer with audio-only attributes
- Exposes content tree for Android Auto via
LibrarySessionCallback
- Extends
-
Audio Attributes:
AUDIO_CONTENT_TYPE_MUSIC+USAGE_MEDIAhandleAudioFocus = true(automatic focus management)handleAudioBecomingNoisy = true(pause on headphone disconnect)
-
Caching:
SimpleCachewithLeastRecentlyUsedCacheEvictorfor streaming- Separate
DownloadManagerfor offline downloads - Shared cache directory between streaming and downloads
Jellyfin SDK
-
Client:
- Use
JellyfinClientWrapper(incore/network) — never createJellyfininstances directly - Call
connect()before any API use - Call
authenticate()with stored token on app start
- Use
-
API Calls:
- Always map SDK DTOs (
BaseItemDto) to domain models (Album,Track,Artist) - Mapping happens in
core/datarepository implementations - Never expose SDK types to feature modules
- Always map SDK DTOs (
-
Images:
- Build image URLs:
${serverUrl}/Items/${itemId}/Images/Primary?maxWidth=600&quality=90 - Use
ImageBlurHashesfor progressive loading placeholders
- Build image URLs:
Artwork Pipeline
Two delivery mechanisms — use the right one for the context:
-
In-app UI (Compose screens — Coil
AsyncImage):- Use
jellyfinImageUrl(serverUrl, itemId)→ HTTPS URL loaded by Coil - For tracks: always fall back to album image:
track.imageId ?: track.albumId - ~54% of tracks have no own
imageTag— the album holds the art
- Use
-
System surfaces (notification, Android Auto, MediaSession):
- Use
content://URIs viaArtworkProviderContentProvider - URI format:
content://${packageName}.artwork/${itemId} - ArtworkProvider fetches from Jellyfin API, caches to
cacheDir/artwork/{itemId}.jpg - Cached images survive offline — Android Auto works without server
ContentBitmapLoaderon MediaSession resolvescontent://URIs (default Media3 BitmapLoader only handles HTTPS)
- Use
Never use HTTPS URLs for MediaSession/notification artwork — Media3's default SimpleBitmapLoader uses URL.openStream() which doesn't support content://, and Android Auto rejects non-content:// URIs.
Never gate artwork on track.imageId != null — fall back to track.albumId since most tracks inherit album art. The correct pattern for track image URLs everywhere (screens, DTOs, search results):
val imgId = track.imageId ?: track.albumId
val imageUrl = if (serverUrl != null && imgId != null) jellyfinImageUrl(serverUrl, imgId) else null
When creating UI data classes that carry track image info (e.g. TrackItem, HomeTrackItem), always include both imageId and albumId fields so the fallback can be applied at the call site.
UI State Pattern (MANDATORY for all data-dependent screens)
Every screen or section that loads data from a repository, API, or database MUST handle all four states:
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
data object Empty : UiState<Nothing>
}
Rules
- Never show placeholder/fake data as if it were real — if data hasn't loaded, show a loading indicator
- Never show a blank screen — always show either loading spinner, empty state message, or error with retry
- Loading:
CircularProgressIndicatorcentered, or shimmer placeholders for lists/grids - Empty: Icon + message explaining why it's empty ("No albums yet", "No servers found on network")
- Error: Error message + "Retry" button. Never crash or show raw exception text
- Success: Render the actual data
Where this applies
- Library tabs (albums, artists, tracks, genres, folders)
- Album detail (track list)
- Artist detail (top tracks, discography)
- Search results
- Favorites (tracks, albums, artists)
- Playlists list + playlist detail
- Login screen server discovery section
- Settings server status
Mock data for screenshot tests
Screenshot tests call composables with mock data directly — they bypass ViewModels entirely. Mock data is passed via default parameter values on screen composables. This means:
- Production: ViewModel provides real state (loading/empty/error/success)
- Screenshot tests: Call
LibraryScreen(albumItems = mockData)— always gets success path - The loading/empty/error paths need separate screenshot test cases if visual testing is desired
Bug Fix Workflow
- Reproduce first: Write a test (unit or instrumented) that fails
- Fix minimally: Smallest change that fixes the bug
- Verify: Run the failing test — it must pass
- No refactoring during bugfix: Fix only. Refactor separately.
Pre-Commit Checklist
Before every commit:
./gradlew check
This runs: compile, lint, unit tests. Fix all issues before committing.
Important Files
gradle/libs.versions.toml— Version catalog (all dependency versions)settings.gradle.kts— Module includes and repository configurationARCHITECTURE.md— Detailed architecture decisions and rationaleFEATURES.md— Complete feature list with prioritiescore/player/src/main/java/dev/mellow/core/player/MellowMediaService.kt— The media servicecore/database/src/main/java/dev/mellow/core/database/MellowDatabase.kt— Room database
Emulator Image Interaction Rule
When taking screenshots or interacting with images via EMU MCP or any visual tool, the widest dimension must be under 2000px. Agents will break/error on images with 2000+ px widest dimension. Always verify device screen dimensions before capturing screenshots (device_info tool), and use appropriate maxWidth/maxHeight parameters when building image URLs.
Android Auto Testing
Use the Desktop Head Unit (DHU) for Android Auto testing:
$ANDROID_HOME/extras/google/auto/desktop-head-unit
Or use EMU MCP tools to test on emulator — see .agents/skills/emu-testing/SKILL.md.
Environment
- Kotlin 2.1.10
- AGP 8.8.2
- Compose BOM 2025.06.01
- Media3 1.6.0
- Hilt 2.56.2
- Room 2.7.1
- jellyfin-sdk-kotlin 1.8.7
- minSdk 26, targetSdk 36