Dylan is an offline-first personal music player. Kotlin Multiplatform core with native UIs (Jetpack Compose on Android, SwiftUI on iOS). Every track is downloaded, verified, and played from local storage — no streaming in v1.
Status: Incubating · License: Apache 2.0 · Personal use only — catalog access relies on undocumented endpoints with no redistribution grant. Do not distribute APKs or fetched content.
- Search — WebSocket type-ahead (800 ms) + HTTP full results, deduped across catalog buckets, recent-search chips
- Home — Trending albums, top-search chips, Jump Back In (last 5), Recently played albums (SQL
GROUP BY album_id), favorites - Albums & Artists — detail pages via
content.getAlbumDetails/webapi.getperma-token routing, play & shuffle end-to-end - Download-first playback — single-slot engine
USER_NOW > USER_BULK > PREFETCH_NEXTwith preemption, stall watchdog (20 s / rate wall 8 KB/s),Range/If-Rangeresume,Content-Length+ftyp/ID3verification, signed-URL resolve at dequeue (5 min TTL) - Offline cache — LRU ≤ 300 files / 2 GB,
.partaccounting, favorites auto-pin into 75% sub-pool with LRU demotion,clear-cacheprotects what's playing - Quality — 128 / 320 toggle, metered forces 128, never downgrades, upgrades only unmetered + earned
- Queue — shuffle anchors current track, repeat off/all/one, next-track auto-advance with late-join on download
- Platform — Media3
MediaSessionnotification, lock-screen controls, process-deathResumeSnapshot, Coil memory (48 MB) + disk (150 MB) artwork caches - Diagnostics —
LogBuffer(512 ring) +FileLogSink(512 KB × 3 files,DROP_OLDEST) atfiles/logs/dylan.log.*, week-later triage (seedocs/triage.md)
androidApp (Compose, minSdk 34) iosApp (SwiftUI, iOS 17+)
Screens ← StateFlows Screens ← @Observable Stores
DylanMediaService AudioEngine (AVQueuePlayer)
└ ExoPlayerEngine └ NativeAudioOutput
shared Kotlin core
PlaybackOrchestrator · QueueStateMachine · WindowPreparer · SnapshotStore
DownloadEngine · DownloadQueue · Fetcher · Verifier · Breaker
CatalogProvider + Mapper · SearchChannel (WS+HTTP) · CacheManager/Reconciler
SQLDelight (WAL, single dbLane) · okio fs · AppContainer (pure ctor start/stop)
FlowAdapter → Swift
Seams (only interfaces, each with a second impl today):
| Seam | Prod | Alt |
|---|---|---|
MusicProvider |
CatalogProvider |
test fakes |
SearchChannel |
WS + HTTP | HTTP-only fallback |
PlayerEngine |
ExoPlayerEngine / IosPlayerEngine |
FakeEngine in tests |
- JDK 17 (
/opt/homebrew/opt/openjdk@17on macOS) - Android SDK (
ANDROID_HOMEorplatform-toolson PATH) - For iOS: Xcode 16.2+ or use cloud CI (no local Xcode needed, see below)
git clone git@github.com:abhakash/dylan.git
cd dylan
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
# gates — must be green before any PR
./gradlew ktlintCheck detekt :shared:jvmTest --rerun-tasks --no-configuration-cache
# Android debug (sideload)
./gradlew :androidApp:assembleDebug --no-configuration-cache
adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk
# appId app.dylan.player, minSdk 34 / targetSdk 36
# release (R8, mapping)
./gradlew :androidApp:assembleRelease --no-configuration-cache
# live probe (real network, manual gate)
./gradlew :shared:probeLocal -PprobeFast --no-configuration-cache
# iOS klibs only (no Xcode)
./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64 --no-configuration-cachePush to GitHub — ci builds the XCFramework + xcodebuild -sdk iphonesimulator on macos-14 (Xcode 16.2). For device IPA: Actions → ios-release → Run workflow → testflight (needs APPLE_TEAM_ID + cert/profile secrets). IPHONEOS_DEPLOYMENT_TARGET = 17.0 runs on iOS 26 devices.
./gradlew :shared:jvmTest --rerun-tasks --no-configuration-cache
./gradlew :shared:probeCi --no-configuration-cache # structural contract
./gradlew :shared:contractDrift --no-configuration-cache # nightly-only: live vs fixtures (not presubmit)Fixtures in fixtures/ are sanitized real responses backing Mapper tests.
dylan/
├── androidApp/ # Compose UI, DylanMediaService, ExoPlayerEngine
├── iosApp/ # SwiftUI, NativeAudioOutputImpl, Bridge
├── shared/
│ ├── commonMain/ # core: provider/mapper, playback, download, cache, db
│ ├── androidMain/ # DriverFactory, NetClass, Util
│ └── iosMain/ # DriverFactory, IosGraph, IosPlayerEngine
├── fixtures/ # sanitized API responses
├── gradle/ # libs.versions.toml (single version source)
├── .github/workflows/ # ci.yml (presubmit: lint/test/debug) + builds.yml (debug/prod lanes) + ios-free/release.yml
└── config/detekt.yml
ci.yml (presubmit, concurrency: cancel-in-progress):
wrapper-validation→lint(ktlint+detekt+Android lint) →test/jvm(jvmTest+probeCi) →android/debug(debug APK) +ios/klib+ios/simulator(:shared:assemble+xcodebuild CODE_SIGNING_ALLOWED=NO) +gosign(dylan-sign build/vet/test on macOS+Linux)- Cache:
gradle/actions/setup-gradle(cache-read-onlyon PRs;GRADLE_ENCRYPTION_KEYis set on thelintjob cache) +~/.konan/DerivedData (shared/buildintentionally uncached) - Artifacts:
apks-debug+lint-reports+xcode-logs(14d) - Nightly (
schedule/ manual):nightly/probe(probeLocal) +nightly/contract-drift(live vs fixtures)
builds.yml (manual flavor: debug/prod/all × platform: android/ios/all, plus auto on v* tags): android/debug (debug APK, no secrets) + android/prod (R8 + signed APK + AAB, keystore secrets, graceful skip when absent, apksigner guard refuses debug-signed output) + ios/debug (sim Debug) + ios/prod-unsigned (Release unsigned IPA with version stamp + sha256, asserted unsigned — sign via tools/dylan-sign). Prod artifacts kept 30d.
version.yml (each main push, except bot pushes): bumps VERSION from the HEAD message (feat: → minor, !:/BREAKING CHANGE → major, else patch) via tools/bump-version.sh, syncs iOS MARKETING_VERSION/CURRENT_PROJECT_VERSION, commits chore(version): vX.Y.Z [skip ci], and pushes tag vX.Y.Z — which triggers builds.yml prod lanes. Loop-safe (actor guard + [skip ci] + rebase-retry, serialized via concurrency group).
ios-free.yml (manual unsigned Release IPA, same as builds.yml: ios/prod-unsigned standalone) and ios-release.yml (workflow_dispatch: testflight/adhoc/simulator) → archive → templated exportOptions.plist (teamID from APPLE_TEAM_ID secret) → TestFlight via fastlane pilot (fails clearly without API-key secrets); signing material trap-cleaned.
Branch protection on main: require lint, test/jvm, android/debug, ios/klib, ios/simulator (+ gosign) + CODEOWNERS review; release pushes need bypass allowance (see release.yml).
Dependabot weekly groups ktor/compose/kotlin/sqldelight.
PRs must be green (ktlintCheck detekt jvmTest + android + ios). Keep seams minimal (Law 3: every interface needs a nameable second impl today). No runBlocking on state lane, no synchronized in commonMain.
Apache 2.0 — see LICENSE. Copyright 2026 Dylan Contributors.
Coil, ExoPlayer/Media3, SQLDelight, Ktor, okio. Icon derived from a 1965 publicity still (public domain).