Skip to content

Repository files navigation

Dylan — Offline-First Music Player

CI License Kotlin Platform

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.


Features

  • 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.get perma-token routing, play & shuffle end-to-end
  • Download-first playback — single-slot engine USER_NOW > USER_BULK > PREFETCH_NEXT with preemption, stall watchdog (20 s / rate wall 8 KB/s), Range/If-Range resume, Content-Length + ftyp/ID3 verification, signed-URL resolve at dequeue (5 min TTL)
  • Offline cache — LRU ≤ 300 files / 2 GB, .part accounting, favorites auto-pin into 75% sub-pool with LRU demotion, clear-cache protects 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 MediaSession notification, lock-screen controls, process-death ResumeSnapshot, Coil memory (48 MB) + disk (150 MB) artwork caches
  • DiagnosticsLogBuffer (512 ring) + FileLogSink (512 KB × 3 files, DROP_OLDEST) at files/logs/dylan.log.*, week-later triage (see docs/triage.md)

Architecture

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

Getting Started

Prerequisites

  • JDK 17 (/opt/homebrew/opt/openjdk@17 on macOS)
  • Android SDK (ANDROID_HOME or platform-tools on PATH)
  • For iOS: Xcode 16.2+ or use cloud CI (no local Xcode needed, see below)

Clone & Build

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-cache

iOS without local Xcode

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


Testing

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


Project Structure

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/CD — FAANG Style

ci.yml (presubmit, concurrency: cancel-in-progress):

  • wrapper-validationlint (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-only on PRs; GRADLE_ENCRYPTION_KEY is set on the lint job cache) + ~/.konan/DerivedData (shared/build intentionally 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.


Contributing

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.


License

Apache 2.0 — see LICENSE. Copyright 2026 Dylan Contributors.


Acknowledgments

Coil, ExoPlayer/Media3, SQLDelight, Ktor, okio. Icon derived from a 1965 publicity still (public domain).

About

No-nonsense Music streaming app

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages