Sprint 4: M4-M7 — marketplace, device sync, scrobble, lyrics + audio analysis

- Marketplace: full screen parity (apps, games, Music+Videos catalog)
- Device link: pairing, sync engine, transport abstraction, golden simulator
- Scrobble: Last.fm client + signature, scrobble store, background service
- Lyrics: LrcLib fetch + synced lyric screen
- Audio analysis: feature extractor, dynamic mix, PCM decoder
- New Room entities/DAOs/migrations for sync, features, scrobbles
- Settings: device link, scrobble, sync configuration
- Parity roadmap doc + sync_golden tool scaffold
- Tests: audio, scrobble, sync, device link, lyrics, dynamic mix
This commit is contained in:
John Smith
2026-09-10 14:22:29 -04:00
parent 8b7f4c50be
commit 5df8cebb3a
62 changed files with 4405 additions and 184 deletions
+4
View File
@@ -16,5 +16,9 @@ captures/
# Logs
*.log
# .NET golden-fixture generator (tools/sync_golden)
tools/sync_golden/bin/
tools/sync_golden/obj/
# Reverse-engineering corpus (never commit Microsoft binaries)
review/
+19 -13
View File
@@ -135,7 +135,7 @@ archive. Binaries are encrypted (AES-ECB, marketplace DRM) and
### Tests
36 Robolectric unit tests + a design-invariant source scanner that
81 Robolectric unit tests + a design-invariant source scanner that
forbids `RoundedCornerShape`, `spring(`, raw hex `Color(0x…)`, and
named `Color.Black/White/Red/…` constants in `ui/` and `ui/apps/`.
@@ -180,7 +180,7 @@ mini-app platform.
| Smart DJ (hearts/breaks) | ✅ | ✅ | Surfaced as a lane in Quickplay |
| Lock shade (wallpaper behind) | ✅ | ✅ | Real `WallpaperManager.getDrawable()` |
| Now Playing (scrub, transport, screensaver) | ✅ | ✅ | Scrubber + transport + screensaver; tap combines dismiss + overlay |
| Artist bio / photos / related | ✅ | ✅ | Wikipedia REST + MusicBrainz; related pivot has honest empty-state |
| Artist bio / photos / related | ✅ | ✅ | Wikipedia REST + MusicBrainz; related pivot ranks by on-device audio similarity (M9) |
| Music crossbar (5 pivots) | ✅ | ✅ | 380 ms deceleration per `DoradoMotion.PIVOT_SLIDE_MS` |
| Album art palette wash | ✅ | ✅ | |
| Tri-state heart rating | ✅ | ✅ | Persisted in Room |
@@ -194,7 +194,7 @@ mini-app platform.
| Crossbar pivot nav | ✅ | ✅ | All collection screens have horizontal pivot pivots |
| Screensaver / now-playing art | ✅ | ✅ | |
| Widget (home-screen) | ✅ | ❌ | Not yet — see roadmap |
| Wi-Fi sync with Dorado (sibling project) | ✅ | ❌ | Not yet — see roadmap |
| Wi-Fi sync with Dorado (sibling project) | ✅ | ◐ | M8.1–8.3: engine + transport core + Device view (simulated target); LAN pairing pending M8.2b |
| USB MTPZ sync to physical Zune HD | ✅ | ❌ | Stretch goal — see roadmap |
### Design-invariant scanner (enforced by `DesignInvariantTest`)
@@ -239,17 +239,23 @@ The release build minifies with R8; keep rules in `app/proguard-rules.pro`.
## 🛣️ Roadmap
### M4 — MiniApp + marketplace completeness *(next)*
### M4 — MiniApp + marketplace completeness ✅ *complete*
- Wire `onLongClick → pin to Quickplay` on every list that currently
leaves it empty (Genres, Videos list, Picture buckets, Podcast feeds +
episodes, Radio stations, Marketplace installed + frozen catalog, Artist
→ Songs, Playlist detail).
- Add `play-next` action in the long-press menu (the Quicklist
replacement the community asked for since firmware 3.x removed it).
- Replace `EmptyPivot("coming soon")` in Marketplace with curated lists
drawn from the user's library.
- Add a `games` pivot in Marketplace crossbar.
- Every empty list long-press under `ui/screens/` now pins to Quickplay
(Genres, Videos, Pictures, Podcast feeds + episodes, Marketplace albums
+ installed apps). Non-actionable affordances (buttons, picture buckets)
no longer carry a dead `onLongClick`.
- The shared `trackMenuActions` helper adds **play-next** to every track
row (the Quicklist replacement the community asked for since firmware
3.x removed it).
- Marketplace `videos` and `podcasts` pivots draw from the library; the
`EmptyPivot("coming soon")` is gone, and a `games` pivot joins the
crossbar.
Next up: **M10 Always-on surfaces** — M9 Modern Listening is complete (audio
similarity, Dynamic Mix, DSP features, Last.fm scrobbling, LRCLIB lyrics).
Outstanding elsewhere: M8 Device Link 8.2b (live LAN pairing) and M9.2b
(persisted play counts) — see [`docs/parity-roadmap.md`](docs/parity-roadmap.md).
### M5 — Social + discovery
@@ -41,6 +41,11 @@ class DoradoGraph(
val radio: com.heretek.dorado_hd.data.repo.RadioRepository,
val podcasts: com.heretek.dorado_hd.data.repo.PodcastRepository,
val games: com.heretek.dorado_hd.data.repo.GameRepository,
val deviceLink: com.heretek.dorado_hd.data.repo.DeviceLinkRepository,
val analysis: com.heretek.dorado_hd.analysis.AudioAnalysisService,
val mixes: com.heretek.dorado_hd.analysis.DynamicMixService,
val scrobble: com.heretek.dorado_hd.scrobble.ScrobbleService,
val lyrics: com.heretek.dorado_hd.net.LrcLibService,
)
class DoradoApp : Application() {
@@ -56,7 +61,19 @@ class DoradoApp : Application() {
val library = LibraryRepository(this, db)
val quickplay = QuickplayRepository(db)
val settings = SettingsRepository(this)
val controller = PlaybackController(this, library, quickplay)
val latestSettings = java.util.concurrent.atomic.AtomicReference(com.heretek.dorado_hd.data.repo.DoradoSettings())
val scrobbleStore = com.heretek.dorado_hd.data.repo.RoomScrobbleStore(db.scrobbleDao())
val scrobbleSink = com.heretek.dorado_hd.net.LastFmClient(
apiKey = { latestSettings.get().lastFmApiKey },
secret = { latestSettings.get().lastFmApiSecret },
sessionKey = { latestSettings.get().lastFmSessionKey },
)
val scrobble = com.heretek.dorado_hd.scrobble.ScrobbleService(
store = scrobbleStore,
sink = scrobbleSink,
enabled = { latestSettings.get().scrobbleEnabled },
)
val controller = PlaybackController(this, library, quickplay, scrobble)
val nav = DoradoNav()
val artistImages = ArtistImageService(this, db, settings)
val artistBios = com.heretek.dorado_hd.net.ArtistBioService(this)
@@ -66,21 +83,41 @@ class DoradoApp : Application() {
val radio = com.heretek.dorado_hd.data.repo.RadioRepository(db)
val podcasts = com.heretek.dorado_hd.data.repo.PodcastRepository(db)
val games = com.heretek.dorado_hd.data.repo.GameRepository(db)
val deviceLink = com.heretek.dorado_hd.data.repo.DeviceLinkRepository(this)
val featureStore = com.heretek.dorado_hd.data.repo.RoomFeatureStore(db.trackFeatureDao())
val analyzer = com.heretek.dorado_hd.analysis.PcmFeatureAnalyzer(this)
val analysis = com.heretek.dorado_hd.analysis.AudioAnalysisService(featureStore, analyzer)
val mixes = com.heretek.dorado_hd.analysis.DynamicMixService(analysis)
val lyrics = com.heretek.dorado_hd.net.LrcLibService()
graph = DoradoGraph(
library, quickplay, settings, settings.settings, controller, nav,
artistImages, artistBios, notes, calendar, alarms, radio, podcasts, games,
artistImages, artistBios, notes, calendar, alarms, radio, podcasts, games, deviceLink, analysis, mixes, scrobble, lyrics,
)
appScope.launch {
controller.connect()
}
// Hydrate the M9.3 audio-feature cache from Room so similarity queries
// immediately use previously analyzed tracks.
appScope.launch {
runCatching { analysis.preload() }
}
// Keep the artist-image service aware of the user's settings.
settings.settings
.onEach { artistImages.settingsSnapshot = it }
.onEach {
artistImages.settingsSnapshot = it
latestSettings.set(it)
}
.launchIn(appScope)
// Flush any scrobbles queued while offline (no-op until enabled + configured).
appScope.launch {
runCatching { scrobble.flush() }
}
// Opt-in MediaStore watcher (Settings > collection > watch media store).
// Debounced 2 s to coalesce bursts (e.g. mass-transfer).
var watcher: ContentObserver? = null
@@ -0,0 +1,137 @@
package com.heretek.dorado_hd.analysis
import com.heretek.dorado_hd.data.model.Artist
import com.heretek.dorado_hd.data.model.Track
/**
* In-memory feature cache + cosine-similarity ranking, mirroring the sibling
* desktop `AudioAnalysisService` (Phase 13a). Pure JVM: no Android
* dependencies, so the ranking is unit-testable. This is what makes the artist
* page's `related` pivot a real signal instead of a genre-only approximation.
*
* Persistence of feature vectors is deferred to M9.1b, when a DSP extractor
* replaces the metadata prior and analysis becomes expensive.
*/
class AudioAnalysisService(
private val store: FeatureStore? = null,
private val analyzer: FeatureAnalyzer = FeatureAnalyzer { AudioFeatureExtractor.extract(it) },
) {
private val cache = HashMap<Long, AudioFeatures>()
@Volatile
private var loaded = false
/** Hydrates the in-memory cache from the store (once). */
suspend fun preload() {
if (loaded) return
store?.load()?.forEach { cache[it.trackId] = it }
loaded = true
}
/** Computes features via the analyzer (DSP when wired), caches and persists. */
suspend fun analyze(track: Track): AudioFeatures {
preload()
val features = analyzer.analyze(track)
cache[track.mediaId] = features
store?.save(features)
return features
}
/** Analyzes every track not already cached/persisted. */
suspend fun analyzeAll(tracks: List<Track>) {
preload()
tracks.forEach { track ->
if (track.mediaId !in cache) analyze(track)
}
}
/**
* Synchronous feature lookup: the cache if analyzed, else the (cheap)
* metadata prior so query methods need no suspend context.
*/
fun featuresFor(track: Track): AudioFeatures =
cache.getOrPut(track.mediaId) { AudioFeatureExtractor.extract(track) }
/** Tracks most similar to [seed], excluding the seed itself. */
fun findSimilar(seed: Track, library: List<Track>, count: Int = 25): List<Track> {
return rank(library.filter { it.mediaId != seed.mediaId }, vectorFor(seed), count)
}
/** Tracks nearest the centroid of [favorites]. */
fun findSimilarToFavorites(favorites: List<Track>, library: List<Track>, count: Int = 50): List<Track> {
if (favorites.isEmpty()) {
return library.sortedBy { it.title.lowercase() }.take(count)
}
val centroid = centroid(favorites.map { vectorFor(it) })
val favoriteIds = favorites.map { it.mediaId }.toSet()
return rank(library.filter { it.mediaId !in favoriteIds }, centroid, count)
}
/**
* Artists whose catalog is closest to [seedArtistId]'s catalog centroid,
* for the artist page's `related` pivot. Empty when the seed has no tracks.
*/
fun relatedArtists(
seedArtistId: Long,
artists: List<Artist>,
library: List<Track>,
count: Int = 12,
): List<Artist> {
val seedTracks = library.filter { it.artistId == seedArtistId }
if (seedTracks.isEmpty()) return emptyList()
val seedCentroid = centroid(seedTracks.map { vectorFor(it) })
return library
.filter { it.artistId != seedArtistId }
.groupBy { it.artistId }
.mapNotNull { (artistId, tracks) ->
val artist = artists.firstOrNull { it.artistId == artistId } ?: return@mapNotNull null
artist to cosine(seedCentroid, centroid(tracks.map { vectorFor(it) }))
}
.sortedWith(
compareByDescending<Pair<Artist, Double>> { it.second }
.thenBy { it.first.name.lowercase() },
)
.take(count)
.map { it.first }
}
private fun vectorFor(track: Track): DoubleArray = featuresFor(track).toVector()
private fun rank(candidates: List<Track>, target: DoubleArray, count: Int): List<Track> =
candidates
.map { it to cosine(target, vectorFor(it)) }
.sortedWith(
compareByDescending<Pair<Track, Double>> { it.second }
.thenBy { it.first.title.lowercase() },
)
.take(count)
.map { it.first }
private fun centroid(vectors: List<DoubleArray>): DoubleArray {
val result = DoubleArray(VECTOR_SIZE)
if (vectors.isEmpty()) return result
for (vector in vectors) {
for (i in 0 until VECTOR_SIZE) result[i] += vector[i]
}
for (i in 0 until VECTOR_SIZE) result[i] /= vectors.size
return result
}
companion object {
const val VECTOR_SIZE = 6
fun cosine(a: DoubleArray, b: DoubleArray): Double {
var dot = 0.0
var magA = 0.0
var magB = 0.0
for (i in a.indices) {
dot += a[i] * b[i]
magA += a[i] * a[i]
magB += b[i] * b[i]
}
if (magA == 0.0 || magB == 0.0) return 0.0
return dot / (Math.sqrt(magA) * Math.sqrt(magB))
}
}
}
@@ -0,0 +1,85 @@
package com.heretek.dorado_hd.analysis
import com.heretek.dorado_hd.data.model.Track
/**
* Derives a deterministic audio-feature prior from track metadata (genre, id),
* 1:1 with the sibling desktop `AudioFeatureExtractor` (Phase 13a). This is a
* replaceable metadata prior, not a DSP extractor: a future implementation can
* compute the same [AudioFeatures] shape from decoded PCM without downstream
* changes.
*/
object AudioFeatureExtractor {
fun extract(track: Track): AudioFeatures {
val genre = track.genre.lowercase()
var energy = 0.5
var valence = 0.5
var acousticness = 0.3
var danceability = 0.5
var bpm = 120.0
if (has(genre, "metal", "punk", "hardcore")) { energy += 0.3; valence -= 0.2; bpm = 140.0 }
if (has(genre, "rock")) { energy += 0.15; valence += 0.05; bpm = 125.0 }
if (has(genre, "electronic", "edm", "house", "techno", "trance")) { energy += 0.2; danceability += 0.3; valence += 0.1; bpm = 128.0 }
if (has(genre, "dance", "disco", "funk")) { danceability += 0.3; valence += 0.2; bpm = 118.0 }
if (has(genre, "hip", "rap")) { danceability += 0.2; energy += 0.1; bpm = 92.0 }
if (has(genre, "pop")) { valence += 0.2; danceability += 0.15; bpm = 116.0 }
if (has(genre, "jazz", "blues")) { acousticness += 0.3; valence += 0.05; bpm = 110.0 }
if (has(genre, "classical", "orchestra", "opera")) { acousticness += 0.5; energy -= 0.2; bpm = 90.0 }
if (has(genre, "folk", "acoustic", "country", "singer-songwriter")) { acousticness += 0.4; energy -= 0.1; bpm = 100.0 }
if (has(genre, "ambient", "drone", "new age")) { energy -= 0.25; acousticness += 0.2; bpm = 80.0 }
if (has(genre, "soul", "r&b", "rnb")) { valence += 0.15; danceability += 0.1; bpm = 102.0 }
// Deterministic per-track jitter so identical-genre tracks differ.
val jitter = (track.mediaId.hashCode() and 0xFF) / 255.0
energy = clamp01(energy + (jitter - 0.5) * 0.12)
valence = clamp01(valence + (jitter - 0.5) * 0.10)
bpm = (bpm + (jitter - 0.5) * 12.0).coerceIn(60.0, 200.0)
val spectralCentroid = clamp01(0.25 + (energy * 0.5) + (jitter - 0.5) * 0.1)
return AudioFeatures(
trackId = track.mediaId,
bpm = bpm,
energy = energy,
valence = valence,
acousticness = clamp01(acousticness),
danceability = danceability,
spectralCentroid = spectralCentroid,
)
}
/**
* Hybrid extractor (M9.3): refines the metadata prior with DSP descriptors
* computed from a decoded PCM window — loudness/energy, spectral centroid
* and zero-crossing rate. Valence/danceability remain from the prior, which
* a real MIR model would replace. Deterministic for the same window.
*/
fun fromPcm(track: Track, samples: FloatArray, sampleRate: Int): AudioFeatures {
val prior = extract(track)
if (samples.isEmpty() || sampleRate <= 0) return prior
val rms = FeatureMath.rms(samples)
val centroid = FeatureMath.spectralCentroidNormalized(samples, sampleRate)
val zcr = FeatureMath.zeroCrossingRate(samples)
val energy = clamp01(
FeatureMath.loudness(rms) * 0.6 +
prior.energy * 0.25 +
(zcr * 2.0).coerceIn(0.0, 1.0) * 0.15,
)
val acousticness = clamp01(prior.acousticness * 0.6 + (1.0 - centroid) * 0.4)
return prior.copy(
energy = energy,
spectralCentroid = centroid,
acousticness = acousticness,
)
}
private fun has(genre: String, vararg tokens: String): Boolean =
tokens.any { genre.contains(it) }
private fun clamp01(value: Double): Double = value.coerceIn(0.0, 1.0)
}
@@ -0,0 +1,28 @@
package com.heretek.dorado_hd.analysis
/**
* Per-track audio feature vector, mirroring the sibling desktop
* `Dorado.Domain.Models.AudioFeatures` (Phase 13a). Values are normalized 0..1
* except [bpm]. Populated by [AudioFeatureExtractor]; the current extractor
* derives a deterministic metadata prior and is designed to be replaced by a
* DSP extractor behind the same shape.
*/
data class AudioFeatures(
val trackId: Long,
val bpm: Double,
val energy: Double,
val valence: Double,
val acousticness: Double,
val danceability: Double,
val spectralCentroid: Double,
) {
/** Six-dimensional normalized vector for cosine similarity. */
fun toVector(): DoubleArray = doubleArrayOf(
(bpm / 200.0).coerceIn(0.0, 1.0),
energy,
valence,
acousticness,
danceability,
spectralCentroid,
)
}
@@ -0,0 +1,22 @@
package com.heretek.dorado_hd.analysis
/**
* A rune-style auto-updating "Mix": a rule that materializes a track list from
* the library on demand. Mirrors the sibling desktop `DynamicMix`/`DynamicMixKind`
* (Phase 13a).
*/
enum class DynamicMixKind {
SIMILAR_TO_ALBUM,
SIMILAR_TO_TRACK,
TOP_PLAYED,
SIMILAR_TO_FAVORITES,
PLAYLISTS_INCLUDING_ARTIST,
}
data class DynamicMix(
val name: String,
val kind: DynamicMixKind = DynamicMixKind.TOP_PLAYED,
val seedId: Long? = null,
val seedText: String? = null,
val trackLimit: Int = 50,
)
@@ -0,0 +1,66 @@
package com.heretek.dorado_hd.analysis
import com.heretek.dorado_hd.data.model.Track
/**
* Materializes rune-style auto-updating Mixes from the current library,
* mirroring the sibling desktop `DynamicMixService` (Phase 13a). Pure JVM.
*
* `TOP_PLAYED` reads an injected play-count map; until play counts are
* persisted (M9.2b) it degrades to title order.
*/
class DynamicMixService(private val analysis: AudioAnalysisService) {
fun buildDefaultMixes(): List<DynamicMix> = listOf(
DynamicMix(name = "Favorites Mix", kind = DynamicMixKind.SIMILAR_TO_FAVORITES, trackLimit = 50),
)
fun materialize(
mix: DynamicMix,
library: List<Track>,
favorites: List<Track> = emptyList(),
playCounts: Map<Long, Int> = emptyMap(),
): List<Track> {
if (library.isEmpty()) return emptyList()
return when (mix.kind) {
DynamicMixKind.TOP_PLAYED ->
library
.sortedWith(
compareByDescending<Track> { playCounts[it.mediaId] ?: 0 }
.thenBy { it.title.lowercase() },
)
.take(mix.trackLimit)
DynamicMixKind.SIMILAR_TO_FAVORITES ->
analysis.findSimilarToFavorites(favorites, library, mix.trackLimit)
DynamicMixKind.SIMILAR_TO_TRACK -> {
val seed = mix.seedId?.let { id -> library.firstOrNull { it.mediaId == id } }
?: return emptyList()
analysis.findSimilar(seed, library, mix.trackLimit)
}
DynamicMixKind.SIMILAR_TO_ALBUM -> {
val seed = mix.seedId?.let { id -> library.firstOrNull { it.mediaId == id } }
?: return emptyList()
// Use a *different* track from the seed's album as the representative.
val representative = library
.firstOrNull { it.albumId == seed.albumId && it.mediaId != seed.mediaId }
?: seed
analysis.findSimilar(representative, library, mix.trackLimit)
}
DynamicMixKind.PLAYLISTS_INCLUDING_ARTIST -> {
val text = mix.seedText?.takeIf { it.isNotBlank() } ?: return emptyList()
library
.filter { it.artist.contains(text, ignoreCase = true) }
.sortedWith(
compareByDescending<Track> { playCounts[it.mediaId] ?: 0 }
.thenBy { it.title.lowercase() },
)
.take(mix.trackLimit)
}
}
}
}
@@ -0,0 +1,149 @@
package com.heretek.dorado_hd.analysis
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.ln
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Pure DSP helpers for the M9.3 PCM extractor: a radix-2 FFT and the spectral
* descriptors the feature vector needs. No Android dependencies, so every
* function here is unit-tested directly.
*/
object FeatureMath {
/** Root-mean-square amplitude of a [-1, 1] sample buffer. */
fun rms(samples: FloatArray): Double {
if (samples.isEmpty()) return 0.0
var sum = 0.0
for (s in samples) sum += s.toDouble() * s
return sqrt(sum / samples.size)
}
/** Fraction of adjacent sample pairs that cross zero (0..1). */
fun zeroCrossingRate(samples: FloatArray): Double {
if (samples.size < 2) return 0.0
var crossings = 0
for (i in 1 until samples.size) {
if ((samples[i - 1] < 0f) != (samples[i] < 0f)) crossings++
}
return crossings.toDouble() / (samples.size - 1)
}
/**
* Spectral centroid normalized to 0..1 (centroid Hz / Nyquist), using a
* Hann-windowed FFT magnitude spectrum.
*/
fun spectralCentroidNormalized(samples: FloatArray, sampleRate: Int): Double {
if (samples.isEmpty() || sampleRate <= 0) return 0.0
val magnitudes = fftMagnitudes(samples)
val binHz = (sampleRate / 2.0) / magnitudes.size
var weighted = 0.0
var total = 0.0
for (k in magnitudes.indices) {
val freq = (k + 1) * binHz
weighted += freq * magnitudes[k]
total += magnitudes[k]
}
if (total <= 0.0) return 0.0
val centroidHz = weighted / total
return (centroidHz / (sampleRate / 2.0)).coerceIn(0.0, 1.0)
}
/** Dominant non-DC frequency in Hz (used by tests and future tempo work). */
fun dominantFrequency(samples: FloatArray, sampleRate: Int): Double {
if (samples.isEmpty() || sampleRate <= 0) return 0.0
val magnitudes = fftMagnitudes(samples)
var bestIndex = 0
var best = -1.0
for (k in magnitudes.indices) {
if (magnitudes[k] > best) { best = magnitudes[k]; bestIndex = k }
}
val binHz = (sampleRate / 2.0) / magnitudes.size
return (bestIndex + 1) * binHz
}
/**
* Magnitude spectrum (first half) of a Hann-windowed signal, length rounded
* up to the next power of two.
*/
fun fftMagnitudes(samples: FloatArray): DoubleArray {
val n = nextPowerOfTwo(samples.size)
if (n == 0) return DoubleArray(0)
val re = DoubleArray(n)
val im = DoubleArray(n)
for (i in samples.indices) re[i] = samples[i].toDouble() * hann(i, samples.size)
fft(re, im)
val half = n / 2
val mags = DoubleArray(half)
for (k in 0 until half) mags[k] = hypot(re[k], im[k])
return mags
}
/** Perceptual "loudness" in dBFS mapped to 0..1 (silence → 0). */
fun loudness(rms: Double): Double {
if (rms <= 0.0) return 0.0
val db = 20.0 * (ln(rms) / ln(10.0))
return ((db + 60.0) / 60.0).coerceIn(0.0, 1.0)
}
private fun hann(i: Int, size: Int): Double {
if (size <= 1) return 1.0
return 0.5 * (1.0 - cos(2.0 * PI * i / (size - 1)))
}
private fun nextPowerOfTwo(value: Int): Int {
if (value <= 0) return 0
var n = 1
while (n < value) n = n shl 1
return n
}
/** In-place iterative radix-2 Cooley-Tukey FFT. */
private fun fft(re: DoubleArray, im: DoubleArray) {
val n = re.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
val tr = re[i]; re[i] = re[j]; re[j] = tr
val ti = im[i]; im[i] = im[j]; im[j] = ti
}
}
var len = 2
while (len <= n) {
val angle = -2.0 * PI / len
val wLenRe = cos(angle)
val wLenIm = sin(angle)
var i = 0
while (i < n) {
var wRe = 1.0
var wIm = 0.0
val half = len / 2
for (k in 0 until half) {
val uRe = re[i + k]
val uIm = im[i + k]
val vRe = re[i + k + half] * wRe - im[i + k + half] * wIm
val vIm = re[i + k + half] * wIm + im[i + k + half] * wRe
re[i + k] = uRe + vRe
im[i + k] = uIm + vIm
re[i + k + half] = uRe - vRe
im[i + k + half] = uIm - vIm
val nextWRe = wRe * wLenRe - wIm * wLenIm
wIm = wRe * wLenIm + wIm * wLenRe
wRe = nextWRe
}
i += len
}
len = len shl 1
}
}
}
@@ -0,0 +1,17 @@
package com.heretek.dorado_hd.analysis
import com.heretek.dorado_hd.data.model.Track
/** Persistence boundary for analyzed [AudioFeatures] (Room-backed in the app). */
interface FeatureStore {
suspend fun load(): List<AudioFeatures>
suspend fun save(features: AudioFeatures)
}
/**
* Computes features for a track. The app supplies a PCM/DSP analyzer;
* the default is the pure metadata prior, which keeps the service testable.
*/
fun interface FeatureAnalyzer {
suspend fun analyze(track: Track): AudioFeatures
}
@@ -0,0 +1,101 @@
package com.heretek.dorado_hd.analysis
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.ByteOrder
/**
* Thin best-effort Android glue: decodes the first [MAX_SAMPLES] of 16-bit PCM
* from a media URI for the DSP extractor. Any failure returns null so the
* caller falls back to the metadata prior — analysis must never break playback
* or crash the UI.
*/
object PcmDecoder {
private const val TIMEOUT_US = 10_000L
private const val MAX_SAMPLES = 1 shl 19
suspend fun decodeWindow(
context: Context,
uri: Uri,
maxSamples: Int = MAX_SAMPLES,
): Pair<FloatArray, Int>? = withContext(Dispatchers.IO) {
runCatching { decode(context, uri, maxSamples) }.getOrNull()
}
private fun decode(context: Context, uri: Uri, maxSamples: Int): Pair<FloatArray, Int>? {
val extractor = MediaExtractor()
var codec: MediaCodec? = null
try {
extractor.setDataSource(context, uri, null)
var format: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val candidate = extractor.getTrackFormat(i)
val mime = candidate.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
format = candidate
extractor.selectTrack(i)
break
}
}
val selected = format ?: return null
val mime = selected.getString(MediaFormat.KEY_MIME) ?: return null
val sampleRate = selected.getInteger(MediaFormat.KEY_SAMPLE_RATE)
codec = MediaCodec.createDecoderByType(mime)
codec.configure(selected, null, null, 0)
codec.start()
val out = FloatArray(maxSamples)
var count = 0
val info = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone && count < maxSamples) {
if (!inputDone) {
val inIndex = codec.dequeueInputBuffer(TIMEOUT_US)
if (inIndex >= 0) {
val buffer = codec.getInputBuffer(inIndex) ?: continue
val size = extractor.readSampleData(buffer, 0)
if (size < 0) {
codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
val outIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
if (outIndex >= 0) {
val buffer = codec.getOutputBuffer(outIndex)
if (buffer != null && info.size >= 2) {
buffer.order(ByteOrder.LITTLE_ENDIAN)
val shorts = buffer.asShortBuffer()
var i = 0
while (i < shorts.remaining() && count < maxSamples) {
out[count++] = shorts.get(i) / 32768f
i++
}
}
codec.releaseOutputBuffer(outIndex, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outputDone = true
}
}
return if (count > 0) out.copyOf(count) to sampleRate else null
} finally {
runCatching { codec?.stop() }
runCatching { codec?.release() }
extractor.release()
}
}
}
@@ -0,0 +1,20 @@
package com.heretek.dorado_hd.analysis
import android.content.Context
import com.heretek.dorado_hd.data.model.Track
/**
* DSP [FeatureAnalyzer]: decodes a PCM window and refines the metadata prior.
* Falls back to the prior when the file cannot be decoded (or is unavailable).
*/
class PcmFeatureAnalyzer(private val context: Context) : FeatureAnalyzer {
override suspend fun analyze(track: Track): AudioFeatures {
val decoded = PcmDecoder.decodeWindow(context, track.uri)
return if (decoded != null) {
AudioFeatureExtractor.fromPcm(track, decoded.first, decoded.second)
} else {
AudioFeatureExtractor.extract(track)
}
}
}
@@ -324,3 +324,30 @@ interface GameScoreDao {
@Insert
suspend fun insert(score: GameScoreEntity): Long
}
@Dao
interface TrackFeatureDao {
@Query("SELECT * FROM track_features")
suspend fun all(): List<TrackFeatureEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(feature: TrackFeatureEntity)
@Query("SELECT COUNT(*) FROM track_features")
suspend fun count(): Int
}
@Dao
interface ScrobbleDao {
@Insert
suspend fun insert(row: ScrobbleEntity): Long
@Query("SELECT * FROM scrobble_queue ORDER BY id ASC LIMIT :limit")
suspend fun pending(limit: Int): List<ScrobbleEntity>
@Query("DELETE FROM scrobble_queue WHERE id IN (:ids)")
suspend fun delete(ids: List<Long>)
@Query("SELECT COUNT(*) FROM scrobble_queue")
suspend fun count(): Int
}
@@ -21,8 +21,10 @@ import androidx.room.RoomDatabase
PodcastFeedEntity::class,
PodcastEpisodeEntity::class,
GameScoreEntity::class,
TrackFeatureEntity::class,
ScrobbleEntity::class,
],
version = 2,
version = 4,
exportSchema = false,
)
abstract class DoradoDatabase : RoomDatabase() {
@@ -38,6 +40,8 @@ abstract class DoradoDatabase : RoomDatabase() {
abstract fun radioDao(): RadioDao
abstract fun podcastDao(): PodcastDao
abstract fun gameScoreDao(): GameScoreDao
abstract fun trackFeatureDao(): TrackFeatureDao
abstract fun scrobbleDao(): ScrobbleDao
companion object {
@Volatile
@@ -46,7 +50,7 @@ abstract class DoradoDatabase : RoomDatabase() {
fun get(context: Context): DoradoDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(context, DoradoDatabase::class.java, "dorado_hd.db")
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
.also { instance = it }
}
@@ -164,3 +164,27 @@ data class GameScoreEntity(
val meta: String?,
val playedAt: Long,
)
/** Cached M9.3 audio-feature vector per track (metadata prior or DSP-refined). */
@Entity(tableName = "track_features")
data class TrackFeatureEntity(
@PrimaryKey val mediaId: Long,
val bpm: Double,
val energy: Double,
val valence: Double,
val acousticness: Double,
val danceability: Double,
val spectralCentroid: Double,
val analyzedAt: Long,
)
/** Queued Last.fm scrobble (M9.4), persisted until the sink accepts it. */
@Entity(tableName = "scrobble_queue")
data class ScrobbleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val artist: String,
val title: String,
val album: String,
val durationSeconds: Int,
val timestampSec: Long,
)
@@ -97,3 +97,42 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_game_scores_playedAt` ON `game_scores` (`playedAt`)")
}
}
/**
* Room v2 → v3: M9.3 cached audio-feature vectors (on-device similarity and
* dynamic mixes). Existing data is untouched; analyzed tracks backfill lazily.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS `track_features` (
`mediaId` INTEGER PRIMARY KEY NOT NULL,
`bpm` REAL NOT NULL,
`energy` REAL NOT NULL,
`valence` REAL NOT NULL,
`acousticness` REAL NOT NULL,
`danceability` REAL NOT NULL,
`spectralCentroid` REAL NOT NULL,
`analyzedAt` INTEGER NOT NULL
)""",
)
}
}
/**
* Room v3 → v4: M9.4 offline Last.fm scrobble queue. Existing data untouched.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS `scrobble_queue` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`artist` TEXT NOT NULL,
`title` TEXT NOT NULL,
`album` TEXT NOT NULL,
`durationSeconds` INTEGER NOT NULL,
`timestampSec` INTEGER NOT NULL
)""",
)
}
}
@@ -12,7 +12,14 @@ enum class Rating(val value: Int) {
enum class RepeatMode { OFF, ALL, ONE }
enum class PinKind { TRACK, ALBUM, ARTIST, PLAYLIST, PICTURE, RADIO }
enum class PinKind {
TRACK, ALBUM, ARTIST, PLAYLIST, PICTURE, RADIO, GENRE, VIDEO, APP, PODCAST, EPISODE;
companion object {
/** Deterministic pin refId for surfaces keyed by a string (genre, app). */
fun stableId(key: String): Long = key.lowercase().hashCode().toLong()
}
}
data class Track(
val mediaId: Long,
@@ -0,0 +1,164 @@
package com.heretek.dorado_hd.data.repo
import android.content.ContentUris
import android.content.Context
import android.provider.MediaStore
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.sync.DeviceSnapshot
import com.heretek.dorado_hd.sync.DeviceTransport
import com.heretek.dorado_hd.sync.SimulatedDeviceTransport
import com.heretek.dorado_hd.sync.SyncCategoryType
import com.heretek.dorado_hd.sync.SyncInput
import com.heretek.dorado_hd.sync.SyncPhoto
import com.heretek.dorado_hd.sync.SyncPodcastEpisode
import com.heretek.dorado_hd.sync.SyncRuleSettings
import com.heretek.dorado_hd.sync.SyncTrack
import com.heretek.dorado_hd.sync.SyncVideo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
/** An item copied back from the linked device into the phone collection queue. */
data class PendingImport(
val title: String,
val category: SyncCategoryType,
val devicePath: String,
val sizeBytes: Long,
)
/**
* Phone side of Device Link (M8.3). Owns the sync target transport and the
* reverse-sync queue. The transport is simulated for now; M8.2b swaps in a LAN
* implementation with no change to the Device view or this repository's API.
*/
class DeviceLinkRepository(private val context: Context) {
val transport: DeviceTransport =
SimulatedDeviceTransport("DORADO-HD", "Zune HD (linked)", 32L * 1024 * 1024 * 1024)
private val _pendingImports = MutableStateFlow<List<PendingImport>>(emptyList())
val pendingImports: StateFlow<List<PendingImport>> = _pendingImports.asStateFlow()
fun snapshot(): DeviceSnapshot =
DeviceSnapshot(transport.totalCapacityBytes, transport.systemBytes, transport.contents())
fun copyBack(item: com.heretek.dorado_hd.sync.DeviceContentItem) {
_pendingImports.update {
it + PendingImport(item.title, item.category, item.devicePath, item.sizeBytes)
}
}
fun clearPending() {
_pendingImports.value = emptyList()
}
/** Snapshot of the phone library as a [SyncInput] for the sync engine. */
suspend fun buildInput(
library: LibraryRepository,
quickplay: QuickplayRepository,
podcasts: PodcastRepository,
): SyncInput = withContext(Dispatchers.IO) {
val ratings = quickplay.ratings()
val tracks = library.tracks().first().map { t ->
SyncTrack(
id = t.mediaId.toString(),
title = t.title,
artistName = t.artist,
albumTitle = t.album,
durationSeconds = t.durationMs / 1000,
filePath = t.uri.toString(),
rating = Rating.from(ratings[t.mediaId] ?: 0),
)
}
val episodes = podcasts.episodesFlat().first().map { e ->
SyncPodcastEpisode(
id = e.episodeId.toString(),
title = e.title,
seriesTitle = e.feedTitle,
publishedAtUtc = e.pubAt,
durationSeconds = e.durationMs / 1000,
audioUrl = e.enclosureUrl,
isPlayed = e.played,
)
}
SyncInput(tracks, queryVideos(), queryPhotos(), episodes)
}
private fun queryVideos(): List<SyncVideo> {
val list = mutableListOf<SyncVideo>()
val cursor = context.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.TITLE,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.DATE_ADDED,
),
null, null,
"${MediaStore.Video.Media.DATE_ADDED} DESC LIMIT 500",
)
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val titleCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.TITLE)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
val addedCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
while (c.moveToNext()) {
val id = c.getLong(idCol)
list += SyncVideo(
id = id.toString(),
title = c.getString(titleCol) ?: "untitled",
filePath = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id).toString(),
sizeBytes = c.getLong(sizeCol),
addedAtUtc = c.getLong(addedCol) * 1000,
)
}
}
return list
}
private fun queryPhotos(): List<SyncPhoto> {
val list = mutableListOf<SyncPhoto>()
val cursor = context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATE_ADDED,
),
null, null,
"${MediaStore.Images.Media.DATE_ADDED} DESC LIMIT 500",
)
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val bucketCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val addedCol = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
while (c.moveToNext()) {
val id = c.getLong(idCol)
list += SyncPhoto(
id = id.toString(),
title = c.getString(nameCol) ?: "untitled",
filePath = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id).toString(),
folderPath = c.getString(bucketCol) ?: "",
sizeBytes = c.getLong(sizeCol),
addedAtUtc = c.getLong(addedCol) * 1000,
)
}
}
return list
}
}
fun DoradoSettings.toSyncRuleSettings(): SyncRuleSettings = SyncRuleSettings(
musicSyncRule = musicSyncRule,
podcastSyncRule = podcastSyncRule,
videoSyncRule = videoSyncRule,
picturesSyncRule = picturesSyncRule,
)
@@ -0,0 +1,37 @@
package com.heretek.dorado_hd.data.repo
import com.heretek.dorado_hd.analysis.AudioFeatures
import com.heretek.dorado_hd.analysis.FeatureStore
import com.heretek.dorado_hd.data.db.TrackFeatureDao
import com.heretek.dorado_hd.data.db.TrackFeatureEntity
/** Room-backed [FeatureStore] for the M9.3 audio-feature cache. */
class RoomFeatureStore(private val dao: TrackFeatureDao) : FeatureStore {
override suspend fun load(): List<AudioFeatures> = dao.all().map { it.toModel() }
override suspend fun save(features: AudioFeatures) {
dao.upsert(
TrackFeatureEntity(
mediaId = features.trackId,
bpm = features.bpm,
energy = features.energy,
valence = features.valence,
acousticness = features.acousticness,
danceability = features.danceability,
spectralCentroid = features.spectralCentroid,
analyzedAt = System.currentTimeMillis(),
),
)
}
private fun TrackFeatureEntity.toModel() = AudioFeatures(
trackId = mediaId,
bpm = bpm,
energy = energy,
valence = valence,
acousticness = acousticness,
danceability = danceability,
spectralCentroid = spectralCentroid,
)
}
@@ -0,0 +1,35 @@
package com.heretek.dorado_hd.data.repo
import com.heretek.dorado_hd.data.db.ScrobbleDao
import com.heretek.dorado_hd.data.db.ScrobbleEntity
import com.heretek.dorado_hd.scrobble.PendingScrobble
import com.heretek.dorado_hd.scrobble.Scrobble
import com.heretek.dorado_hd.scrobble.ScrobbleStore
/** Room-backed offline scrobble queue. */
class RoomScrobbleStore(private val dao: ScrobbleDao) : ScrobbleStore {
override suspend fun enqueue(scrobble: Scrobble) {
dao.insert(
ScrobbleEntity(
artist = scrobble.artist,
title = scrobble.title,
album = scrobble.album,
durationSeconds = scrobble.durationSeconds,
timestampSec = scrobble.timestampSec,
),
)
}
override suspend fun pending(limit: Int): List<PendingScrobble> =
dao.pending(limit).map {
PendingScrobble(
id = it.id,
scrobble = Scrobble(it.artist, it.title, it.album, it.durationSeconds, it.timestampSec),
)
}
override suspend fun delete(ids: List<Long>) = dao.delete(ids)
override suspend fun count(): Int = dao.count()
}
@@ -22,6 +22,16 @@ data class DoradoSettings(
val watchMediaStore: Boolean = false,
/** Persistent URI of the user-imported SAF tree (or empty). */
val importedTreeUri: String = "",
/** Device Link sync rules — mirrors the desktop AppSettings rule labels. */
val musicSyncRule: String = "All Music (Automatic Sync)",
val podcastSyncRule: String = "3 Newest Episodes",
val videoSyncRule: String = "All Videos & Pictures",
val picturesSyncRule: String = "Newest 25 Items",
/** M9.4 — offline Last.fm scrobbling (opt-in; credentials user-supplied). */
val scrobbleEnabled: Boolean = false,
val lastFmApiKey: String = "",
val lastFmApiSecret: String = "",
val lastFmSessionKey: String = "",
)
class SettingsRepository(private val context: Context) {
@@ -33,6 +43,14 @@ class SettingsRepository(private val context: Context) {
val LIBRARY_SCANNED = booleanPreferencesKey("library_scanned")
val WATCH_MEDIA_STORE = booleanPreferencesKey("watch_media_store")
val IMPORTED_TREE_URI = stringPreferencesKey("imported_tree_uri")
val MUSIC_SYNC_RULE = stringPreferencesKey("music_sync_rule")
val PODCAST_SYNC_RULE = stringPreferencesKey("podcast_sync_rule")
val VIDEO_SYNC_RULE = stringPreferencesKey("video_sync_rule")
val PICTURES_SYNC_RULE = stringPreferencesKey("pictures_sync_rule")
val SCROBBLE_ENABLED = booleanPreferencesKey("scrobble_enabled")
val LASTFM_API_KEY = stringPreferencesKey("lastfm_api_key")
val LASTFM_API_SECRET = stringPreferencesKey("lastfm_api_secret")
val LASTFM_SESSION_KEY = stringPreferencesKey("lastfm_session_key")
}
val settings: Flow<DoradoSettings> = context.dataStore.data.map { p ->
@@ -44,6 +62,14 @@ class SettingsRepository(private val context: Context) {
libraryScanned = p[Keys.LIBRARY_SCANNED] ?: false,
watchMediaStore = p[Keys.WATCH_MEDIA_STORE] ?: false,
importedTreeUri = p[Keys.IMPORTED_TREE_URI] ?: "",
musicSyncRule = p[Keys.MUSIC_SYNC_RULE] ?: "All Music (Automatic Sync)",
podcastSyncRule = p[Keys.PODCAST_SYNC_RULE] ?: "3 Newest Episodes",
videoSyncRule = p[Keys.VIDEO_SYNC_RULE] ?: "All Videos & Pictures",
picturesSyncRule = p[Keys.PICTURES_SYNC_RULE] ?: "Newest 25 Items",
scrobbleEnabled = p[Keys.SCROBBLE_ENABLED] ?: false,
lastFmApiKey = p[Keys.LASTFM_API_KEY] ?: "",
lastFmApiSecret = p[Keys.LASTFM_API_SECRET] ?: "",
lastFmSessionKey = p[Keys.LASTFM_SESSION_KEY] ?: "",
)
}
@@ -54,4 +80,14 @@ class SettingsRepository(private val context: Context) {
suspend fun setLibraryScanned(scanned: Boolean) = context.dataStore.edit { it[Keys.LIBRARY_SCANNED] = scanned }
suspend fun setWatchMediaStore(enabled: Boolean) = context.dataStore.edit { it[Keys.WATCH_MEDIA_STORE] = enabled }
suspend fun setImportedTreeUri(uri: String) = context.dataStore.edit { it[Keys.IMPORTED_TREE_URI] = uri }
suspend fun setMusicSyncRule(rule: String) = context.dataStore.edit { it[Keys.MUSIC_SYNC_RULE] = rule }
suspend fun setPodcastSyncRule(rule: String) = context.dataStore.edit { it[Keys.PODCAST_SYNC_RULE] = rule }
suspend fun setMediaSyncRule(rule: String) = context.dataStore.edit {
it[Keys.VIDEO_SYNC_RULE] = rule
it[Keys.PICTURES_SYNC_RULE] = rule
}
suspend fun setScrobbleEnabled(enabled: Boolean) = context.dataStore.edit { it[Keys.SCROBBLE_ENABLED] = enabled }
suspend fun setLastFmApiKey(value: String) = context.dataStore.edit { it[Keys.LASTFM_API_KEY] = value }
suspend fun setLastFmApiSecret(value: String) = context.dataStore.edit { it[Keys.LASTFM_API_SECRET] = value }
suspend fun setLastFmSessionKey(value: String) = context.dataStore.edit { it[Keys.LASTFM_SESSION_KEY] = value }
}
@@ -13,6 +13,7 @@ import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.data.repo.QuickplayRepository
import com.heretek.dorado_hd.data.repo.LibraryRepository
import com.heretek.dorado_hd.scrobble.ScrobbleService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -33,9 +34,11 @@ class PlaybackController(
private val context: Context,
private val library: LibraryRepository,
private val quickplay: QuickplayRepository,
private val scrobble: ScrobbleService? = null,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var controller: MediaController? = null
private var candidateReachedThreshold = false
private val _nowPlaying = MutableStateFlow<Track?>(null)
val nowPlaying: StateFlow<Track?> = _nowPlaying.asStateFlow()
@@ -86,11 +89,20 @@ class PlaybackController(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val previous = _nowPlaying.value
val previousReached = candidateReachedThreshold
val index = controller?.currentMediaItemIndex ?: return
_currentIndex.value = index
val track = _queue.value.getOrNull(index)
_nowPlaying.value = track
_durationMs.value = controller?.duration ?: 0L
candidateReachedThreshold = false
if (scrobble != null && previous != null && previousReached && previous.mediaId != track?.mediaId) {
val finished = previous
scope.launch {
scrobble.record(finished.artist, finished.title, finished.album, (finished.durationMs / 1000).toInt())
}
}
if (track != null) {
scope.launch {
_currentRating.value = quickplay.ratingOf(track.mediaId).first()
@@ -111,7 +123,11 @@ class PlaybackController(
while (true) {
val player = controller
if (player != null && _isPlaying.value) {
_positionMs.value = player.currentPosition.coerceAtLeast(0)
val position = player.currentPosition.coerceAtLeast(0)
_positionMs.value = position
if (reachedScrobbleThreshold(position, _durationMs.value)) {
candidateReachedThreshold = true
}
}
delay(500)
}
@@ -238,5 +254,12 @@ class PlaybackController(
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/**
* Last.fm scrobble rule: at least half the track, or 4 minutes —
* whichever comes first.
*/
fun reachedScrobbleThreshold(positionMs: Long, durationMs: Long): Boolean =
positionMs >= 240_000L || (durationMs > 0 && positionMs >= durationMs / 2)
}
}
@@ -0,0 +1,59 @@
package com.heretek.dorado_hd.net
import com.heretek.dorado_hd.scrobble.LastFmSignature
import com.heretek.dorado_hd.scrobble.Scrobble
import com.heretek.dorado_hd.scrobble.ScrobbleSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Last.fm `track.scrobble` over the documented signed-params API. Credentials
* are supplied lazily from settings; a missing credential fails the send so the
* scrobble stays queued. Network glue — the signing and queue are unit-tested.
*/
class LastFmClient(
private val apiKey: () -> String,
private val secret: () -> String,
private val sessionKey: () -> String,
) : ScrobbleSink {
override suspend fun scrobble(scrobble: Scrobble): Boolean = withContext(Dispatchers.IO) {
val key = apiKey()
val sec = secret()
val sk = sessionKey()
if (key.isBlank() || sec.isBlank() || sk.isBlank()) return@withContext false
val params = linkedMapOf(
"method" to "track.scrobble",
"api_key" to key,
"sk" to sk,
"artist" to scrobble.artist,
"track" to scrobble.title,
"timestamp" to scrobble.timestampSec.toString(),
"format" to "json",
)
if (scrobble.album.isNotBlank()) params["album"] = scrobble.album
if (scrobble.durationSeconds > 0) params["duration"] = scrobble.durationSeconds.toString()
params["api_sig"] = LastFmSignature.apiSignature(params, sec)
runCatching {
val connection = URL(LastFmSignature.API_ROOT).openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = 8_000
connection.readTimeout = 15_000
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
val body = params.entries.joinToString("&") { "${encode(it.key)}=${encode(it.value)}" }
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.use { it.readText() } ?: ""
code in 200..299 && !text.contains("\"error\"")
}.getOrDefault(false)
}
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
@@ -0,0 +1,67 @@
package com.heretek.dorado_hd.net
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** Lyrics for a track: [plain] and/or LRC-timestamped [synced] text. */
data class Lyrics(val plain: String?, val synced: String?)
/**
* Parses LRCLIB `/api/get` JSON. Pure and unit-tested; the HTTP call is the
* only untested part. LRCLIB is the same source the sibling desktop uses.
*/
object LrcLibParser {
fun parse(json: String): Lyrics? {
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
val plain = obj.optString("plainLyrics").takeIf { it.isNotBlank() && it != "null" }
val synced = obj.optString("syncedLyrics").takeIf { it.isNotBlank() && it != "null" }
if (plain == null && synced == null) return null
return Lyrics(plain, synced)
}
/** Strips LRC `[mm:ss.xx]` timestamps, leaving readable lines. */
fun plainFromSynced(synced: String): String =
synced.lineSequence()
.map { line -> line.replace(Regex("\\[[0-9:.]+\\]"), "").trim() }
.filter { it.isNotEmpty() }
.joinToString("\n")
}
/** LRCLIB lyrics lookup (post-device extension, canon §10). */
class LrcLibService {
suspend fun lyricsFor(
artist: String,
title: String,
album: String,
durationSec: Int,
): Lyrics? = withContext(Dispatchers.IO) {
if (artist.isBlank() || title.isBlank()) return@withContext null
runCatching {
val base = "$API_ROOT?artist_name=${encode(artist)}&track_name=${encode(title)}"
val withAlbum = if (album.isNotBlank()) "$base&album_name=${encode(album)}" else base
val url = if (durationSec > 0) "$withAlbum&duration=$durationSec" else withAlbum
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 8_000
connection.readTimeout = 15_000
connection.setRequestProperty("User-Agent", "DoradoHD/0.1")
if (connection.responseCode !in 200..299) {
null
} else {
LrcLibParser.parse(connection.inputStream.bufferedReader().use { it.readText() })
}
}.getOrNull()
}
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
private companion object {
const val API_ROOT = "https://lrclib.net/api/get"
}
}
@@ -0,0 +1,28 @@
package com.heretek.dorado_hd.scrobble
import java.security.MessageDigest
/**
* Last.fm API request signing (the documented `api_sig` scheme): drop
* `format`/`callback`/`api_sig`, sort the remaining parameters by name,
* concatenate `name+value`, append the shared secret, then MD5.
*
* Pure and unit-tested — the network client is the only untested part.
*/
object LastFmSignature {
const val API_ROOT = "https://ws.audioscrobbler.com/2.0/"
fun apiSignature(params: Map<String, String>, secret: String): String {
val raw = params.entries
.filter { it.key != "format" && it.key != "callback" && it.key != "api_sig" }
.sortedBy { it.key }
.joinToString(separator = "") { it.key + it.value } + secret
return md5Hex(raw)
}
fun md5Hex(input: String): String {
val digest = MessageDigest.getInstance("MD5").digest(input.toByteArray(Charsets.UTF_8))
return digest.joinToString(separator = "") { "%02x".format(it) }
}
}
@@ -0,0 +1,18 @@
package com.heretek.dorado_hd.scrobble
/** One scrobble payload (Last.fm `track.scrobble` fields). */
data class Scrobble(
val artist: String,
val title: String,
val album: String = "",
val durationSeconds: Int = 0,
val timestampSec: Long,
)
/** A queued scrobble with its store id. */
data class PendingScrobble(val id: Long, val scrobble: Scrobble)
/** Sends a single scrobble; returns false when it should stay queued. */
fun interface ScrobbleSink {
suspend fun scrobble(scrobble: Scrobble): Boolean
}
@@ -0,0 +1,40 @@
package com.heretek.dorado_hd.scrobble
/**
* Offline-first scrobbling: records plays into a durable queue and flushes
* them in order when a sink is reachable. Sending stops at the first failure so
* ordering is preserved. Pure JVM — the [ScrobbleSink] and [ScrobbleStore] are
* injected, so the queue semantics are unit-tested.
*/
class ScrobbleService(
private val store: ScrobbleStore,
private val sink: ScrobbleSink,
private val enabled: suspend () -> Boolean,
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
) {
/** Queues a play if scrobbling is on and the fields are usable. */
suspend fun record(artist: String, title: String, album: String, durationSeconds: Int): Boolean {
if (artist.isBlank() || title.isBlank()) return false
if (!enabled()) return false
store.enqueue(Scrobble(artist, title, album, durationSeconds, nowSec()))
return true
}
/** Sends up to [limit] queued scrobbles; returns how many were accepted. */
suspend fun flush(limit: Int = 50): Int {
if (!enabled()) return 0
var sent = 0
val done = mutableListOf<Long>()
for (pending in store.pending(limit)) {
val ok = runCatching { sink.scrobble(pending.scrobble) }.getOrDefault(false)
if (!ok) break
done += pending.id
sent++
}
if (done.isNotEmpty()) store.delete(done)
return sent
}
suspend fun pendingCount(): Int = store.count()
}
@@ -0,0 +1,9 @@
package com.heretek.dorado_hd.scrobble
/** Persistence boundary for the offline scrobble queue (Room-backed in app). */
interface ScrobbleStore {
suspend fun enqueue(scrobble: Scrobble)
suspend fun pending(limit: Int): List<PendingScrobble>
suspend fun delete(ids: List<Long>)
suspend fun count(): Int
}
@@ -0,0 +1,23 @@
package com.heretek.dorado_hd.sync
/**
* Transport boundary to a device's content store, mirroring the desktop
* `IDeviceTransport` (MTPZ semantics). The simulated implementation powers the
* full Device-view flow today; a real MTP or LAN transport implements the same
* contract later with zero UI changes.
*/
interface DeviceTransport {
val deviceSerialNumber: String
val deviceName: String
val totalCapacityBytes: Long
val systemBytes: Long
fun contents(): List<DeviceContentItem>
fun tryGetItem(entityId: String): DeviceContentItem?
fun copyToDevice(item: TransferItem)
fun removeFromDevice(item: DeviceContentItem)
val usedBytes: Long
val freeBytes: Long
}
@@ -0,0 +1,69 @@
package com.heretek.dorado_hd.sync
import java.util.UUID
import kotlin.math.max
/**
* In-memory device content store with plausible defaults (32 GB Zune HD, some
* pre-synced content). 1:1 with the desktop `SimulatedDeviceTransport` so the
* phone-side Device view and the dry-run manifest behave identically before a
* real transport exists.
*/
class SimulatedDeviceTransport(
override val deviceSerialNumber: String,
name: String,
capacityBytes: Long,
) : DeviceTransport {
override val deviceName: String = name.ifBlank { DEFAULT_NAME }
override val totalCapacityBytes: Long = if (capacityBytes <= 0) DEFAULT_CAPACITY else capacityBytes
override val systemBytes: Long = (totalCapacityBytes * 0.035).toLong()
private val items = LinkedHashMap<String, DeviceContentItem>()
init {
if (totalCapacityBytes >= FOUR_GB) seedDefaultContent()
}
override fun contents(): List<DeviceContentItem> =
items.values.sortedWith(compareBy({ it.category.ordinal }, { it.title }))
override fun tryGetItem(entityId: String): DeviceContentItem? = items[entityId]
override fun copyToDevice(item: TransferItem) {
items[item.entityId] = DeviceContentItem(
category = item.category,
entityId = item.entityId,
title = item.title,
devicePath = "\\Content\\${item.category}\\${item.entityId}",
sizeBytes = item.sizeBytes,
)
}
override fun removeFromDevice(item: DeviceContentItem) {
items.remove(item.entityId)
}
override val usedBytes: Long
get() = systemBytes + items.values.sumOf { it.sizeBytes }
override val freeBytes: Long
get() = max(0L, totalCapacityBytes - usedBytes)
/** Two stale music items plus a podcast, a video and a photo. */
private fun seedDefaultContent() {
listOf(
DeviceContentItem(SyncCategoryType.MUSIC, UUID.randomUUID().toString(), "Ice Ice Baby", "\\Content\\Music\\legacy1", 4_800_000),
DeviceContentItem(SyncCategoryType.MUSIC, UUID.randomUUID().toString(), "Macarena", "\\Content\\Music\\legacy2", 4_100_000),
DeviceContentItem(SyncCategoryType.PODCASTS, UUID.randomUUID().toString(), "KEXP — Music That Matters 512", "\\Content\\Podcasts\\kexp512", 58_000_000),
DeviceContentItem(SyncCategoryType.VIDEOS, UUID.randomUUID().toString(), "Zune Marketing Reel 2006", "\\Content\\Videos\\reel", 412_000_000),
DeviceContentItem(SyncCategoryType.PICTURES, UUID.randomUUID().toString(), "Wallpaper", "\\Content\\Pictures\\wallpaper", 2_400_000),
).forEach { items[it.entityId] = it }
}
companion object {
private const val DEFAULT_NAME = "Zune HD 32GB"
private const val DEFAULT_CAPACITY = 32L * 1024 * 1024 * 1024
private const val FOUR_GB = 4L * 1024 * 1024 * 1024
}
}
@@ -0,0 +1,248 @@
package com.heretek.dorado_hd.sync
import com.heretek.dorado_hd.data.model.Rating
/**
* 1:1 Kotlin port of the sibling Dorado desktop `SyncEngine` (Phase 9).
* Turns Settings rules into a planned transfer set (add / remove / keep)
* against the device's current contents. Guest sessions are add-only.
*
* Pure JVM: no Android dependencies, so it is exercised by golden tests
* against fixtures generated from the desktop engine.
*/
object SyncEngine {
/** Zune's ~128 kbps audio estimate when the exact size is unknown. */
const val AUDIO_BYTES_PER_SECOND: Long = 16_000L
/**
* Builds the default group for a device from the persisted rule strings,
* mirroring `SyncEngine.BuildDefaultGroup` exactly — including the quirk
* that the picture rule inherits the video rule's mode.
*/
fun buildDefaultGroup(
deviceSerialNumber: String,
settings: SyncRuleSettings,
isGuestSession: Boolean = false,
): SyncGroup {
val categories = mutableListOf<SyncCategoryRule>()
// Music rule
val musicMode = when {
settings.musicSyncRule.contains("Manual", ignoreCase = true) -> SyncMode.MANUAL
settings.musicSyncRule.contains("Selected", ignoreCase = true) -> SyncMode.SELECTED_ITEMS
else -> SyncMode.AUTOMATIC
}
categories += SyncCategoryRule(
category = SyncCategoryType.MUSIC,
mode = musicMode,
ruleText = settings.musicSyncRule,
preferFavorites = musicMode == SyncMode.SELECTED_ITEMS,
)
// Podcast rule
var podcastMode = SyncMode.SELECTED_ITEMS
var podcastCount = parseTrailingCount(settings.podcastSyncRule)
if (settings.podcastSyncRule.contains("Manual", ignoreCase = true)) {
podcastMode = SyncMode.MANUAL
} else if (settings.podcastSyncRule.startsWith("All", ignoreCase = true)) {
podcastMode = SyncMode.AUTOMATIC
podcastCount = null
}
categories += SyncCategoryRule(
category = SyncCategoryType.PODCASTS,
mode = podcastMode,
ruleText = settings.podcastSyncRule,
newestCount = if (podcastMode == SyncMode.SELECTED_ITEMS) podcastCount else null,
)
// Media rules: pictures share the video rule's mode (desktop parity).
val mediaMode = when {
settings.videoSyncRule.contains("Manual", ignoreCase = true) ||
settings.videoSyncRule.contains("Nothing", ignoreCase = true) -> SyncMode.MANUAL
settings.videoSyncRule.startsWith("All", ignoreCase = true) -> SyncMode.AUTOMATIC
else -> SyncMode.SELECTED_ITEMS
}
categories += SyncCategoryRule(
category = SyncCategoryType.VIDEOS,
mode = mediaMode,
ruleText = settings.videoSyncRule,
newestCount = if (mediaMode == SyncMode.SELECTED_ITEMS) parseTrailingCount(settings.videoSyncRule) else null,
)
categories += SyncCategoryRule(
category = SyncCategoryType.PICTURES,
mode = mediaMode,
ruleText = settings.picturesSyncRule,
newestCount = if (mediaMode == SyncMode.SELECTED_ITEMS) parseTrailingCount(settings.picturesSyncRule) else null,
)
val name = if (isGuestSession) {
"Guest Session — $deviceSerialNumber"
} else {
"Sync Group — $deviceSerialNumber"
}
return SyncGroup(deviceSerialNumber, name, isGuestSession, categories)
}
/**
* Computes the planned transfer set. Removals are computed first so their
* freed space can back later adds; adds and keeps follow in rule order,
* truncated to projected free space.
*
* Remaining desktop parity notes (goldens lock these in): KEEP items carry
* only title + size, and the picture rule inherits the video rule's mode.
*/
fun buildPlan(group: SyncGroup, input: SyncInput, device: DeviceSnapshot): SyncPlan {
val desired = LinkedHashMap<String, TransferItem>()
for (rule in group.categories) {
for (item in selectDesired(rule, input)) {
desired[item.entityId] = item
}
}
val deviceContents = device.contents.associateBy { it.entityId }
val managedCategories = group.categories
.filter { it.mode != SyncMode.MANUAL }
.map { it.category }
.toSet()
val items = mutableListOf<TransferItem>()
if (!group.isGuestSession) {
for (content in device.contents) {
if (content.entityId !in desired && content.category in managedCategories) {
items += TransferItem(
action = TransferAction.REMOVE,
category = content.category,
entityId = content.entityId,
title = content.title,
sizeBytes = content.sizeBytes,
detail = "No longer in sync group",
)
}
}
}
val removeBytes = items.filter { it.action == TransferAction.REMOVE }.sumOf { it.sizeBytes }
val projectedUsed = device.contents.sumOf { it.sizeBytes } - removeBytes
var freeBytes = device.totalCapacityBytes - device.systemBytes - projectedUsed
for ((entityId, item) in desired) {
if (deviceContents.containsKey(entityId)) {
// Desktop parity: KEEP items carry only title + size (no detail/path).
items += TransferItem(
action = TransferAction.KEEP,
category = item.category,
entityId = entityId,
title = item.title,
sizeBytes = item.sizeBytes,
)
} else if (item.sizeBytes <= freeBytes) {
items += item
freeBytes -= item.sizeBytes
}
}
return SyncPlan(group.deviceSerialNumber, group.isGuestSession, items)
}
/**
* Applies a plan to the transport: removals first so their freed space
* backs the adds that follow (mirrors the desktop `ApplyPlanAsync`). KEEP
* items are no-ops. [onProgress] receives (index+1)/count per item, then a
* final 1.0.
*/
fun applyPlan(plan: SyncPlan, transport: DeviceTransport, onProgress: ((Double) -> Unit)? = null) {
val ordered = plan.items.filter { it.action == TransferAction.REMOVE } +
plan.items.filter { it.action == TransferAction.ADD }
ordered.forEachIndexed { index, item ->
when (item.action) {
TransferAction.ADD -> transport.copyToDevice(item)
TransferAction.REMOVE -> transport.tryGetItem(item.entityId)?.let { transport.removeFromDevice(it) }
TransferAction.KEEP -> Unit
}
onProgress?.invoke((index + 1).toDouble() / ordered.size)
}
onProgress?.invoke(1.0)
}
private fun selectDesired(rule: SyncCategoryRule, input: SyncInput): List<TransferItem> {
if (rule.mode == SyncMode.MANUAL) return emptyList()
return when (rule.category) {
SyncCategoryType.MUSIC -> {
val candidates = if (rule.preferFavorites) {
input.tracks.filter { it.rating == Rating.HEART }
} else {
input.tracks
}
limitIfConfigured(candidates.sortedBy { it.title }, rule).map { track ->
TransferItem(
action = TransferAction.ADD,
category = SyncCategoryType.MUSIC,
entityId = track.id,
title = track.title,
sourcePath = track.filePath,
sizeBytes = estimateAudioBytes(track.durationSeconds),
detail = "${track.artistName} — ${track.albumTitle}",
)
}
}
SyncCategoryType.PODCASTS -> {
val candidates = input.podcastEpisodes.filter { !it.isPlayed }
limitIfConfigured(candidates.sortedByDescending { it.publishedAtUtc }, rule).map { episode ->
TransferItem(
action = TransferAction.ADD,
category = SyncCategoryType.PODCASTS,
entityId = episode.id,
title = episode.title,
sourcePath = episode.audioUrl,
sizeBytes = estimateAudioBytes(episode.durationSeconds),
detail = episode.seriesTitle,
)
}
}
SyncCategoryType.VIDEOS -> {
limitIfConfigured(input.videos.sortedByDescending { it.addedAtUtc }, rule).map { video ->
TransferItem(
action = TransferAction.ADD,
category = SyncCategoryType.VIDEOS,
entityId = video.id,
title = video.title,
sourcePath = video.filePath,
sizeBytes = video.sizeBytes,
)
}
}
SyncCategoryType.PICTURES -> {
limitIfConfigured(input.photos.sortedByDescending { it.addedAtUtc }, rule).map { photo ->
TransferItem(
action = TransferAction.ADD,
category = SyncCategoryType.PICTURES,
entityId = photo.id,
title = photo.title,
sourcePath = photo.filePath,
sizeBytes = photo.sizeBytes,
detail = photo.folderPath,
)
}
}
}
}
private fun <T> limitIfConfigured(ordered: List<T>, rule: SyncCategoryRule): List<T> =
rule.newestCount?.let { ordered.take(it) } ?: ordered
private fun estimateAudioBytes(durationSeconds: Long): Long =
if (durationSeconds <= 0) 0 else durationSeconds * AUDIO_BYTES_PER_SECOND
/**
* Mirrors the desktop: the first run of digits anywhere in the label.
* "3 Newest Episodes" → 3, "Newest 25 Items" → 25.
*/
private fun parseTrailingCount(ruleText: String): Int? =
Regex("\\d+").find(ruleText)?.value?.toIntOrNull()
}
@@ -0,0 +1,139 @@
package com.heretek.dorado_hd.sync
import com.heretek.dorado_hd.data.model.Rating
/**
* Kotlin mirror of the sibling Dorado desktop sync contract
* (`Dorado.Domain/Models/SyncModels.cs`). Field names and semantics are kept
* 1:1 so the phone (device) and desktop (source) compute identical plans;
* M8 adds the transport + protocol on top of this pure-JVM core.
*
* entity ids are opaque strings here; the desktop uses `Guid`, the phone will
* use its MediaStore ids rendered canonically.
*/
enum class SyncMode { AUTOMATIC, SELECTED_ITEMS, MANUAL }
enum class SyncCategoryType { MUSIC, PODCASTS, VIDEOS, PICTURES }
enum class TransferAction { ADD, REMOVE, KEEP }
/** One persisted sync rule per category (ZMDB SchemaSyncGroup parity). */
data class SyncCategoryRule(
val category: SyncCategoryType,
val mode: SyncMode,
val ruleText: String = "",
val newestCount: Int? = null,
val preferFavorites: Boolean = false,
)
/** A persisted sync group per device; guest sessions are add-only. */
data class SyncGroup(
val deviceSerialNumber: String,
val name: String = "",
val isGuestSession: Boolean = false,
val categories: List<SyncCategoryRule> = emptyList(),
)
data class TransferItem(
val action: TransferAction,
val category: SyncCategoryType,
val entityId: String,
val title: String,
val sourcePath: String = "",
val sizeBytes: Long = 0,
val detail: String? = null,
)
data class DeviceContentItem(
val category: SyncCategoryType,
val entityId: String,
val title: String,
val devicePath: String = "",
val sizeBytes: Long = 0,
val durationSeconds: Long? = null,
val playCount: Int? = null,
)
/** The computed difference between a group's rules and the device contents. */
data class SyncPlan(
val deviceSerialNumber: String,
val isGuestSession: Boolean = false,
val items: List<TransferItem> = emptyList(),
) {
val addCount: Int get() = items.count { it.action == TransferAction.ADD }
val removeCount: Int get() = items.count { it.action == TransferAction.REMOVE }
val keepCount: Int get() = items.count { it.action == TransferAction.KEEP }
val totalAddBytes: Long get() = items.filter { it.action == TransferAction.ADD }.sumOf { it.sizeBytes }
val totalRemoveBytes: Long get() = items.filter { it.action == TransferAction.REMOVE }.sumOf { it.sizeBytes }
fun addBytesFor(category: SyncCategoryType): Long =
items.filter { it.action == TransferAction.ADD && it.category == category }.sumOf { it.sizeBytes }
fun removeBytesFor(category: SyncCategoryType): Long =
items.filter { it.action == TransferAction.REMOVE && it.category == category }.sumOf { it.sizeBytes }
}
/** The library snapshot the rules are evaluated against. */
data class SyncInput(
val tracks: List<SyncTrack> = emptyList(),
val videos: List<SyncVideo> = emptyList(),
val photos: List<SyncPhoto> = emptyList(),
val podcastEpisodes: List<SyncPodcastEpisode> = emptyList(),
)
data class SyncTrack(
val id: String,
val title: String,
val artistName: String = "",
val albumTitle: String = "",
val durationSeconds: Long = 0,
val filePath: String = "",
val rating: Rating = Rating.NONE,
)
data class SyncVideo(
val id: String,
val title: String,
val filePath: String = "",
val sizeBytes: Long = 0,
val addedAtUtc: Long = 0,
)
data class SyncPhoto(
val id: String,
val title: String,
val filePath: String = "",
val folderPath: String = "",
val sizeBytes: Long = 0,
val addedAtUtc: Long = 0,
)
data class SyncPodcastEpisode(
val id: String,
val title: String,
val seriesTitle: String = "",
val publishedAtUtc: Long = 0,
val durationSeconds: Long = 0,
val audioUrl: String = "",
val isPlayed: Boolean = false,
)
/**
* The device side of the plan computation. A real transport (MTPZ, or the
* phone's own store during LAN sync) supplies the same three values.
*/
data class DeviceSnapshot(
val totalCapacityBytes: Long,
val systemBytes: Long = 0,
val contents: List<DeviceContentItem> = emptyList(),
)
/** The four Zune device sync rules, mirroring `AppSettings` on the desktop. */
data class SyncRuleSettings(
val musicSyncRule: String = "All Music (Automatic Sync)",
val podcastSyncRule: String = "3 Newest Episodes",
val videoSyncRule: String = "All Videos & Pictures",
val picturesSyncRule: String = "Newest 25 Items",
)
@@ -0,0 +1,25 @@
package com.heretek.dorado_hd.sync
import java.security.SecureRandom
/**
* Wire contract for phone ↔ Dorado desktop sync. Envelopes are JSON-RPC 2.0,
* matching `dorado/src/Dorado.Plugins.Protocol`. Discovery is mDNS over
* [_SERVICE_TYPE]; the socket transport, TLS and pairing handshake land in
* M8.2b once the desktop exposes a sync endpoint.
*/
object SyncProtocol {
const val JSONRPC = "2.0"
const val SERVICE_TYPE = "_dorado-sync._tcp"
const val DEFAULT_PORT = 8787
const val METHOD_HELLO = "sync.hello"
const val METHOD_PAIR = "sync.pair"
const val METHOD_MANIFEST = "sync.manifest"
const val METHOD_PULL = "sync.pull"
const val METHOD_PUSH = "sync.push"
/** A fresh 6-digit pairing code (leading zeros preserved). */
fun newPairingCode(random: SecureRandom = SecureRandom()): String =
"%06d".format(random.nextInt(1_000_000))
}
@@ -0,0 +1,18 @@
package com.heretek.dorado_hd.sync
/**
* The rule labels the Device view cycles through, chosen so the desktop
* `ParseTrailingCount`/mode keywords resolve to the intended [SyncMode].
* Labels mirror the desktop Settings wording.
*/
object SyncRulePresets {
val MUSIC = listOf("All Music (Automatic Sync)", "Selected Favorites", "Manual")
val PODCASTS = listOf("All Unplayed Episodes", "3 Newest Episodes", "Manual")
val MEDIA = listOf("All Videos & Pictures", "Newest 50 Items", "Manual")
/** Next preset after [current], wrapping; unknown labels fall back to first. */
fun next(options: List<String>, current: String): String {
val index = options.indexOf(current)
return if (index < 0) options.first() else options[(index + 1) % options.size]
}
}
@@ -50,9 +50,11 @@ import com.heretek.dorado_hd.ui.components.MenuController
import com.heretek.dorado_hd.ui.nav.DoradoDestination
import com.heretek.dorado_hd.ui.screens.AlbumDetailScreen
import com.heretek.dorado_hd.ui.screens.ArtistDetailScreen
import com.heretek.dorado_hd.ui.screens.DeviceScreen
import com.heretek.dorado_hd.ui.screens.GenreScreen
import com.heretek.dorado_hd.ui.screens.HomePages
import com.heretek.dorado_hd.ui.screens.InternetScreen
import com.heretek.dorado_hd.ui.screens.LyricsScreen
import com.heretek.dorado_hd.ui.screens.MarketplaceScreen
import com.heretek.dorado_hd.ui.screens.MiniAppScreen
import com.heretek.dorado_hd.ui.screens.MusicScreen
@@ -65,6 +67,8 @@ import com.heretek.dorado_hd.ui.screens.PlaylistDetailScreen
import com.heretek.dorado_hd.ui.screens.RadioScreen
import com.heretek.dorado_hd.ui.screens.SettingsScreen
import com.heretek.dorado_hd.ui.screens.SocialScreen
import com.heretek.dorado_hd.ui.screens.VideoItem
import com.heretek.dorado_hd.ui.screens.VideoPlayerScreen
import com.heretek.dorado_hd.ui.screens.VideosScreen
@Composable
@@ -158,6 +162,8 @@ private fun NavHost(canvasWidth: Dp, canvasHeight: Dp) {
is DoradoDestination.PlaylistDetail -> PlaylistDetailScreen(destination.playlistId, canvasWidth)
DoradoDestination.NowPlaying -> NowPlayingScreen(canvasWidth)
DoradoDestination.Settings -> SettingsScreen(canvasWidth)
DoradoDestination.Device -> DeviceScreen(canvasWidth)
DoradoDestination.Lyrics -> LyricsScreen(canvasWidth)
DoradoDestination.Videos -> VideosScreen(canvasWidth)
DoradoDestination.Pictures -> PicturesScreen(canvasWidth)
DoradoDestination.Radio -> RadioScreen(canvasWidth)
@@ -168,6 +174,16 @@ private fun NavHost(canvasWidth: Dp, canvasHeight: Dp) {
DoradoDestination.Internet -> InternetScreen(canvasWidth)
is DoradoDestination.MiniApp -> MiniAppScreen(destination.appId, canvasWidth)
is DoradoDestination.PictureDetail -> PictureDetailScreen(destination.uri)
is DoradoDestination.Video -> VideoPlayerScreen(
item = VideoItem(
id = 0L,
title = destination.title,
artist = "",
uri = android.net.Uri.parse(destination.uri),
bucket = "",
),
onExit = { graph.nav.pop() },
)
}
}
}
@@ -42,14 +42,58 @@ import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.Selawik
import com.heretek.dorado_hd.design.DoradoMotion
import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.DoradoGraph
import com.heretek.dorado_hd.analysis.DynamicMix
import com.heretek.dorado_hd.analysis.DynamicMixKind
import com.heretek.dorado_hd.design.components.AlbumArt
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.data.model.PinKind
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** One action in a context menu. */
data class MenuAction(val label: String, val action: () -> Unit)
/**
* The universal track long-press actions: queue it next, pin it to Quickplay.
* Screens append their own entity-specific actions (view album, add to
* playlist, remove…) to this list.
*/
fun trackMenuActions(
graph: DoradoGraph,
scope: CoroutineScope,
track: Track,
): List<MenuAction> = listOf(
MenuAction("play next") { graph.controller.enqueue(track) },
MenuAction("start mix") {
scope.launch {
val library = graph.library.tracks().first()
val ratings = graph.quickplay.ratings()
val favorites = library.filter { ratings[it.mediaId] == Rating.HEART.value }
val mix = graph.mixes.materialize(
DynamicMix(
name = "mix: ${track.title}",
kind = DynamicMixKind.SIMILAR_TO_TRACK,
seedId = track.mediaId,
trackLimit = 25,
),
library = library,
favorites = favorites,
)
if (mix.isNotEmpty()) graph.controller.play(mix, 0)
}
},
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.TRACK, track.mediaId, track.title, track.artist, track.albumId)
}
},
)
class MenuRequest(
val title: String,
val actions: List<MenuAction>,
@@ -21,6 +21,8 @@ sealed interface DoradoDestination {
data class PlaylistDetail(val playlistId: Long) : DoradoDestination
data object NowPlaying : DoradoDestination
data object Settings : DoradoDestination
data object Device : DoradoDestination
data object Lyrics : DoradoDestination
// Media pivots (canon §3.1, Phase 5 surfaces).
data object Videos : DoradoDestination
data object Pictures : DoradoDestination
@@ -34,6 +36,8 @@ sealed interface DoradoDestination {
data class MiniApp(val appId: String) : DoradoDestination
// A single pinned picture viewer — URI is stored in the pin's subLabel.
data class PictureDetail(val uri: String) : DoradoDestination
// A single pinned video, opened from Quickplay — title + URI from the pin.
data class Video(val title: String, val uri: String) : DoradoDestination
}
class DoradoNav {
@@ -1,6 +1,7 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
@@ -47,7 +48,9 @@ import com.heretek.dorado_hd.ui.components.DetailScaffold
import com.heretek.dorado_hd.ui.components.LocalContextMenu
import com.heretek.dorado_hd.ui.components.MenuAction
import com.heretek.dorado_hd.ui.components.TrackRow
import com.heretek.dorado_hd.ui.components.trackMenuActions
import com.heretek.dorado_hd.ui.nav.DoradoDestination
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
@@ -109,24 +112,21 @@ fun AlbumDetailScreen(albumId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier
.combinedClickable(onClick = {
.clickable {
tracks.firstOrNull()?.let { graph.controller.play(tracks, 0) }
}, onLongClick = {})
}
.padding(end = 16.dp),
)
EdgeCropText(
text = "shuffle",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier.combinedClickable(
onClick = {
tracks.firstOrNull()?.let {
graph.controller.play(tracks, 0)
graph.controller.setShuffle(true)
}
},
onLongClick = {},
),
modifier = Modifier.clickable {
tracks.firstOrNull()?.let {
graph.controller.play(tracks, 0)
graph.controller.setShuffle(true)
}
},
)
}
}
@@ -146,12 +146,7 @@ fun AlbumDetailScreen(albumId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
onLongClick = {
menus.show(
title = track.title,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.TRACK, track.mediaId, track.title, track.artist, track.albumId)
}
},
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("view artist") { graph.nav.push(DoradoDestination.Artist(track.artistId)) },
),
)
@@ -184,7 +179,14 @@ fun ArtistDetailScreen(artistId: Long, canvasWidth: androidx.compose.ui.unit.Dp)
tracks = graph.library.tracksByArtist(artistId)
albums = graph.library.albumsByArtist(artistId)
artistName = tracks.firstOrNull()?.artist
related = graph.library.relatedArtists(artistId)
// On-device audio similarity first (M9), genre overlap as fallback.
related = graph.analysis.relatedArtists(
seedArtistId = artistId,
artists = graph.library.artists().first(),
library = graph.library.tracks().first(),
count = 12,
)
if (related.isEmpty()) related = graph.library.relatedArtists(artistId)
loaded = true
}
@@ -317,6 +319,8 @@ private fun ArtistPhotos(artistName: String?) {
private fun ArtistRelated(related: List<com.heretek.dorado_hd.data.model.Artist>, artistName: String? = null) {
val context = androidx.compose.ui.platform.LocalContext.current
val graph = LocalDoradoGraph.current
val menus = LocalContextMenu.current
val scope = rememberCoroutineScope()
if (related.isEmpty()) {
// The Zune HD surfaced 'similar artists' from Last.fm + your friends'
// listening history. We have neither offline, so the empty state
@@ -332,7 +336,7 @@ private fun ArtistRelated(related: List<com.heretek.dorado_hd.data.model.Artist>
color = LocalDoradoColors.current.textSecondary,
)
EdgeCropText(
text = "your zune would have drawn these from the social feed and last.fm similarity. without those signals, the only related artists we can show are the ones who share genres with this one in your library — a thin slice.",
text = "related artists are ranked by on-device audio similarity. this artist has no comparable tracks in your library yet.",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = LocalDoradoColors.current.textSecondary,
alpha = 0.6f,
@@ -368,7 +372,18 @@ private fun ArtistRelated(related: List<com.heretek.dorado_hd.data.model.Artist>
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Artist(artist.artistId)) },
onLongClick = {},
onLongClick = {
menus.show(
title = artist.name,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.ARTIST, artist.artistId, artist.name, "${artist.albumCount} albums", 0)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
contentAlignment = Alignment.CenterStart,
@@ -387,6 +402,8 @@ private fun ArtistRelated(related: List<com.heretek.dorado_hd.data.model.Artist>
@Composable
private fun ArtistAlbums(albums: List<Album>) {
val graph = LocalDoradoGraph.current
val menus = LocalContextMenu.current
val scope = rememberCoroutineScope()
LazyRow(
contentPadding = androidx.compose.foundation.layout.PaddingValues(DoradoTokens.EDGE.dp),
horizontalArrangement = Arrangement.spacedBy(DoradoTokens.GRID_GUTTER.dp),
@@ -397,7 +414,18 @@ private fun ArtistAlbums(albums: List<Album>) {
Modifier
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Album(album.albumId)) },
onLongClick = {},
onLongClick = {
menus.show(
title = album.title,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.ALBUM, album.albumId, album.title, album.artist, album.albumId)
}
},
),
)
},
)
.width(92.dp),
) {
@@ -426,6 +454,7 @@ private fun ArtistSongs(
graph: com.heretek.dorado_hd.DoradoGraph,
) {
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
val scope = rememberCoroutineScope()
KineticList(
items = tracks,
key = { it.mediaId },
@@ -438,7 +467,7 @@ private fun ArtistSongs(
onLongClick = {
menus.show(
title = track.title,
actions = listOf(
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("view album") { graph.nav.push(DoradoDestination.Album(track.albumId)) },
),
)
@@ -452,6 +481,8 @@ private fun ArtistSongs(
@Composable
fun GenreScreen(genre: String, canvasWidth: androidx.compose.ui.unit.Dp) {
val graph = LocalDoradoGraph.current
val menus = LocalContextMenu.current
val scope = rememberCoroutineScope()
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
@@ -469,7 +500,15 @@ fun GenreScreen(genre: String, canvasWidth: androidx.compose.ui.unit.Dp) {
track = track,
playing = nowPlayingId?.mediaId == track.mediaId,
onClick = { graph.controller.play(tracks, index) },
onLongClick = {},
onLongClick = {
menus.show(
title = track.title,
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("view album") { graph.nav.push(DoradoDestination.Album(track.albumId)) },
MenuAction("view artist") { graph.nav.push(DoradoDestination.Artist(track.artistId)) },
),
)
},
)
},
)
@@ -481,6 +520,7 @@ fun GenreScreen(genre: String, canvasWidth: androidx.compose.ui.unit.Dp) {
fun PlaylistDetailScreen(playlistId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
val graph = LocalDoradoGraph.current
val scope = rememberCoroutineScope()
val menus = LocalContextMenu.current
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
@@ -508,10 +548,17 @@ fun PlaylistDetailScreen(playlistId: Long, canvasWidth: androidx.compose.ui.unit
playing = nowPlayingId?.mediaId == track.mediaId,
onClick = { graph.controller.play(tracks, index) },
onLongClick = {
scope.launch {
graph.library.removeFromPlaylist(playlistId, track.mediaId)
tracks = graph.library.tracksInPlaylist(playlistId)
}
menus.show(
title = track.title,
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("remove from playlist") {
scope.launch {
graph.library.removeFromPlaylist(playlistId, track.mediaId)
tracks = graph.library.tracksInPlaylist(playlistId)
}
},
),
)
},
)
},
@@ -0,0 +1,319 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.heretek.dorado_hd.data.repo.DoradoSettings
import com.heretek.dorado_hd.data.repo.toSyncRuleSettings
import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.sync.DeviceContentItem
import com.heretek.dorado_hd.sync.DeviceTransport
import com.heretek.dorado_hd.sync.SyncEngine
import com.heretek.dorado_hd.sync.SyncPlan
import com.heretek.dorado_hd.sync.SyncProtocol
import com.heretek.dorado_hd.sync.SyncRulePresets
import com.heretek.dorado_hd.sync.TransferAction
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
import com.heretek.dorado_hd.ui.components.SectionLabel
import kotlinx.coroutines.launch
/**
* Device Link (M8.3): the phone library is the sync source; the linked device
* is the target. Shows the pairing state, the storage gauge, the four Zune
* sync rules, a dry-run "what will sync" manifest and the reverse copy-back
* queue. Targets a simulated transport until the LAN transport lands (M8.2b).
*/
@Composable
fun DeviceScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
val graph = LocalDoradoGraph.current
val scope = rememberCoroutineScope()
val settings by graph.settingsFlow.collectAsState(initial = DoradoSettings())
val pending by graph.deviceLink.pendingImports.collectAsState()
val transport = graph.deviceLink.transport
val pairingCode = remember { SyncProtocol.newPairingCode() }
var guest by remember { mutableStateOf(false) }
var plan by remember { mutableStateOf<SyncPlan?>(null) }
var contents by remember { mutableStateOf(transport.contents()) }
var busy by remember { mutableStateOf(false) }
suspend fun recompute() {
busy = true
try {
val input = graph.deviceLink.buildInput(graph.library, graph.quickplay, graph.podcasts)
val group = SyncEngine.buildDefaultGroup(
transport.deviceSerialNumber,
settings.toSyncRuleSettings(),
guest,
)
plan = SyncEngine.buildPlan(group, input, graph.deviceLink.snapshot())
contents = transport.contents()
} finally {
busy = false
}
}
LaunchedEffect(settings.musicSyncRule, settings.podcastSyncRule, settings.videoSyncRule, guest) {
recompute()
}
DetailScaffold(title = "device link") {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 48.dp),
) {
SectionLabel("status")
EdgeCropText(
text = "not paired — start a sync from the dorado desktop, then enter this code",
fontSize = DoradoTokens.TYPE_LIST.dp,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp),
)
EdgeCropText(
text = "pairing code $pairingCode",
fontSize = DoradoTokens.TYPE_NOW_TITLE.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
)
EdgeCropText(
text = "${transport.deviceName} · ${formatBytes(transport.totalCapacityBytes)}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.textSecondary,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp),
)
SectionLabel("storage")
GasGauge(transport)
EdgeCropText(
text = "${formatBytes(transport.freeBytes)} free · " +
"${formatBytes(transport.usedBytes - transport.systemBytes)} used · " +
"${formatBytes(transport.systemBytes)} reserved",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = LocalDoradoColors.current.textSecondary,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
)
SectionLabel("sync rules")
RuleRow("music", settings.musicSyncRule) {
scope.launch {
graph.settings.setMusicSyncRule(SyncRulePresets.next(SyncRulePresets.MUSIC, settings.musicSyncRule))
}
}
RuleRow("podcasts", settings.podcastSyncRule) {
scope.launch {
graph.settings.setPodcastSyncRule(SyncRulePresets.next(SyncRulePresets.PODCASTS, settings.podcastSyncRule))
}
}
RuleRow("videos & pictures", settings.videoSyncRule) {
scope.launch {
graph.settings.setMediaSyncRule(SyncRulePresets.next(SyncRulePresets.MEDIA, settings.videoSyncRule))
}
}
RuleRow("session", if (guest) "guest (add-only, never removes)" else "normal") {
guest = !guest
}
SectionLabel("what will sync")
val current = plan
when {
busy -> LoadingLine()
current == null -> LoadingLine()
current.items.isEmpty() -> EdgeCropText(
text = "up to date",
fontSize = DoradoTokens.TYPE_LIST.dp,
alpha = 0.5f,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
)
else -> {
EdgeCropText(
text = "${current.addCount} to add · ${current.removeCount} to remove · ${current.keepCount} kept",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.textSecondary,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp),
)
EdgeCropText(
text = "${formatBytes(current.totalAddBytes)} to copy",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = LocalDoradoColors.current.textSecondary,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 4.dp),
)
ManifestList(current)
EdgeCropText(
text = "apply to device",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier
.clickable(enabled = !busy) {
val p = plan ?: return@clickable
scope.launch {
busy = true
try {
SyncEngine.applyPlan(p, transport)
recompute()
} finally {
busy = false
}
}
}
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 8.dp),
)
}
}
SectionLabel("on device")
if (contents.isEmpty()) {
EdgeCropText(
text = "nothing on device",
fontSize = DoradoTokens.TYPE_LIST.dp,
alpha = 0.4f,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp),
)
} else {
contents.forEach { item -> DeviceItemRow(item) { graph.deviceLink.copyBack(item) } }
}
if (pending.isNotEmpty()) {
SectionLabel("pending imports (${pending.size})")
pending.forEach { p ->
EdgeCropText(
text = "${p.title} · ${formatBytes(p.sizeBytes)}",
fontSize = DoradoTokens.TYPE_LIST.dp,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 2.dp),
)
}
EdgeCropText(
text = "clear queue",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier
.clickable { graph.deviceLink.clearPending() }
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
)
}
}
}
}
@Composable
private fun LoadingLine() {
EdgeCropText(
text = "building…",
fontSize = DoradoTokens.TYPE_LIST.dp,
alpha = 0.4f,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
)
}
@Composable
private fun RuleRow(label: String, value: String, onClick: () -> Unit) {
val colors = LocalDoradoColors.current
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 6.dp),
) {
EdgeCropText(text = label, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = value,
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
)
}
}
@Composable
private fun GasGauge(transport: DeviceTransport) {
val colors = LocalDoradoColors.current
val total = transport.totalCapacityBytes.coerceAtLeast(1L).toFloat()
val systemFrac = (transport.systemBytes / total).coerceIn(0.001f, 1f)
val usedFrac = ((transport.usedBytes - transport.systemBytes) / total).coerceIn(0.001f, 1f)
val freeFrac = (1f - systemFrac - usedFrac).coerceAtLeast(0.001f)
Row(
Modifier
.fillMaxWidth()
.height(10.dp)
.padding(horizontal = DoradoTokens.EDGE.dp)
.background(colors.tile),
) {
Box(Modifier.fillMaxHeight().weight(systemFrac).background(colors.border))
Box(Modifier.fillMaxHeight().weight(usedFrac).background(colors.accent))
Box(Modifier.fillMaxHeight().weight(freeFrac))
}
}
@Composable
private fun ManifestList(plan: SyncPlan) {
val colors = LocalDoradoColors.current
plan.items.take(200).forEach { item ->
val marker = when (item.action) {
TransferAction.ADD -> "+"
TransferAction.REMOVE -> "−"
TransferAction.KEEP -> "="
}
EdgeCropText(
text = "$marker ${item.title}",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = when (item.action) {
TransferAction.ADD -> colors.textPrimary
TransferAction.REMOVE -> colors.textSecondary
TransferAction.KEEP -> colors.textInactive
},
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 2.dp),
)
}
}
@Composable
private fun DeviceItemRow(item: DeviceContentItem, onCopyBack: () -> Unit) {
val colors = LocalDoradoColors.current
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 4.dp),
) {
Column(Modifier.weight(1f)) {
EdgeCropText(text = item.title, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = "${item.category} · ${formatBytes(item.sizeBytes)}",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = colors.textSecondary,
)
}
EdgeCropText(
text = "copy back",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier.clickable(onClick = onCopyBack).padding(vertical = 4.dp),
)
}
}
private fun formatBytes(bytes: Long): String {
val mb = bytes / (1024.0 * 1024.0)
return if (mb >= 1024) "%.1f GB".format(mb / 1024.0) else "%.0f MB".format(mb)
}
@@ -1,6 +1,7 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -155,10 +156,7 @@ fun QuickplayScreen(canvasWidth: Dp) {
Modifier
.fillMaxWidth()
.height(72.dp)
.combinedClickable(
onClick = { if (track != null) graph.nav.push(DoradoDestination.NowPlaying) },
onLongClick = {},
),
.clickable { if (track != null) graph.nav.push(DoradoDestination.NowPlaying) },
verticalAlignment = Alignment.CenterVertically,
) {
if (track != null) {
@@ -194,6 +192,10 @@ fun QuickplayScreen(canvasWidth: Dp) {
SectionLabel("smart dj")
SmartDjRow(graph, scope)
Spacer(Modifier.height(8.dp))
SectionLabel("mixes")
FavoritesMixRow(graph, scope)
Spacer(Modifier.height(8.dp))
SectionLabel("pins")
QuickplayRow(graph, scope, cards = pins)
@@ -219,26 +221,22 @@ private fun SmartDjRow(graph: DoradoGraph, scope: CoroutineScope) {
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
enabled = !building,
onClick = {
building = true
scope.launch {
try {
val tracks = graph.library.tracks().first()
val ratings = graph.quickplay.ratings()
val trackList = tracks.take(60)
val current = graph.controller.nowPlaying.value
val ordered = PlaybackController.smartShuffleOrder(trackList, ratings, current)
val mix = ordered.take(25)
if (mix.isNotEmpty()) graph.controller.play(mix, 0)
} finally {
building = false
}
.clickable(enabled = !building) {
building = true
scope.launch {
try {
val tracks = graph.library.tracks().first()
val ratings = graph.quickplay.ratings()
val trackList = tracks.take(60)
val current = graph.controller.nowPlaying.value
val ordered = PlaybackController.smartShuffleOrder(trackList, ratings, current)
val mix = ordered.take(25)
if (mix.isNotEmpty()) graph.controller.play(mix, 0)
} finally {
building = false
}
},
onLongClick = {},
)
}
}
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -250,6 +248,50 @@ private fun SmartDjRow(graph: DoradoGraph, scope: CoroutineScope) {
}
}
/** Materialize and play the rune-style Favorites Mix (M9.2). */
@Composable
private fun FavoritesMixRow(graph: DoradoGraph, scope: CoroutineScope) {
val colors = LocalDoradoColors.current
var building by remember { mutableStateOf(false) }
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.clickable(enabled = !building) {
building = true
scope.launch {
try {
val library = graph.library.tracks().first()
val ratings = graph.quickplay.ratings()
val favorites = library.filter {
ratings[it.mediaId] == com.heretek.dorado_hd.data.model.Rating.HEART.value
}
val mix = graph.mixes.materialize(
com.heretek.dorado_hd.analysis.DynamicMix(
name = "favorites mix",
kind = com.heretek.dorado_hd.analysis.DynamicMixKind.SIMILAR_TO_FAVORITES,
trackLimit = 50,
),
library = library,
favorites = favorites,
)
if (mix.isNotEmpty()) graph.controller.play(mix, 0)
} finally {
building = false
}
}
}
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EdgeCropText(
text = if (building) "building mix…" else "play favorites mix",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
color = colors.accent,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun QuickplayRow(graph: DoradoGraph, scope: CoroutineScope, cards: List<QuickplayCard>) {
@@ -306,6 +348,7 @@ private fun QuickplayRow(graph: DoradoGraph, scope: CoroutineScope, cards: List<
private fun NewRow() {
val graph = LocalDoradoGraph.current
val scope = rememberCoroutineScope()
val menus = LocalContextMenu.current
val albums by graph.library.albums().collectAsState(initial = emptyList())
val recent = albums.sortedByDescending { it.dateAdded }.take(12)
LazyRow(
@@ -318,7 +361,18 @@ private fun NewRow() {
.width(72.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Album(album.albumId)) },
onLongClick = {},
onLongClick = {
menus.show(
title = album.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.ALBUM, album.albumId, album.title, album.artist, album.albumId)
}
},
),
)
},
),
) {
AlbumArt(
@@ -349,5 +403,26 @@ private fun openCard(graph: DoradoGraph, scope: CoroutineScope, card: QuickplayC
if (uri != null) graph.nav.push(DoradoDestination.PictureDetail(uri))
}
PinKind.RADIO -> graph.nav.push(DoradoDestination.Radio)
PinKind.GENRE -> graph.nav.push(DoradoDestination.Genre(card.label))
PinKind.VIDEO -> graph.nav.push(DoradoDestination.Video(card.label, card.subLabel))
PinKind.APP -> graph.nav.push(DoradoDestination.MiniApp(card.subLabel))
PinKind.PODCAST -> graph.nav.push(DoradoDestination.PodcastFeed(card.refId))
PinKind.EPISODE -> scope.launch {
val track = com.heretek.dorado_hd.data.model.Track(
mediaId = card.refId,
title = card.label,
artist = "",
artistId = 0,
album = "",
albumId = 0,
genre = "podcast",
durationMs = 0,
dateAdded = 0,
trackNumber = 0,
year = "",
uri = android.net.Uri.parse(card.subLabel),
)
graph.controller.play(listOf(track))
}
}
}
@@ -0,0 +1,91 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.Selawik
import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.net.LrcLibParser
import com.heretek.dorado_hd.net.Lyrics
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
/**
* Lyrics for the current track via LRCLIB (M9.4). A post-device extension
* (canon §10); plain text is preferred, falling back to de-timestamped LRC.
*/
@Composable
fun LyricsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
val graph = LocalDoradoGraph.current
val colors = LocalDoradoColors.current
val current by graph.controller.nowPlaying.collectAsState()
var lyrics by remember { mutableStateOf<Lyrics?>(null) }
var loaded by remember { mutableStateOf(false) }
val track = current
LaunchedEffect(track?.mediaId) {
lyrics = null
loaded = false
if (track != null) {
lyrics = graph.lyrics.lyricsFor(
artist = track.artist,
title = track.title,
album = track.album,
durationSec = (track.durationMs / 1000).toInt(),
)
}
loaded = true
}
DetailScaffold(title = "lyrics") {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
val text = lyrics?.plain ?: lyrics?.synced?.let { LrcLibParser.plainFromSynced(it) }
when {
!loaded -> EdgeCropText(
text = "loading…",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
alpha = 0.4f,
modifier = Modifier.padding(DoradoTokens.EDGE.dp),
)
text == null -> EdgeCropText(
text = "no lyrics found for “${track?.title ?: ""}”",
fontSize = DoradoTokens.TYPE_NOW_META.dp,
alpha = 0.5f,
modifier = Modifier.padding(DoradoTokens.EDGE.dp),
)
else -> BasicText(
text = text,
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST.sp,
color = colors.textPrimary.copy(alpha = 0.9f),
lineHeight = DoradoTokens.TYPE_NOW_META.sp * 1.4f,
),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 12.dp),
)
}
}
}
}
@@ -2,6 +2,7 @@ package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -55,11 +57,11 @@ fun MarketplaceScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
val scope = rememberCoroutineScope()
val pagerState = androidx.compose.foundation.pager.rememberPagerState(
initialPage = 0,
pageCount = { 4 },
pageCount = { 5 },
)
Column(Modifier.fillMaxSize()) {
CrossbarBar(
labels = listOf("music", "videos", "podcasts", "apps"),
labels = listOf("music", "videos", "podcasts", "apps", "games"),
selected = pagerState.currentPage,
onSelect = { index -> scope.launch { pagerState.animateScrollToPage(index) } },
)
@@ -69,17 +71,187 @@ fun MarketplaceScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
) { page ->
when (page) {
0 -> MarketplaceMusic()
1 -> EmptyPivot("videos", "coming soon")
2 -> EmptyPivot("podcasts", "add feeds in podcasts")
else -> AppsPivot()
1 -> MarketplaceVideos()
2 -> MarketplacePodcasts()
3 -> AppsPivot()
else -> GamesPivot()
}
}
}
}
@Composable
private fun MarketplaceVideos() {
val context = androidx.compose.ui.platform.LocalContext.current
val graph = LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
var videos by remember { mutableStateOf<List<VideoItem>>(emptyList()) }
LaunchedEffect(Unit) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val list = mutableListOf<VideoItem>()
val cursor = context.contentResolver.query(
android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(
android.provider.MediaStore.Video.Media._ID,
android.provider.MediaStore.Video.Media.TITLE,
android.provider.MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
),
null, null,
"${android.provider.MediaStore.Video.Media.DATE_ADDED} DESC LIMIT 50",
)
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(android.provider.MediaStore.Video.Media._ID)
val titleCol = c.getColumnIndexOrThrow(android.provider.MediaStore.Video.Media.TITLE)
val bucketCol = c.getColumnIndexOrThrow(android.provider.MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
while (c.moveToNext()) {
val id = c.getLong(idCol)
list += VideoItem(
id = id,
title = c.getString(titleCol) ?: "untitled",
artist = "",
uri = android.content.ContentUris.withAppendedId(
android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id,
),
bucket = c.getString(bucketCol) ?: "",
)
}
}
videos = list
}
}
if (videos.isEmpty()) {
EmptyPivot("videos", "no videos on device")
return
}
KineticList(
items = videos,
key = { it.id },
letter = { firstLetterOf(it.title) },
rowContent = { v, _ ->
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Video(v.title, v.uri.toString())) },
onLongClick = {
menus.show(
title = v.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("play") {
graph.nav.push(DoradoDestination.Video(v.title, v.uri.toString()))
},
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.VIDEO,
v.id, v.title, v.uri.toString(), 0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EdgeCropText(text = v.title, fontSize = DoradoTokens.TYPE_LIST.dp)
}
},
)
}
@Composable
private fun MarketplacePodcasts() {
val graph = LocalDoradoGraph.current
val feeds by graph.podcasts.feeds().collectAsState(initial = emptyList())
if (feeds.isEmpty()) {
EmptyPivot("podcasts", "add feeds in podcasts")
return
}
KineticList(
items = feeds,
key = { it.id },
letter = { firstLetterOf(it.title) },
rowContent = { feed, _ ->
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.clickable { graph.nav.push(DoradoDestination.PodcastFeed(feed.id)) }
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
EdgeCropText(text = feed.title, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = "${feed.description.take(48)}",
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = LocalDoradoColors.current.textSecondary,
)
}
}
},
)
}
@Composable
private fun GamesPivot() {
val graph = LocalDoradoGraph.current
val installed = DoradoApps.all.filter { it.category == "games" }
val catalogGames = OfficialCatalog.all.filter { it.category == OfficialCatalog.GAMES }
Column(Modifier.fillMaxSize()) {
if (installed.isNotEmpty()) {
EdgeCropText(
text = "installed",
fontSize = DoradoTokens.TYPE_CROSSBAR.dp,
alpha = 0.6f,
modifier = Modifier.padding(start = DoradoTokens.EDGE.dp, top = 8.dp),
)
Spacer(Modifier.height(8.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = DoradoTokens.EDGE.dp),
horizontalArrangement = Arrangement.spacedBy(DoradoTokens.GRID_GUTTER.dp),
) {
items(installed, key = { it.id }) { app ->
Column(
Modifier.width(DoradoTokens.APP_TILE.dp).clickable {
graph.nav.push(DoradoDestination.MiniApp(app.id))
},
) {
AlbumArt(
model = null,
contentDescription = app.title,
modifier = Modifier.size(DoradoTokens.APP_TILE.dp),
)
EdgeCropText(text = app.title, fontSize = DoradoTokens.TYPE_CAPTION.dp, modifier = Modifier.fillMaxWidth())
}
}
}
Spacer(Modifier.height(12.dp))
}
EdgeCropText(
text = "frozen catalog",
fontSize = DoradoTokens.TYPE_CROSSBAR.dp,
alpha = 0.6f,
modifier = Modifier.padding(start = DoradoTokens.EDGE.dp),
)
KineticList(
items = catalogGames,
key = { it.exe },
letter = { firstLetterOf(it.title) },
rowContent = { entry, _ -> AppsCatalogRow(entry) },
)
}
}
@Composable
private fun MarketplaceMusic() {
val graph = LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
val albums by graph.library.albums().collectAsState(initial = emptyList())
val featured = remember(albums) { albums.take(12) }
Column(Modifier.fillMaxSize().padding(horizontal = DoradoTokens.EDGE.dp, vertical = 8.dp)) {
@@ -97,7 +269,21 @@ private fun MarketplaceMusic() {
Column(
Modifier.width(DoradoTokens.APP_TILE.dp).combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Album(album.albumId)) },
onLongClick = {},
onLongClick = {
menus.show(
title = album.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.ALBUM,
album.albumId, album.title, album.artist, album.albumId,
)
}
},
),
)
},
),
) {
AlbumArt(
@@ -124,6 +310,8 @@ private fun EmptyPivot(label: String, note: String) {
@Composable
private fun AppsPivot() {
val graph = LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
val installed = DoradoApps.all
val catalog = OfficialCatalog.all
Column(Modifier.fillMaxSize()) {
@@ -143,7 +331,22 @@ private fun AppsPivot() {
Column(
Modifier.width(DoradoTokens.APP_TILE.dp).combinedClickable(
onClick = { graph.nav.push(DoradoDestination.MiniApp(app.id)) },
onLongClick = {},
onLongClick = {
menus.show(
title = app.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.APP,
com.heretek.dorado_hd.data.model.PinKind.stableId(app.id),
app.title, app.id, 0,
)
}
},
),
)
},
),
) {
AlbumArt(
@@ -178,6 +381,8 @@ private fun AppsPivot() {
private fun AppsCatalogRow(entry: OfficialApp) {
val graph = LocalDoradoGraph.current
val colors = LocalDoradoColors.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
val isInstalled = entry.installedId != null
Row(
Modifier
@@ -186,7 +391,24 @@ private fun AppsCatalogRow(entry: OfficialApp) {
.combinedClickable(
enabled = isInstalled,
onClick = { entry.installedId?.let { graph.nav.push(DoradoDestination.MiniApp(it)) } },
onLongClick = {},
onLongClick = {
entry.installedId?.let { id ->
menus.show(
title = entry.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.APP,
com.heretek.dorado_hd.data.model.PinKind.stableId(id),
entry.title, id, 0,
)
}
},
),
)
}
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -8,6 +8,7 @@ import android.provider.MediaStore
import android.webkit.WebView
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
@@ -149,6 +150,9 @@ fun VideosScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
@Composable
private fun VideoListContent(videos: List<VideoItem>, onPlay: (VideoItem) -> Unit) {
val graph = LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
if (videos.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EdgeCropText(
@@ -168,7 +172,28 @@ private fun VideoListContent(videos: List<VideoItem>, onPlay: (VideoItem) -> Uni
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(onClick = { onPlay(v) }, onLongClick = {})
.combinedClickable(
onClick = { onPlay(v) },
onLongClick = {
menus.show(
title = v.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("play") { onPlay(v) },
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.VIDEO,
v.id,
v.title,
v.uri.toString(),
0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -183,7 +208,7 @@ private fun VideoListContent(videos: List<VideoItem>, onPlay: (VideoItem) -> Uni
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun VideoPlayerScreen(item: VideoItem, onExit: () -> Unit) {
fun VideoPlayerScreen(item: VideoItem, onExit: () -> Unit) {
val context = LocalContext.current
val player = remember { androidx.media3.exoplayer.ExoPlayer.Builder(context).build() }
var positionMs by remember { mutableStateOf(0L) }
@@ -223,7 +248,7 @@ private fun VideoPlayerScreen(item: VideoItem, onExit: () -> Unit) {
Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.combinedClickable(onClick = onExit, onLongClick = {}),
.clickable(onClick = onExit),
) {
EdgeCropText(text = "<- back", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
@@ -243,12 +268,9 @@ private fun VideoPlayerScreen(item: VideoItem, onExit: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Box(
Modifier
.combinedClickable(
onClick = {
if (player.isPlaying) player.pause() else player.play()
},
onLongClick = {},
)
.clickable {
if (player.isPlaying) player.pause() else player.play()
}
.padding(end = 12.dp),
) {
EdgeCropText(
@@ -269,11 +291,7 @@ private fun VideoPlayerScreen(item: VideoItem, onExit: () -> Unit) {
Modifier
.fillMaxWidth()
.height(6.dp)
.background(LocalDoradoColors.current.tile)
.combinedClickable(
onClick = {},
onLongClick = {},
),
.background(LocalDoradoColors.current.tile),
) {
val frac = if (durationMs > 0) (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) else 0f
Box(
@@ -371,17 +389,14 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.combinedClickable(
onClick = {
val item = viewing.items[pagerStateBucket.currentPage]
scope.launch { graph.quickplay.pin(com.heretek.dorado_hd.data.model.PinKind.PICTURE, item.id, item.displayName, item.uri.toString(), 0) }
},
onLongClick = {},
),
.clickable {
val item = viewing.items[pagerStateBucket.currentPage]
scope.launch { graph.quickplay.pin(com.heretek.dorado_hd.data.model.PinKind.PICTURE, item.id, item.displayName, item.uri.toString(), 0) }
},
) {
EdgeCropText(text = "pin", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
Box(Modifier.align(Alignment.TopStart).padding(8.dp).combinedClickable(onClick = { selectedBucket = null }, onLongClick = {})) {
Box(Modifier.align(Alignment.TopStart).padding(8.dp).clickable { selectedBucket = null }) {
EdgeCropText(text = "<- albums", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
}
@@ -407,10 +422,7 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { selectedBucket = bucket; viewerIndex = 0 },
onLongClick = {},
)
.clickable { selectedBucket = bucket; viewerIndex = 0 }
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -437,6 +449,9 @@ fun PicturesScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
@Composable
private fun PictureListContent(pictures: List<PictureItem>, onPicture: (PictureItem) -> Unit) {
val graph = LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
if (pictures.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EdgeCropText(
@@ -456,7 +471,28 @@ private fun PictureListContent(pictures: List<PictureItem>, onPicture: (PictureI
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(onClick = { onPicture(p) }, onLongClick = {})
.combinedClickable(
onClick = { onPicture(p) },
onLongClick = {
menus.show(
title = p.displayName,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("view") { onPicture(p) },
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.PICTURE,
p.id,
p.displayName,
p.uri.toString(),
0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -499,11 +535,8 @@ fun InternetScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(DoradoTokens.EDGE.dp)) {
// Back / forward in-page navigation (canon §3.6).
Box(
Modifier.combinedClickable(
enabled = canBack,
onClick = { webViewRef.value?.goBack() },
onLongClick = {},
).padding(end = 6.dp),
Modifier.clickable(enabled = canBack, onClick = { webViewRef.value?.goBack() })
.padding(end = 6.dp),
) {
EdgeCropText(
text = "<",
@@ -512,11 +545,8 @@ fun InternetScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
)
}
Box(
Modifier.combinedClickable(
enabled = canForward,
onClick = { webViewRef.value?.goForward() },
onLongClick = {},
).padding(end = 6.dp),
Modifier.clickable(enabled = canForward, onClick = { webViewRef.value?.goForward() })
.padding(end = 6.dp),
) {
EdgeCropText(
text = ">",
@@ -535,20 +565,17 @@ fun InternetScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
Box(
Modifier
.padding(start = 6.dp)
.combinedClickable(
onClick = { navigate(input) },
onLongClick = {},
),
.clickable { navigate(input) },
) {
EdgeCropText(text = "go", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
Box(
Modifier
.padding(start = 6.dp)
.combinedClickable(
onClick = { navigate(currentUrl); if (bookmarks.lastOrNull() != currentUrl) bookmarks.add(currentUrl) },
onLongClick = {},
),
.clickable {
navigate(currentUrl)
if (bookmarks.lastOrNull() != currentUrl) bookmarks.add(currentUrl)
},
) {
EdgeCropText(text = "*", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
@@ -1,6 +1,7 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -35,6 +36,7 @@ import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.LocalContextMenu
import com.heretek.dorado_hd.ui.components.MenuAction
import com.heretek.dorado_hd.ui.components.TrackRow
import com.heretek.dorado_hd.ui.components.trackMenuActions
import com.heretek.dorado_hd.ui.nav.DoradoDestination
import kotlinx.coroutines.launch
@@ -199,11 +201,16 @@ private fun PlaylistsTab() {
onLongClick = {
menus.show(
title = playlist.name,
actions = listOf(
MenuAction("delete playlist") {
scope.launch { graph.library.deletePlaylist(playlist.id) }
},
),
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.PLAYLIST, playlist.id, playlist.name, "${playlist.trackCount} songs", 0)
}
},
MenuAction("delete playlist") {
scope.launch { graph.library.deletePlaylist(playlist.id) }
},
),
)
},
)
@@ -230,14 +237,11 @@ private fun PlaylistsTab() {
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.combinedClickable(
onClick = {
menus.showPrompt("new playlist", "name") { name ->
scope.launch { graph.library.createPlaylist(name) }
}
},
onLongClick = {},
)
.clickable {
menus.showPrompt("new playlist", "name") { name ->
scope.launch { graph.library.createPlaylist(name) }
}
}
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 12.dp),
)
}
@@ -266,12 +270,7 @@ private fun SongsTab() {
onLongClick = {
menus.show(
title = track.title,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(PinKind.TRACK, track.mediaId, track.title, track.artist, track.albumId)
}
},
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("add to playlist") {
menus.show(
title = "add to playlist",
@@ -303,6 +302,8 @@ private fun SongsTab() {
@Composable
private fun GenresTab() {
val graph = LocalDoradoGraph.current
val menus = LocalContextMenu.current
val scope = rememberCoroutineScope()
val genres by graph.library.genres().collectAsState(initial = emptyList())
KineticList(
@@ -316,7 +317,24 @@ private fun GenresTab() {
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.Genre(genre.name)) },
onLongClick = {},
onLongClick = {
menus.show(
title = genre.name,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
PinKind.GENRE,
PinKind.stableId(genre.name),
genre.name,
"${genre.trackCount} songs",
0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -233,6 +233,17 @@ fun NowPlayingScreen(canvasWidth: Dp) {
alpha = 0.6f,
modifier = Modifier.padding(top = 6.dp),
)
EdgeCropText(
text = "lyrics",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
modifier = Modifier
.clickable {
poke()
graph.nav.push(DoradoDestination.Lyrics)
}
.padding(top = 8.dp),
)
}
}
@@ -2,6 +2,7 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -152,8 +153,7 @@ fun PodcastsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
Modifier
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 8.dp)
.combinedClickable(
onClick = {
.clickable {
menus.showPrompt("add feed", "https://example.com/feed.xml") { url ->
scope.launch {
val parsed = PodcastFetcher.fetch(url)
@@ -183,9 +183,7 @@ fun PodcastsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
}
}
}
},
onLongClick = {},
),
},
) {
EdgeCropText(text = "+ add feed by url", fontSize = DoradoTokens.TYPE_LIST.dp, color = LocalDoradoColors.current.accent)
}
@@ -256,6 +254,17 @@ private fun PodcastsFeedList(
menus.show(
title = feed.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.PODCAST,
feed.id,
feed.title,
feed.description.take(60),
0,
)
}
},
com.heretek.dorado_hd.ui.components.MenuAction("remove") {
scope.launch { graph.podcasts.deleteFeed(feed.id) }
},
@@ -280,6 +289,9 @@ private fun PodcastEpisodeList(
episodes: List<com.heretek.dorado_hd.data.db.PodcastEpisodeFlat>,
onPlay: (com.heretek.dorado_hd.data.db.PodcastEpisodeFlat) -> Unit,
) {
val graph = com.heretek.dorado_hd.ui.LocalDoradoGraph.current
val menus = com.heretek.dorado_hd.ui.components.LocalContextMenu.current
val scope = rememberCoroutineScope()
if (episodes.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EdgeCropText(text = "no episodes", fontSize = DoradoTokens.TYPE_NOW_META.dp, alpha = 0.4f)
@@ -295,7 +307,28 @@ private fun PodcastEpisodeList(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(onClick = { onPlay(ep) }, onLongClick = {})
.combinedClickable(
onClick = { onPlay(ep) },
onLongClick = {
menus.show(
title = ep.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("play") { onPlay(ep) },
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.EPISODE,
ep.episodeId,
ep.title,
ep.enclosureUrl,
0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -312,6 +345,7 @@ private fun PodcastEpisodeList(
fun PodcastFeedScreen(feedId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
val graph = LocalDoradoGraph.current
val scope = rememberCoroutineScope()
val menus = LocalContextMenu.current
val episodes by graph.podcasts.episodes(feedId).collectAsState(initial = emptyList())
val feedTitle = remember(feedId) { "podcast" }
@@ -345,7 +379,24 @@ fun PodcastFeedScreen(feedId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
graph.controller.play(listOf(track))
}
},
onLongClick = {},
onLongClick = {
menus.show(
title = ep.title,
actions = listOf(
com.heretek.dorado_hd.ui.components.MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
com.heretek.dorado_hd.data.model.PinKind.EPISODE,
ep.id,
ep.title,
ep.enclosureUrl,
0,
)
}
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -3,6 +3,7 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
@@ -127,45 +128,39 @@ fun RadioScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
text = "tune in",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier.combinedClickable(
onClick = {
val match = stations.firstOrNull { it.frequencyKhz == dialKhz }
?: stations.firstOrNull { it.streamUrl.isNotBlank() }
if (match != null) {
current = match
playStation(graph, match)
scope.launch { graph.radio.touch(match.id) }
}
},
onLongClick = {},
),
modifier = Modifier.clickable {
val match = stations.firstOrNull { it.frequencyKhz == dialKhz }
?: stations.firstOrNull { it.streamUrl.isNotBlank() }
if (match != null) {
current = match
playStation(graph, match)
scope.launch { graph.radio.touch(match.id) }
}
},
)
Spacer(Modifier.weight(1f))
EdgeCropText(
text = "+ station",
fontSize = DoradoTokens.TYPE_LIST.dp,
color = LocalDoradoColors.current.accent,
modifier = Modifier.combinedClickable(
onClick = {
menus.showPrompt("add station", "name|url") { text ->
val parts = text.split("|")
if (parts.size >= 2) {
scope.launch {
graph.radio.add(
RadioStationEntity(
name = parts[0].trim().ifBlank { "stream" },
frequencyKhz = dialKhz,
streamUrl = parts[1].trim(),
isPreset = false,
lastPlayedAt = 0,
),
)
}
modifier = Modifier.clickable {
menus.showPrompt("add station", "name|url") { text ->
val parts = text.split("|")
if (parts.size >= 2) {
scope.launch {
graph.radio.add(
RadioStationEntity(
name = parts[0].trim().ifBlank { "stream" },
frequencyKhz = dialKhz,
streamUrl = parts[1].trim(),
isPreset = false,
lastPlayedAt = 0,
),
)
}
}
},
onLongClick = {},
),
}
},
)
}
RadioStationList(
@@ -36,6 +36,7 @@ import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
import com.heretek.dorado_hd.ui.components.SectionLabel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Composable
@@ -149,6 +150,59 @@ fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
subLabel = scanStatusText(lastScanAt, lastScanResult, lastImportResult),
)
SectionLabel("device")
SettingsRow(
label = "device link",
subLabel = "pair with dorado desktop · preview what will sync",
onClick = { graph.nav.push(com.heretek.dorado_hd.ui.nav.DoradoDestination.Device) },
)
SettingsRow(
label = "analyze library",
subLabel = "compute on-device audio features for similarity and mixes",
onClick = { scope.launch { graph.analysis.analyzeAll(graph.library.tracks().first()) } },
)
SectionLabel("scrobbling")
SettingsToggle(
label = "last.fm scrobbling",
subLabel = "queue plays offline, send when configured",
value = settings.scrobbleEnabled,
) { enabled ->
scope.launch { graph.settings.setScrobbleEnabled(enabled) }
}
SettingsRow(
label = "last.fm api key",
subLabel = settings.lastFmApiKey.ifBlank { "not configured" },
onClick = {
menus.showPrompt("last.fm api key", "api key") { value ->
scope.launch { graph.settings.setLastFmApiKey(value) }
}
},
)
SettingsRow(
label = "last.fm api secret",
subLabel = if (settings.lastFmApiSecret.isBlank()) "not configured" else "configured",
onClick = {
menus.showPrompt("last.fm api secret", "shared secret") { value ->
scope.launch { graph.settings.setLastFmApiSecret(value) }
}
},
)
SettingsRow(
label = "last.fm session key",
subLabel = if (settings.lastFmSessionKey.isBlank()) "not configured" else "configured",
onClick = {
menus.showPrompt("last.fm session key", "session key") { value ->
scope.launch { graph.settings.setLastFmSessionKey(value) }
}
},
)
SettingsRow(
label = "flush scrobbles",
subLabel = "send queued plays now",
onClick = { scope.launch { graph.scrobble.flush() } },
)
SectionLabel("about")
SettingsRow(
label = "dorado hd ${BuildConfig.VERSION_NAME}",
@@ -0,0 +1,119 @@
package com.heretek.dorado_hd
import android.net.Uri
import com.heretek.dorado_hd.analysis.AudioAnalysisService
import com.heretek.dorado_hd.analysis.AudioFeatureExtractor
import com.heretek.dorado_hd.data.model.Artist
import com.heretek.dorado_hd.data.model.Track
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AudioAnalysisTest {
private fun track(id: Long, artistId: Long, genre: String, title: String = "T$id") = Track(
mediaId = id,
title = title,
artist = "Artist $artistId",
artistId = artistId,
album = "Album",
albumId = artistId,
genre = genre,
durationMs = 180_000,
dateAdded = 0,
trackNumber = 1,
year = "",
uri = Uri.parse("file:///t$id"),
)
private fun artist(id: Long) = Artist(id, "Artist $id", albumCount = 1, trackCount = 1)
@Test
fun `extractor is deterministic and id-sensitive`() {
val a = track(1, 10, "rock")
val b = track(1, 10, "rock")
assertEquals(AudioFeatureExtractor.extract(a), AudioFeatureExtractor.extract(b))
val different = track(2, 10, "rock")
assertFalse(AudioFeatureExtractor.extract(a) == AudioFeatureExtractor.extract(different))
}
@Test
fun `genre shifts the feature vector`() {
val metal = AudioFeatureExtractor.extract(track(1, 1, "metal"))
val classical = AudioFeatureExtractor.extract(track(2, 2, "classical"))
assertTrue("metal should be more energetic", metal.energy > classical.energy)
assertTrue("classical should be more acoustic", classical.acousticness > metal.acousticness)
assertTrue("metal tempo should be higher", metal.bpm > classical.bpm)
}
@Test
fun `cosine similarity behaves`() {
val v = doubleArrayOf(0.1, 0.2, 0.3, 0.4, 0.5, 0.6)
assertEquals(1.0, AudioAnalysisService.cosine(v, v), 1e-9)
assertEquals(0.0, AudioAnalysisService.cosine(doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), v), 1e-9)
}
@Test
fun `findSimilar ranks the same-genre track first`() {
val service = AudioAnalysisService()
val seed = track(1, 10, "rock")
val rock = track(2, 11, "rock")
val classical = track(3, 12, "classical")
val electronic = track(4, 13, "electronic")
val ranked = service.findSimilar(seed, listOf(seed, rock, classical, electronic), count = 3)
assertEquals(rock.mediaId, ranked.first().mediaId)
assertFalse("seed must be excluded", ranked.any { it.mediaId == seed.mediaId })
}
@Test
fun `findSimilarToFavorites uses the centroid and excludes favorites`() {
val service = AudioAnalysisService()
val favA = track(1, 10, "rock")
val favB = track(2, 11, "rock")
val candidateRock = track(3, 12, "rock")
val candidateClassical = track(4, 13, "classical")
val ranked = service.findSimilarToFavorites(
listOf(favA, favB),
listOf(favA, favB, candidateRock, candidateClassical),
count = 2,
)
assertEquals(candidateRock.mediaId, ranked.first().mediaId)
assertTrue(ranked.none { it.mediaId == favA.mediaId || it.mediaId == favB.mediaId })
}
@Test
fun `findSimilarToFavorites with no favorites falls back deterministically`() {
val service = AudioAnalysisService()
val b = track(1, 10, "rock", title = "bravo")
val a = track(2, 11, "rock", title = "alpha")
val ranked = service.findSimilarToFavorites(emptyList(), listOf(b, a), count = 2)
assertEquals(listOf("alpha", "bravo"), ranked.map { it.title })
}
@Test
fun `relatedArtists excludes the seed and ranks comparable catalogs first`() {
val service = AudioAnalysisService()
val library = listOf(
track(1, artistId = 10, genre = "rock"),
track(2, artistId = 10, genre = "rock"),
track(3, artistId = 11, genre = "rock"),
track(4, artistId = 12, genre = "classical"),
track(5, artistId = 13, genre = "electronic"),
)
val artists = listOf(artist(10), artist(11), artist(12), artist(13))
val related = service.relatedArtists(seedArtistId = 10, artists = artists, library = library, count = 3)
assertEquals("comparable rock catalog should rank first", 11L, related.first().artistId)
assertTrue("seed artist must be excluded", related.none { it.artistId == 10L })
// No seed tracks -> empty (screen then falls back to genre overlap).
assertTrue(service.relatedArtists(99, artists, library).isEmpty())
}
}
@@ -0,0 +1,97 @@
package com.heretek.dorado_hd
import android.content.Context
import android.net.Uri
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.analysis.AudioAnalysisService
import com.heretek.dorado_hd.analysis.AudioFeatureExtractor
import com.heretek.dorado_hd.analysis.AudioFeatures
import com.heretek.dorado_hd.analysis.FeatureAnalyzer
import com.heretek.dorado_hd.analysis.FeatureStore
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.data.repo.RoomFeatureStore
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AudioFeatureStoreTest {
private fun track(id: Long = 1) = Track(
mediaId = id,
title = "T$id",
artist = "A",
artistId = 10,
album = "Al",
albumId = 10,
genre = "rock",
durationMs = 180_000,
dateAdded = 0,
trackNumber = 1,
year = "",
uri = Uri.parse("file:///t$id"),
)
@Test
fun `room store round-trips features`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db = Room.inMemoryDatabaseBuilder(context, DoradoDatabase::class.java)
.allowMainThreadQueries()
.build()
try {
val store = RoomFeatureStore(db.trackFeatureDao())
val features = AudioFeatures(42, 128.0, 0.7, 0.5, 0.2, 0.8, 0.4)
store.save(features)
assertEquals(listOf(features), store.load())
} finally {
db.close()
}
}
@Test
fun `service computes via the analyzer and persists`() = runBlocking {
val saved = mutableListOf<AudioFeatures>()
val store = object : FeatureStore {
override suspend fun load() = emptyList<AudioFeatures>()
override suspend fun save(features: AudioFeatures) { saved += features }
}
var analyzerCalls = 0
val analyzer = FeatureAnalyzer { t -> analyzerCalls++; AudioFeatureExtractor.extract(t) }
val service = AudioAnalysisService(store, analyzer)
val t = track()
service.analyze(t)
service.analyze(t)
assertEquals(2, analyzerCalls)
assertEquals(2, saved.size)
// The cache is now authoritative for sync queries.
assertEquals(saved.last(), service.featuresFor(t))
}
@Test
fun `preload hydrates the cache and analyzeAll skips cached tracks`() = runBlocking {
val t = track()
val stored = AudioFeatureExtractor.extract(t)
var loadCalls = 0
var saveCalls = 0
val store = object : FeatureStore {
override suspend fun load(): List<AudioFeatures> { loadCalls++; return listOf(stored) }
override suspend fun save(features: AudioFeatures) { saveCalls++ }
}
var analyzerCalls = 0
val analyzer = FeatureAnalyzer { tr -> analyzerCalls++; AudioFeatureExtractor.extract(tr) }
val service = AudioAnalysisService(store, analyzer)
service.analyzeAll(listOf(t))
assertEquals("persisted track must not be re-analyzed", 0, analyzerCalls)
assertEquals(0, saveCalls)
assertEquals(1, loadCalls)
assertEquals(stored, service.featuresFor(t))
}
}
@@ -0,0 +1,84 @@
package com.heretek.dorado_hd
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.data.repo.DeviceLinkRepository
import com.heretek.dorado_hd.data.repo.DoradoSettings
import com.heretek.dorado_hd.data.repo.toSyncRuleSettings
import com.heretek.dorado_hd.sync.DeviceContentItem
import com.heretek.dorado_hd.sync.SyncCategoryType
import com.heretek.dorado_hd.sync.SyncEngine
import com.heretek.dorado_hd.sync.SyncMode
import com.heretek.dorado_hd.sync.SyncRulePresets
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DeviceLinkTest {
@Test
fun `sync rule presets cycle and wrap`() {
assertEquals(
SyncRulePresets.MUSIC[1],
SyncRulePresets.next(SyncRulePresets.MUSIC, SyncRulePresets.MUSIC[0]),
)
assertEquals(
SyncRulePresets.MUSIC[0],
SyncRulePresets.next(SyncRulePresets.MUSIC, SyncRulePresets.MUSIC.last()),
)
assertEquals(
SyncRulePresets.MUSIC[0],
SyncRulePresets.next(SyncRulePresets.MUSIC, "unknown label"),
)
}
@Test
fun `settings map to engine rule strings and modes`() {
val settings = DoradoSettings(
musicSyncRule = "Selected Favorites",
podcastSyncRule = "All Unplayed Episodes",
videoSyncRule = "Manual",
picturesSyncRule = "Manual",
)
val rules = settings.toSyncRuleSettings()
assertEquals("Selected Favorites", rules.musicSyncRule)
assertEquals("All Unplayed Episodes", rules.podcastSyncRule)
assertEquals("Manual", rules.videoSyncRule)
assertEquals("Manual", rules.picturesSyncRule)
val group = SyncEngine.buildDefaultGroup("S", rules)
val music = group.categories.first { it.category == SyncCategoryType.MUSIC }
assertEquals(SyncMode.SELECTED_ITEMS, music.mode)
assertTrue(music.preferFavorites)
assertEquals(SyncMode.AUTOMATIC, group.categories.first { it.category == SyncCategoryType.PODCASTS }.mode)
assertEquals(SyncMode.MANUAL, group.categories.first { it.category == SyncCategoryType.VIDEOS }.mode)
}
@Test
fun `pending imports queue and clear`() {
val repo = DeviceLinkRepository(ApplicationProvider.getApplicationContext())
assertTrue(repo.pendingImports.value.isEmpty())
repo.copyBack(DeviceContentItem(SyncCategoryType.MUSIC, "i1", "Song", "/d1", 1000))
repo.copyBack(DeviceContentItem(SyncCategoryType.PICTURES, "i2", "Photo", "/d2", 2000))
assertEquals(2, repo.pendingImports.value.size)
assertEquals("Song", repo.pendingImports.value.first().title)
assertEquals(SyncCategoryType.PICTURES, repo.pendingImports.value.last().category)
repo.clearPending()
assertTrue(repo.pendingImports.value.isEmpty())
}
@Test
fun `device snapshot reflects the simulated target`() {
val repo = DeviceLinkRepository(ApplicationProvider.getApplicationContext())
val snap = repo.snapshot()
assertEquals(repo.transport.contents().size, snap.contents.size)
assertEquals(repo.transport.totalCapacityBytes, snap.totalCapacityBytes)
assertTrue(snap.systemBytes > 0)
assertTrue("seeded Zune HD target should have stale content", snap.contents.isNotEmpty())
}
}
@@ -0,0 +1,103 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.sync.DeviceSnapshot
import com.heretek.dorado_hd.sync.SimulatedDeviceTransport
import com.heretek.dorado_hd.sync.SyncCategoryRule
import com.heretek.dorado_hd.sync.SyncCategoryType
import com.heretek.dorado_hd.sync.SyncEngine
import com.heretek.dorado_hd.sync.SyncGroup
import com.heretek.dorado_hd.sync.SyncInput
import com.heretek.dorado_hd.sync.SyncMode
import com.heretek.dorado_hd.sync.SyncProtocol
import com.heretek.dorado_hd.sync.SyncTrack
import com.heretek.dorado_hd.sync.TransferAction
import com.heretek.dorado_hd.sync.TransferItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceTransportTest {
private fun musicOnlyGroup(serial: String = "S") = SyncGroup(
serial,
categories = listOf(SyncCategoryRule(SyncCategoryType.MUSIC, SyncMode.AUTOMATIC)),
)
private fun snapshotOf(t: SimulatedDeviceTransport) =
DeviceSnapshot(t.totalCapacityBytes, t.systemBytes, t.contents())
@Test
fun `simulator seeds only large devices, like the desktop`() {
val big = SimulatedDeviceTransport("S", "Zune HD", 32L * 1024 * 1024 * 1024)
assertEquals(5, big.contents().size)
val tiny = SimulatedDeviceTransport("S", "Tiny", 11L * 1024 * 1024)
assertTrue(tiny.contents().isEmpty())
}
@Test
fun `used and free bytes include the system partition`() {
val t = SimulatedDeviceTransport("S", "Zune HD", 32L * 1024 * 1024 * 1024)
assertEquals(t.systemBytes + t.contents().sumOf { it.sizeBytes }, t.usedBytes)
assertEquals(t.totalCapacityBytes - t.usedBytes, t.freeBytes)
}
@Test
fun `copy and remove track free space`() {
val t = SimulatedDeviceTransport("S", "Zune HD", 32L * 1024 * 1024 * 1024)
val before = t.freeBytes
val item = TransferItem(TransferAction.ADD, SyncCategoryType.MUSIC, "id1", "Song", "/s", 10L * 1024 * 1024)
t.copyToDevice(item)
assertEquals(before - 10L * 1024 * 1024, t.freeBytes)
t.removeFromDevice(t.tryGetItem("id1")!!)
assertEquals(before, t.freeBytes)
}
@Test
fun `applyPlan removes stale content then adds the new track`() {
val transport = SimulatedDeviceTransport("S", "Zune HD", 32L * 1024 * 1024 * 1024)
val staleTitle = transport.contents().first { it.category == SyncCategoryType.MUSIC }.title
val track = SyncTrack("new1", "New Song", "A", "Al", 1, "/n", Rating.HEART)
val plan = SyncEngine.buildPlan(
musicOnlyGroup(),
SyncInput(tracks = listOf(track)),
snapshotOf(transport),
)
assertTrue(plan.items.any { it.action == TransferAction.REMOVE })
val progress = mutableListOf<Double>()
SyncEngine.applyPlan(plan, transport) { progress += it }
val music = transport.contents().filter { it.category == SyncCategoryType.MUSIC }
assertFalse(music.any { it.title == staleTitle })
assertTrue(music.any { it.title == "New Song" })
assertTrue(progress.isNotEmpty())
assertTrue(progress.all { it in 0.0..1.0 })
assertEquals(1.0, progress.last(), 1e-9)
}
@Test
fun `keep items are left untouched by applyPlan`() {
val transport = SimulatedDeviceTransport("S", "Tiny", 11L * 1024 * 1024)
transport.copyToDevice(TransferItem(TransferAction.ADD, SyncCategoryType.MUSIC, "k1", "Kept", "/k", 16000))
val track = SyncTrack("k1", "Kept", "A", "Al", 1, "/k", Rating.HEART)
val plan = SyncEngine.buildPlan(musicOnlyGroup(), SyncInput(tracks = listOf(track)), snapshotOf(transport))
assertEquals(1, plan.keepCount)
SyncEngine.applyPlan(plan, transport)
assertNotNull(transport.tryGetItem("k1"))
}
@Test
fun `pairing code is always six digits`() {
repeat(100) {
val code = SyncProtocol.newPairingCode()
assertEquals(6, code.length)
assertTrue(code.all { it.isDigit() })
}
}
}
@@ -0,0 +1,138 @@
package com.heretek.dorado_hd
import android.net.Uri
import com.heretek.dorado_hd.analysis.AudioAnalysisService
import com.heretek.dorado_hd.analysis.DynamicMix
import com.heretek.dorado_hd.analysis.DynamicMixKind
import com.heretek.dorado_hd.analysis.DynamicMixService
import com.heretek.dorado_hd.data.model.Track
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DynamicMixTest {
private fun track(
id: Long,
artistId: Long,
genre: String,
title: String = "T$id",
albumId: Long = artistId,
artist: String = "Artist $artistId",
) = Track(
mediaId = id,
title = title,
artist = artist,
artistId = artistId,
album = "Album $albumId",
albumId = albumId,
genre = genre,
durationMs = 180_000,
dateAdded = 0,
trackNumber = 1,
year = "",
uri = Uri.parse("file:///t$id"),
)
private fun service() = DynamicMixService(AudioAnalysisService())
@Test
fun `top played orders by count then title, and degrades to title order`() {
val a = track(1, 10, "rock", title = "bravo")
val b = track(2, 11, "rock", title = "alpha")
val c = track(3, 12, "rock", title = "charlie")
val lib = listOf(a, b, c)
val ranked = service().materialize(
DynamicMix("top", DynamicMixKind.TOP_PLAYED, trackLimit = 3),
lib,
playCounts = mapOf(1L to 5, 2L to 9, 3L to 0),
)
assertEquals(listOf(2L, 1L, 3L), ranked.map { it.mediaId })
val byTitle = service().materialize(
DynamicMix("top", DynamicMixKind.TOP_PLAYED, trackLimit = 3),
lib,
)
assertEquals(listOf("alpha", "bravo", "charlie"), byTitle.map { it.title })
}
@Test
fun `similar to track ranks comparable tracks and excludes the seed`() {
val seed = track(1, 10, "rock")
val rock = track(2, 11, "rock")
val classical = track(3, 12, "classical")
val lib = listOf(seed, rock, classical)
val mix = service().materialize(
DynamicMix("mix", DynamicMixKind.SIMILAR_TO_TRACK, seedId = 1, trackLimit = 3),
lib,
)
assertEquals(rock.mediaId, mix.first().mediaId)
assertTrue("seed excluded", mix.none { it.mediaId == seed.mediaId })
}
@Test
fun `similar to album uses a sibling track as representative`() {
val seed = track(1, 10, "rock", albumId = 50)
val sibling = track(2, 10, "rock", albumId = 50)
val other = track(3, 11, "classical", albumId = 60)
val lib = listOf(seed, sibling, other)
val mix = service().materialize(
DynamicMix("album mix", DynamicMixKind.SIMILAR_TO_ALBUM, seedId = 1, trackLimit = 3),
lib,
)
assertTrue("mix should be non-empty", mix.isNotEmpty())
assertTrue("mix capped at limit", mix.size <= 3)
}
@Test
fun `similar to favorites delegates and excludes favorites`() {
val favA = track(1, 10, "rock")
val favB = track(2, 11, "rock")
val candidate = track(3, 12, "rock")
val classical = track(4, 13, "classical")
val lib = listOf(favA, favB, candidate, classical)
val mix = service().materialize(
DynamicMix("fav mix", DynamicMixKind.SIMILAR_TO_FAVORITES, trackLimit = 2),
lib,
favorites = listOf(favA, favB),
)
assertEquals(candidate.mediaId, mix.first().mediaId)
assertTrue(mix.none { it.mediaId == favA.mediaId || it.mediaId == favB.mediaId })
}
@Test
fun `artist mix filters by artist and needs seed text`() {
val lib = listOf(
track(1, 10, "rock", artist = "The Cure"),
track(2, 11, "rock", artist = "The Cure"),
track(3, 12, "classical", artist = "Bach"),
)
val mix = service().materialize(
DynamicMix("artist mix", DynamicMixKind.PLAYLISTS_INCLUDING_ARTIST, seedText = "cure", trackLimit = 10),
lib,
)
assertEquals(listOf(1L, 2L), mix.map { it.mediaId })
assertTrue(
service().materialize(
DynamicMix("artist mix", DynamicMixKind.PLAYLISTS_INCLUDING_ARTIST, seedText = " "),
lib,
).isEmpty(),
)
}
@Test
fun `empty library yields no mix and defaults include favorites`() {
assertTrue(
service().materialize(DynamicMix("x", DynamicMixKind.TOP_PLAYED), emptyList()).isEmpty(),
)
assertTrue(service().buildDefaultMixes().any { it.kind == DynamicMixKind.SIMILAR_TO_FAVORITES })
}
}
@@ -0,0 +1,87 @@
package com.heretek.dorado_hd
import android.net.Uri
import com.heretek.dorado_hd.analysis.AudioFeatureExtractor
import com.heretek.dorado_hd.analysis.FeatureMath
import com.heretek.dorado_hd.data.model.Track
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.math.PI
import kotlin.math.sin
@RunWith(RobolectricTestRunner::class)
class FeatureMathTest {
private val sampleRate = 44_100
private fun sine(freqHz: Double, samples: Int = 4096, amplitude: Float = 0.8f): FloatArray =
FloatArray(samples) { (amplitude * sin(2.0 * PI * freqHz * it / sampleRate)).toFloat() }
private fun track(id: Long = 1, genre: String = "rock") = Track(
mediaId = id,
title = "T$id",
artist = "A",
artistId = 10,
album = "Al",
albumId = 10,
genre = genre,
durationMs = 180_000,
dateAdded = 0,
trackNumber = 1,
year = "",
uri = Uri.parse("file:///t$id"),
)
@Test
fun `rms matches the signal amplitude`() {
val constant = FloatArray(1024) { 0.5f }
assertEquals(0.5, FeatureMath.rms(constant), 1e-6)
assertEquals(0.0, FeatureMath.rms(FloatArray(0)), 1e-6)
}
@Test
fun `fft finds the dominant frequency of a sine`() {
val freq = FeatureMath.dominantFrequency(sine(1000.0), sampleRate)
assertEquals(1000.0, freq, 30.0)
}
@Test
fun `spectral centroid of a pure tone tracks its frequency`() {
val low = FeatureMath.spectralCentroidNormalized(sine(500.0), sampleRate)
val high = FeatureMath.spectralCentroidNormalized(sine(3000.0), sampleRate)
assertTrue("higher tone should have higher centroid", high > low)
assertEquals(1000.0 / (sampleRate / 2.0), FeatureMath.spectralCentroidNormalized(sine(1000.0), sampleRate), 0.02)
}
@Test
fun `zero crossing rate rises with frequency`() {
val low = FeatureMath.zeroCrossingRate(sine(200.0))
val high = FeatureMath.zeroCrossingRate(sine(4000.0))
assertTrue(high > low)
}
@Test
fun `loudness is monotonic and silent at zero`() {
assertEquals(0.0, FeatureMath.loudness(0.0), 1e-9)
assertTrue(FeatureMath.loudness(0.5) > FeatureMath.loudness(0.05))
}
@Test
fun `pcm extractor raises energy for louder audio`() {
val quiet = AudioFeatureExtractor.fromPcm(track(), sine(1000.0, amplitude = 0.02f), sampleRate)
val loud = AudioFeatureExtractor.fromPcm(track(), sine(1000.0, amplitude = 0.9f), sampleRate)
assertTrue("louder window should read more energetic", loud.energy > quiet.energy)
}
@Test
fun `pcm extractor tracks brightness and falls back on empty input`() {
val dark = AudioFeatureExtractor.fromPcm(track(), sine(300.0), sampleRate)
val bright = AudioFeatureExtractor.fromPcm(track(), sine(6000.0), sampleRate)
assertTrue(bright.spectralCentroid > dark.spectralCentroid)
assertEquals(AudioFeatureExtractor.extract(track()), AudioFeatureExtractor.fromPcm(track(), FloatArray(0), sampleRate))
}
}
@@ -85,8 +85,21 @@ class LogicTest {
@Test
fun `pin kinds cover quickplay surfaces`() {
assertTrue(PinKind.TRACK.name == "TRACK")
// TRACK / ALBUM / ARTIST / PLAYLIST (canonical 4) + PICTURE / RADIO (Phase 5).
assertEquals(6, PinKind.entries.size)
// TRACK / ALBUM / ARTIST / PLAYLIST (canonical 4) + PICTURE / RADIO (Phase 5)
// + GENRE / VIDEO / APP / PODCAST / EPISODE (M4 pin-completeness pass).
assertEquals(11, PinKind.entries.size)
assertTrue(
"M4 pin-completeness must cover every Quickplay surface",
PinKind.entries.map { it.name }.containsAll(
listOf(
"TRACK", "ALBUM", "ARTIST", "PLAYLIST", "PICTURE", "RADIO",
"GENRE", "VIDEO", "APP", "PODCAST", "EPISODE",
),
),
)
// String-keyed surfaces get a deterministic refId so pin/unpin round-trips.
assertEquals(PinKind.stableId("Rock"), PinKind.stableId("rock"))
assertTrue(PinKind.stableId("Rock") != PinKind.stableId("Jazz"))
}
/* ============ Phase 1 / Smart DJ ============ */
@@ -0,0 +1,34 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.net.LrcLibParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class LyricsTest {
@Test
fun `parses plain and synced lyrics`() {
val json = """{"plainLyrics":"hello\nworld","syncedLyrics":"[00:01.00]hello\n[00:02.00]world"}"""
val lyrics = LrcLibParser.parse(json)!!
assertEquals("hello\nworld", lyrics.plain)
assertTrue(lyrics.synced!!.contains("hello"))
}
@Test
fun `returns null when both lyric fields are empty or malformed`() {
assertNull(LrcLibParser.parse("""{"plainLyrics":"","syncedLyrics":"null"}"""))
assertNull(LrcLibParser.parse("not json at all"))
assertNull(LrcLibParser.parse("{}"))
}
@Test
fun `plain from synced strips LRC timestamps`() {
val synced = "[00:01.00]first line\n[00:12.50]second line\n[00:20.00]"
assertEquals("first line\nsecond line", LrcLibParser.plainFromSynced(synced))
}
}
@@ -0,0 +1,114 @@
package com.heretek.dorado_hd
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.repo.RoomScrobbleStore
import com.heretek.dorado_hd.scrobble.LastFmSignature
import com.heretek.dorado_hd.scrobble.PendingScrobble
import com.heretek.dorado_hd.scrobble.Scrobble
import com.heretek.dorado_hd.scrobble.ScrobbleService
import com.heretek.dorado_hd.scrobble.ScrobbleSink
import com.heretek.dorado_hd.scrobble.ScrobbleStore
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ScrobbleTest {
private class FakeStore : ScrobbleStore {
val rows = mutableListOf<PendingScrobble>()
private var nextId = 1L
override suspend fun enqueue(scrobble: Scrobble) {
rows += PendingScrobble(nextId++, scrobble)
}
override suspend fun pending(limit: Int): List<PendingScrobble> = rows.take(limit)
override suspend fun delete(ids: List<Long>) {
rows.removeAll { it.id in ids }
}
override suspend fun count(): Int = rows.size
}
@Test
fun `md5 matches a known vector`() {
assertEquals("900150983cd24fb0d6963f7d28e17f72", LastFmSignature.md5Hex("abc"))
}
@Test
fun `signature is order independent and excludes format`() {
val a = LastFmSignature.apiSignature(mapOf("b" to "2", "a" to "1", "format" to "json"), "secret")
val b = LastFmSignature.apiSignature(mapOf("a" to "1", "b" to "2"), "secret")
assertEquals(a, b)
assertNotEquals(a, LastFmSignature.apiSignature(mapOf("a" to "1", "b" to "2"), "other-secret"))
}
@Test
fun `record queues when enabled and rejects unusable fields`() = runBlocking {
val store = FakeStore()
var enabled = true
val service = ScrobbleService(store, ScrobbleSink { true }, enabled = { enabled }, nowSec = { 1_000L })
assertTrue(service.record("A", "T", "Al", 200))
assertEquals(1, store.count())
assertFalse(service.record("", "T", "Al", 200))
assertFalse(service.record("A", " ", "Al", 200))
enabled = false
assertFalse(service.record("A", "T2", "Al", 200))
assertEquals(1, store.count())
}
@Test
fun `flush preserves order and stops at the first failure`() = runBlocking {
val store = FakeStore()
val sent = mutableListOf<String>()
var accept = true
val sink = ScrobbleSink { s -> if (accept) { sent += s.title; true } else false }
val service = ScrobbleService(store, sink, enabled = { true }, nowSec = { 1_000L })
service.record("A", "one", "", 1)
service.record("A", "two", "", 1)
service.record("A", "three", "", 1)
accept = false
assertEquals(0, service.flush())
assertEquals(3, store.count())
accept = true
assertEquals(3, service.flush())
assertEquals(0, store.count())
assertEquals(listOf("one", "two", "three"), sent)
}
@Test
fun `room scrobble store round-trips`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db = Room.inMemoryDatabaseBuilder(context, DoradoDatabase::class.java)
.allowMainThreadQueries()
.build()
try {
val store = RoomScrobbleStore(db.scrobbleDao())
store.enqueue(Scrobble("A", "T", "Al", 200, 1234))
val pending = store.pending(10)
assertEquals(1, pending.size)
assertEquals("T", pending.first().scrobble.title)
assertEquals(1234L, pending.first().scrobble.timestampSec)
store.delete(listOf(pending.first().id))
assertEquals(0, store.count())
} finally {
db.close()
}
}
}
@@ -0,0 +1,235 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.sync.DeviceContentItem
import com.heretek.dorado_hd.sync.DeviceSnapshot
import com.heretek.dorado_hd.sync.SyncCategoryType
import com.heretek.dorado_hd.sync.SyncEngine
import com.heretek.dorado_hd.sync.SyncGroup
import com.heretek.dorado_hd.sync.SyncInput
import com.heretek.dorado_hd.sync.SyncMode
import com.heretek.dorado_hd.sync.SyncPhoto
import com.heretek.dorado_hd.sync.SyncPlan
import com.heretek.dorado_hd.sync.SyncPodcastEpisode
import com.heretek.dorado_hd.sync.SyncRuleSettings
import com.heretek.dorado_hd.sync.SyncTrack
import com.heretek.dorado_hd.sync.SyncVideo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Golden tests for the Kotlin `SyncEngine` mirror. The expected plan strings
* were generated by running the sibling Dorado desktop engine
* (`Dorado.Application.Services.SyncEngine`) against the identical fixtures —
* see the M8.1 harness. They lock in the exact ordering, keep/add/remove split
* and capacity truncation.
*/
class SyncEngineTest {
private fun g(n: Int) = "00000000-0000-0000-0000-%012d".format(n)
private fun tracks() = listOf(
SyncTrack(g(1), "Delta", "A", "Al", 1, "/t1", Rating.HEART),
SyncTrack(g(2), "Alpha", "B", "Al", 2, "/t2", Rating.NONE),
SyncTrack(g(3), "Charlie", "C", "Al", 3, "/t3", Rating.HEART),
SyncTrack(g(4), "Bravo", "D", "Al", 4, "/t4", Rating.BROKEN),
SyncTrack(g(5), "Echo", "E", "Al", 5, "/t5", Rating.HEART),
)
private fun episodes() = listOf(
SyncPodcastEpisode(g(11), "Ep Old", "S1", 1577836800L, 1, "/p1", false),
SyncPodcastEpisode(g(12), "Ep New", "S1", 1704067200L, 2, "/p2", false),
SyncPodcastEpisode(g(13), "Ep Mid", "S1", 1640995200L, 3, "/p3", false),
SyncPodcastEpisode(g(14), "Ep Played", "S1", 1735689600L, 4, "/p4", true),
)
private fun videos() = listOf(
SyncVideo(g(21), "Vid A", "/v1", 100000, 1609459200L),
SyncVideo(g(22), "Vid B", "/v2", 200000, 1672531200L),
)
private fun photos() = listOf(
SyncPhoto(g(31), "Pho A", "/ph1", "F1", 50000, 1577836800L),
SyncPhoto(g(32), "Pho B", "/ph2", "F1", 60000, 1640995200L),
SyncPhoto(g(33), "Pho C", "/ph3", "F2", 70000, 1704067200L),
)
private fun input() = SyncInput(tracks(), videos(), photos(), episodes())
private fun device() = DeviceSnapshot(
totalCapacityBytes = 300000,
systemBytes = 0,
contents = listOf(
DeviceContentItem(SyncCategoryType.MUSIC, g(1), "Delta", "/d1", 16000),
DeviceContentItem(SyncCategoryType.MUSIC, g(99), "Stale Song", "/d9", 9000),
DeviceContentItem(SyncCategoryType.PICTURES, g(98), "Stale Photo", "/d8", 7000),
DeviceContentItem(SyncCategoryType.VIDEOS, g(21), "Vid A", "/d2", 100000),
DeviceContentItem(SyncCategoryType.PODCASTS, g(97), "Stale Ep", "/d7", 5000),
),
)
private fun settings() = SyncRuleSettings(
musicSyncRule = "Selected Favorites",
podcastSyncRule = "3 Newest Episodes",
videoSyncRule = "All Videos & Pictures",
picturesSyncRule = "Newest 25 Items",
)
/** Canonical rendering that matches the desktop harness output exactly. */
private fun canonical(plan: SyncPlan): String = buildString {
append("group serial=${plan.deviceSerialNumber} guest=${plan.isGuestSession}")
plan.items.forEach { item ->
append('\n')
append("ITEM ${item.action} ${item.category} ${item.entityId} bytes=${item.sizeBytes} ")
append("title=${item.title} detail=${item.detail ?: ""}")
}
append('\n')
append(
"counts add=${plan.addCount} remove=${plan.removeCount} keep=${plan.keepCount} " +
"addBytes=${plan.totalAddBytes} removeBytes=${plan.totalRemoveBytes}",
)
}
private val goldenA = """
group serial=SERIAL guest=false
ITEM REMOVE MUSIC 00000000-0000-0000-0000-000000000099 bytes=9000 title=Stale Song detail=No longer in sync group
ITEM REMOVE PICTURES 00000000-0000-0000-0000-000000000098 bytes=7000 title=Stale Photo detail=No longer in sync group
ITEM REMOVE PODCASTS 00000000-0000-0000-0000-000000000097 bytes=5000 title=Stale Ep detail=No longer in sync group
ITEM ADD MUSIC 00000000-0000-0000-0000-000000000003 bytes=48000 title=Charlie detail=C — Al
ITEM KEEP MUSIC 00000000-0000-0000-0000-000000000001 bytes=16000 title=Delta detail=
ITEM ADD MUSIC 00000000-0000-0000-0000-000000000005 bytes=80000 title=Echo detail=E — Al
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000012 bytes=32000 title=Ep New detail=S1
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000011 bytes=16000 title=Ep Old detail=S1
ITEM KEEP VIDEOS 00000000-0000-0000-0000-000000000021 bytes=100000 title=Vid A detail=
counts add=4 remove=3 keep=2 addBytes=176000 removeBytes=21000
""".trimIndent()
private val goldenB = """
group serial=SERIAL guest=true
ITEM ADD MUSIC 00000000-0000-0000-0000-000000000003 bytes=48000 title=Charlie detail=C — Al
ITEM KEEP MUSIC 00000000-0000-0000-0000-000000000001 bytes=16000 title=Delta detail=
ITEM ADD MUSIC 00000000-0000-0000-0000-000000000005 bytes=80000 title=Echo detail=E — Al
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000012 bytes=32000 title=Ep New detail=S1
ITEM KEEP VIDEOS 00000000-0000-0000-0000-000000000021 bytes=100000 title=Vid A detail=
counts add=3 remove=0 keep=2 addBytes=160000 removeBytes=0
""".trimIndent()
private val goldenC = """
group serial=SERIAL guest=false
ITEM REMOVE PICTURES 00000000-0000-0000-0000-000000000098 bytes=7000 title=Stale Photo detail=No longer in sync group
ITEM REMOVE PODCASTS 00000000-0000-0000-0000-000000000097 bytes=5000 title=Stale Ep detail=No longer in sync group
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000012 bytes=32000 title=Ep New detail=S1
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000013 bytes=48000 title=Ep Mid detail=S1
ITEM ADD PODCASTS 00000000-0000-0000-0000-000000000011 bytes=16000 title=Ep Old detail=S1
ITEM KEEP VIDEOS 00000000-0000-0000-0000-000000000021 bytes=100000 title=Vid A detail=
ITEM ADD PICTURES 00000000-0000-0000-0000-000000000033 bytes=70000 title=Pho C detail=F2
counts add=4 remove=2 keep=1 addBytes=166000 removeBytes=12000
""".trimIndent()
@Test
fun `plan matches the desktop engine golden`() {
val group = SyncEngine.buildDefaultGroup("SERIAL", settings())
val plan = SyncEngine.buildPlan(group, input(), device())
assertEquals(goldenA, canonical(plan))
}
@Test
fun `guest session is add-only and matches the desktop golden`() {
val group = SyncEngine.buildDefaultGroup("SERIAL", settings(), isGuestSession = true)
val plan = SyncEngine.buildPlan(group, input(), device())
assertEquals(goldenB, canonical(plan))
assertEquals(0, plan.removeCount)
}
@Test
fun `manual music is neither added nor removed and matches the desktop golden`() {
val manual = settings().copy(musicSyncRule = "Manual")
val group = SyncEngine.buildDefaultGroup("SERIAL", manual)
val plan = SyncEngine.buildPlan(group, input(), device())
assertEquals(goldenC, canonical(plan))
assertTrue(plan.items.none { it.category == SyncCategoryType.MUSIC })
}
@Test
fun `default group derives modes from rule strings like the desktop`() {
val automatic = SyncEngine.buildDefaultGroup(
"SERIAL",
SyncRuleSettings(
musicSyncRule = "All Music (Automatic Sync)",
podcastSyncRule = "All Unplayed Episodes",
videoSyncRule = "All Videos & Pictures",
picturesSyncRule = "All Pictures",
),
)
assertEquals(SyncMode.AUTOMATIC, automatic.rule(SyncCategoryType.MUSIC).mode)
assertFalse(automatic.rule(SyncCategoryType.MUSIC).preferFavorites)
assertEquals(SyncMode.AUTOMATIC, automatic.rule(SyncCategoryType.PODCASTS).mode)
assertNull(automatic.rule(SyncCategoryType.PODCASTS).newestCount)
assertEquals(SyncMode.AUTOMATIC, automatic.rule(SyncCategoryType.VIDEOS).mode)
assertEquals(SyncMode.AUTOMATIC, automatic.rule(SyncCategoryType.PICTURES).mode)
val selected = SyncEngine.buildDefaultGroup(
"SERIAL",
SyncRuleSettings(
musicSyncRule = "Selected Items",
podcastSyncRule = "3 Newest Episodes",
videoSyncRule = "Selected Items",
picturesSyncRule = "5 Newest",
),
)
assertEquals(SyncMode.SELECTED_ITEMS, selected.rule(SyncCategoryType.MUSIC).mode)
assertTrue(selected.rule(SyncCategoryType.MUSIC).preferFavorites)
assertEquals(3, selected.rule(SyncCategoryType.PODCASTS).newestCount)
assertEquals(SyncMode.SELECTED_ITEMS, selected.rule(SyncCategoryType.VIDEOS).mode)
assertNull(selected.rule(SyncCategoryType.VIDEOS).newestCount)
assertEquals(5, selected.rule(SyncCategoryType.PICTURES).newestCount)
val manual = SyncEngine.buildDefaultGroup(
"SERIAL",
SyncRuleSettings(
musicSyncRule = "Sync Manually",
podcastSyncRule = "Sync Manually",
videoSyncRule = "Nothing",
picturesSyncRule = "Newest 25 Items",
),
)
assertEquals(SyncMode.MANUAL, manual.rule(SyncCategoryType.MUSIC).mode)
assertEquals(SyncMode.MANUAL, manual.rule(SyncCategoryType.PODCASTS).mode)
assertEquals(SyncMode.MANUAL, manual.rule(SyncCategoryType.VIDEOS).mode)
assertEquals(SyncMode.MANUAL, manual.rule(SyncCategoryType.PICTURES).mode)
assertNull(manual.rule(SyncCategoryType.PICTURES).newestCount)
}
@Test
fun `parity details match the desktop`() {
// Pictures inherit the video rule's mode.
val inherited = SyncEngine.buildDefaultGroup(
"SERIAL",
SyncRuleSettings(videoSyncRule = "Selected Items", picturesSyncRule = "All Pictures"),
)
assertEquals(SyncMode.SELECTED_ITEMS, inherited.rule(SyncCategoryType.PICTURES).mode)
// The count is the first digit run anywhere: "Newest 25 Items" → 25.
val counted = SyncEngine.buildDefaultGroup(
"SERIAL",
SyncRuleSettings(videoSyncRule = "Selected Items", picturesSyncRule = "Newest 25 Items"),
)
assertEquals(25, counted.rule(SyncCategoryType.PICTURES).newestCount)
// REMOVE items carry the device item's size, so stale content frees
// capacity for the adds that follow.
val plan = SyncEngine.buildPlan(
SyncEngine.buildDefaultGroup("SERIAL", settings()),
input(),
device(),
)
assertTrue(plan.removeCount > 0)
assertEquals(21000L, plan.totalRemoveBytes)
}
private fun SyncGroup.rule(category: SyncCategoryType) =
categories.first { it.category == category }
}
+237
View File
@@ -0,0 +1,237 @@
# Dorado-HD Parity Roadmap
Planning artifact for the next parity passes. Mirrors the structure of the
sibling [dorado](https://github.com/project-dorado/dorado) `docs/parity/`
corpus. Every gap below is backed by a code location so the roadmap stays
falsifiable.
Ground rules (AGENTS.md): the canon is law — change
`docs/zune-hd-ui-canon.md` first with a source citation, then the code.
`DesignInvariantTest` audits zero corner radius, no Material chrome leakage,
hearts-not-stars, and the motion rules. Features the Zune HD never shipped
must be labelled **post-device extension** (canon §10), never canon.
## 1. Verified baseline
A code-verified snapshot (not README claims):
| Surface | State | Anchor |
|---|---|---|
| Home (9 entries, kinetic, edge-crop) | ✅ | `ui/screens/HomeScreens.kt`, `design/components/KineticList.kt` |
| Quickplay (3D parallax, Smart DJ lane) | ✅ | `ui/screens/HomeScreens.kt` |
| Crossbar pivots (music/artist/marketplace/videos/pictures/podcasts/radio) | ✅ | `design/components/CrossbarBar.kt` |
| Now Playing (scrub, transport overlay, swipe-skip, screensaver) | ✅ | `ui/screens/NowPlayingScreen.kt` |
| Tri-state heart rating | ✅ | `media/PlaybackController.kt` (`setRating`) |
| Lock/wake shade (WallpaperManager) | ✅ | `ui/DoradoRoot.kt:115`, `design/components/LockShade.kt` |
| 9 media pivots + Internet + Social(mock) | ✅ | `ui/nav/DoradoNav.kt:14-37` |
| 29 mini-apps (12 util + 9 games + 8 mocks) | ✅ | `ui/apps/DoradoApps.kt` |
| Frozen 62-entry marketplace catalog | ✅ | `data/official/OfficialCatalog.kt` |
| Design-invariant scanner | ✅ | `app/src/test/.../DesignInvariantTest.kt` |
## 2. Parity axes
1. **On-device parity** — every behavior the Zune HD itself had (widget,
lock screen, the M4–M7 wishlist).
2. **Cross-project parity** — interop with the sister desktop `dorado`
(Wi-Fi sync, and eventually physical Zune HD over USB).
3. **Modern parity** — what great Zune-successor projects solved that the
2009 device could not (dynamic mixes, on-device recommendation,
scrobbling, lyrics).
## 3. Verified gap inventory
| Gap | Evidence (current code) | Axis | Effort |
|---|---|---|---|
| Home-screen widget | no `appwidget`/Glance; `AndroidManifest.xml` has no receiver | device | M |
| Wi-Fi sync with Dorado | no `sync/` package; desktop already has `SyncModels.cs`, `SyncEngine.BuildPlan`, `IDeviceTransport` + `SimulatedDeviceTransport` | cross-project | L |
| USB MTP/MTPZ to a physical Zune HD | stretch; spec is in `dorado/.agents/skills/zune-hardware-sync` (MTPZ handshake, F-marker ZMDB, PPP interceptor `0x922C/0x922D`, host `192.168.55.100` / device `.101`) | cross-project | XL |
| Long-press → pin incomplete | was `onLongClick = {}` across `DetailScreens`/`MediaScreens`/`MusicScreen`/`Podcasts`/`MarketplaceScreens`; now pin + shared `trackMenuActions` | device (M4) | ✅ done |
| Play-next / Quicklist action | was absent; now `trackMenuActions` adds play-next to every track row | device (M4) | ✅ done |
| Marketplace curated pivots | was `EmptyPivot("videos","coming soon")`; now library-backed videos/podcasts | device (M4) | ✅ done |
| Games pivot in Marketplace | was absent; now a 5th crossbar pivot over installed + frozen catalog games | device (M4) | ✅ done |
| Share / Zune-Card artwork export | no `ACTION_SEND` anywhere | device (M5) | S |
| EQ presets / crossfade | 0 matches; `PlaybackController` has shuffle/repeat/rating only | modern (M6) | M |
| Related-artists pivot is a thin empty-state | honest text at `DetailScreens.kt:321-335` ("only artists who share genres… a thin slice") | modern | M |
| Scrobbling / lyrics / tag editor | 0 matches; `dorado` already uses Last.fm + LRCLIB | modern | M |
| Live-radio pause-and-cache | `Radio.kt` dial/presets only | device (M6) | M |
| Canon drift | §3.1 still says only `music`/`settings` functional; §5 still calls the in-app shade a stretch goal | doc | XS |
## 4. Milestones
### M4 — Mini-app + marketplace completeness ✅ *complete*
- Wire `onLongClick → QuickplayRepository.pin(...)` at every empty list site
(Videos, Pictures, podcast feeds + episodes, marketplace albums + installed
apps, artist → songs, playlist detail, genre). Picture buckets and buttons
had no meaningful action, so their dead `onLongClick` was removed instead.
- Add **play-next** to the long-press context menu (the Quicklist
replacement); back it with `PlaybackController.enqueue` (`PlaybackController.kt:132`).
- Replace `EmptyPivot("coming soon")` in Marketplace with curated slices of
the user's library.
- Add a `games` pivot using `OfficialCatalog.GAMES`.
- **Accept:** no empty `onLongClick = {}` remains under `ui/screens/`
(mini-app internals are out of scope); track rows use the shared
`trackMenuActions` (pin + play-next); the PinKind coverage test names every
Quickplay surface.
### M5 — Social + discovery
- Now Playing system share sheet (`ACTION_SEND`, artist + title + album).
- Zune-Card-style artwork export (PNG/JPEG).
- On-device podcast search (curated top charts).
- Wire Smart DJ as a tap-to-build-mix action in the music crossbar.
### M6 — Community wishlist
- EQ presets (rock / acoustic / hip-hop / pop / classical / electronic).
- Crossfade on `play()` (settings toggle).
- Live-radio pause-and-cache ("Live / Rewind ±5 min") — capability-gated on a
real-time streaming source.
- Lock-screen Now Playing art + controls (richer than the default Media3
notification).
- Soft keyboard "bulge around the finger" affordance (Wikipedia-cited).
### M7 — Design-token + invariant-test hygiene
- Tokenize remaining raw dp sizes.
- Unit tests for `KineticList` A–Z popup, `HomePages` parallax math,
`StaggerEntrance` clamp.
- Keep `DesignInvariantTest` catching named `Color.Black/White/…`.
### M8 — Device Link (Wi-Fi sync with Dorado) — *TODO (8.1–8.3 landed, 8.2b/8.4 pending)*
Mirror the desktop Phase 9 semantics on the phone side, 1:1 with
`Dorado.Domain/Models/SyncModels.cs`:
- ✅ **M8.1 — pure-JVM core.** New `sync/` package (`SyncModels.kt`,
`SyncEngine.kt`): `SyncMode`, `SyncCategoryRule`, `SyncGroup`, `SyncPlan`,
`TransferItem`, `DeviceSnapshot` matching the C# contract, plus
`buildDefaultGroup()` and `buildPlan()`. `SyncEngineTest` locks the output
against goldens generated from the real desktop engine (a throwaway C#
harness referencing `Dorado.Application`).
- ✅ **M8.2 — transport core.** `DeviceTransport` interface +
`SimulatedDeviceTransport` (mirrors the desktop simulator: 32 GB seed,
system-partition accounting, category/title ordering) + `SyncEngine.applyPlan`
(removals first, progress reporting), all unit-tested. `SyncProtocol` holds
the JSON-RPC 2.0 contract constants and the 6-digit pairing-code generator.
- ⬜ **M8.2b — live transport.** mDNS (`NsdManager`, `_dorado-sync._tcp`), TLS
socket, pairing handshake — blocked on a desktop-side sync endpoint.
- ✅ **M8.3 — Device view.** `DeviceScreen` (Settings → device link): pairing
state + 6-digit code, storage gas gauge, the four sync rules (tap to cycle
presets), guest-session toggle, dry-run "what will sync" manifest,
apply-to-device, and reverse copy-back → pending-imports queue. Backed by
`DeviceLinkRepository` (source = phone library; target =
`SimulatedDeviceTransport`). The four rule strings persist in `DoradoSettings`.
- **Accept:** golden test — `buildPlan` output equals the desktop `SyncPlan`
for identical inputs; JVM round-trip test; `SyncRulePresets`/mapping tests.
**Cross-repo behavior (the goldens lock it in):**
- ✅ **Fixed upstream in `dorado`** this pass, mirrored here, both sides
re-goldened:
- `REMOVE` now carries the device item's `SizeBytes`, so freed space backs
later adds — `totalRemoveBytes` is meaningful and the desktop Device
view's "projected free" is now correct (was always 0).
- `NewestCount` now parses the first digit run anywhere — `"Newest 25 Items"`
→ 25 (was null). The desktop suite is **189/189 green**; `SyncEngineTest`
is green against the new fixtures.
- ⚠️ **Intentional desktop parity** (do not change on one side only):
- The **picture** rule inherits the **video** rule's mode
(`BuildPlan_KeepsExisting_AndRemovesStale` asserts this).
- A successful `KEEP` carries only title + size (no detail/source path).
The golden generator is vendored at `tools/sync_golden/` (C# console
referencing `dorado/src/Dorado.Application`); re-run it after any desktop
`SyncEngine` change.
### M9 — Modern Listening (Rune-inspired) — *in progress*
- ✅ **M9.1 — audio feature + similarity core.** Ported the sibling desktop
Phase 13a (`AudioFeatures`, `AudioFeatureExtractor`, `AudioAnalysisService`,
cosine ranking) into `analysis/`. The artist page's `related` pivot now ranks
by on-device audio similarity — the device genuinely sourced this from
Last.fm/friends, so a real signal is the honest fix — with genre overlap as
fallback. Pure JVM, unit-tested.
- ✅ **M9.2 — Dynamic Mix.** `DynamicMix`/`DynamicMixService` (mirrors the
desktop): SimilarToTrack / SimilarToAlbum / SimilarToFavorites /
PlaylistsIncludingArtist, plus TopPlayed (reads an injected play-count map;
title-order until counts are persisted in M9.2b). Surfaced as **start mix**
in every track's long-press menu and a **play favorites mix** row in
Quickplay.
- ✅ **M9.3 — DSP + persistence.** `FeatureMath` (radix-2 FFT, RMS,
zero-crossing rate, loudness, spectral centroid) + hybrid
`AudioFeatureExtractor.fromPcm` (DSP-refined energy/centroid/acousticness
over the metadata prior); `PcmDecoder` (MediaExtractor/MediaCodec) +
`PcmFeatureAnalyzer` glue; Room `track_features` (DB v3 + migration) behind
`FeatureStore`/`RoomFeatureStore`; `AudioAnalysisService` preloads on start
and persists analyses. Trigger via Settings ▸ device ▸ *analyze library*.
DSP math and the store are tested; the decoder is best-effort glue that
falls back to the metadata prior.
- ✅ **M9.4 — scrobbling + lyrics.** Last.fm `track.scrobble` with the
documented `api_sig` signing (`LastFmSignature`), a durable offline queue
(Room `scrobble_queue`, DB v4) behind `ScrobbleStore`/`RoomScrobbleStore`,
and `ScrobbleService` (order-preserving flush, stops at the first failure).
`PlaybackController` queues a scrobble once a track passes the Last.fm
threshold (half, or 4 min). Settings ▸ *scrobbling* holds the opt-in, the
user-supplied API key/secret/session key, and a manual flush. Lyrics via
LRCLIB (`LrcLibService` + pure `LrcLibParser`) surfaced from Now Playing.
Signing, queue semantics and parsing tested; the HTTP clients are glue.
- ⬜ **M9.2b — play counts.** Persist per-track counts so *Top Played* mixes
are real (currently title-order until counts exist).
- All of the above are canon §10 post-device extensions.
### M10 — Always-on surfaces
- **Glance widget**: Zune-style Now Playing + transport; add the appwidget
receiver to `AndroidManifest.xml`. RemoteViews are outside Compose, so keep
widget XML zero-corner-radius to honor the invariant's spirit.
- Richer lock-screen Now Playing art/controls.
- Optional sleep timer (post-device extension).
### M11 — Hardware stretch (capability-gated)
- USB-host MTP/MTPZ transport, F-marker ZMDB parser, PPP HTTP interceptor
per `zune-hardware-sync`. Likely N-A; document rather than ship. Never
bundle Microsoft binaries.
## 5. OSINT source map (inspiration, not code)
| Source | What to borrow | License posture |
|---|---|---|
| [losses/rune](https://github.com/losses/rune) | Dynamic **Mix**, on-device audio analysis → recommendations, scrobbling, lyrics, tag editor | MPL-2.0 → **behavioral only, never copy** |
| [ZuneDev/Xune](https://github.com/ZuneDev/Xune) | Iris UI-library semantics (motion/typography cross-check) | reference only |
| [zunes/Zune-Research](https://github.com/zunes/Zune-Research) | firmware / ZCP / OpenZDK ground truth for canon citations | docs reference |
| [ZuneDev/ZuneNet](https://github.com/ZuneDev/ZuneNet) | self-hostable social backend shape (beyond the mock) | reference only |
| [zunes/ZuneDiscordRPC](https://github.com/zunes/ZuneDiscordRPC) | now-playing presence idea | reference only |
| [dumbie/ZuseMe](https://github.com/dumbie/ZuseMe) | Last.fm scrobble pattern | reference only |
| [cdtinney/spune](https://github.com/cdtinney/spune) | visualizer for the screensaver backdrop | reference only |
| [whoozle/android-file-transfer-linux](https://github.com/whoozle/android-file-transfer-linux) | MTP client reference for the stretch transport | reference only |
| sibling `dorado` | `SyncModels.cs`, `SyncEngine`, `IDeviceTransport`, `Plugins.Protocol` | MIT — same project family |
## 6. Canon + doc changes required first
1. **Canon §3.1** — functional home entries are now all 9 (`music…settings`);
add a Sept-2009 source citation.
2. **Canon §5** — promote the in-app shade from "stretch goal" to implemented
(WallpaperManager-backed; `DoradoRoot.kt:115`), citing ITPro Today.
3. **New canon §10 — Post-device extensions** — EQ, crossfade, scrobble,
lyrics, Mix, widget, sleep timer. Label so they are never mistaken for
device-canon behavior.
## 7. Verification gates (every milestone)
- `./gradlew assembleDebug test lint` green (JDK 21 / AGP 9.4).
- `DesignInvariantTest` 0 violations; no `RoundedCornerShape`, no `spring(`,
no raw hex / named `Color.*` in `ui/`.
- New sizes/colors/durations go through `DoradoTokens` / `LocalDoradoColors`.
- New pure-logic engines unit-tested (the games set this bar).
## 8. Recommended first sprint
1. **Doc sync** (XS) — canon §3.1/§5 + new §10.
2. **M4 closeout** (S–M) — pin wiring + play-next + games/curated pivots.
3. **M8.1** (M) — land the Kotlin `SyncModels` mirror + `SyncEngine.buildPlan()`
with golden tests against the desktop semantics (pure JVM, unblocks M8).
Front-loads cheap device-parity wins, then the one architectural gap that
crosses into the sister project.
+144
View File
@@ -0,0 +1,144 @@
using System.Globalization;
using Dorado.Application.Interfaces;
using Dorado.Application.Models;
using Dorado.Application.Services;
using Dorado.Domain.Enums;
using Dorado.Domain.Models;
Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
static Guid G(int n) => new Guid($"00000000-0000-0000-0000-{n:000000000000}");
var engine = new SyncEngine();
// ---------------------------------------------------------------- inputs
List<Track> Tracks() => new()
{
new Track { Id = G(1), Title = "Delta", ArtistName = "A", AlbumTitle = "Al", Duration = TimeSpan.FromSeconds(1), FilePath = "/t1", Rating = HeartRating.Favorite },
new Track { Id = G(2), Title = "Alpha", ArtistName = "B", AlbumTitle = "Al", Duration = TimeSpan.FromSeconds(2), FilePath = "/t2", Rating = HeartRating.None },
new Track { Id = G(3), Title = "Charlie", ArtistName = "C", AlbumTitle = "Al", Duration = TimeSpan.FromSeconds(3), FilePath = "/t3", Rating = HeartRating.Favorite },
new Track { Id = G(4), Title = "Bravo", ArtistName = "D", AlbumTitle = "Al", Duration = TimeSpan.FromSeconds(4), FilePath = "/t4", Rating = HeartRating.Dislike },
new Track { Id = G(5), Title = "Echo", ArtistName = "E", AlbumTitle = "Al", Duration = TimeSpan.FromSeconds(5), FilePath = "/t5", Rating = HeartRating.Favorite },
};
List<PodcastEpisode> Episodes() => new()
{
new PodcastEpisode { Id = G(11), Title = "Ep Old", SeriesTitle = "S1", PublishedAtUtc = new DateTime(2020, 1, 1), Duration = TimeSpan.FromSeconds(1), AudioUrl = "/p1", IsPlayed = false },
new PodcastEpisode { Id = G(12), Title = "Ep New", SeriesTitle = "S1", PublishedAtUtc = new DateTime(2024, 1, 1), Duration = TimeSpan.FromSeconds(2), AudioUrl = "/p2", IsPlayed = false },
new PodcastEpisode { Id = G(13), Title = "Ep Mid", SeriesTitle = "S1", PublishedAtUtc = new DateTime(2022, 1, 1), Duration = TimeSpan.FromSeconds(3), AudioUrl = "/p3", IsPlayed = false },
new PodcastEpisode { Id = G(14), Title = "Ep Played", SeriesTitle = "S1", PublishedAtUtc = new DateTime(2025, 1, 1), Duration = TimeSpan.FromSeconds(4), AudioUrl = "/p4", IsPlayed = true },
};
List<Video> Videos() => new()
{
new Video { Id = G(21), Title = "Vid A", FilePath = "/v1", SizeBytes = 100000, AddedAtUtc = new DateTime(2021, 1, 1) },
new Video { Id = G(22), Title = "Vid B", FilePath = "/v2", SizeBytes = 200000, AddedAtUtc = new DateTime(2023, 1, 1) },
};
List<Photo> Photos() => new()
{
new Photo { Id = G(31), Title = "Pho A", FolderPath = "F1", FilePath = "/ph1", SizeBytes = 50000, AddedAtUtc = new DateTime(2020, 1, 1) },
new Photo { Id = G(32), Title = "Pho B", FolderPath = "F1", FilePath = "/ph2", SizeBytes = 60000, AddedAtUtc = new DateTime(2022, 1, 1) },
new Photo { Id = G(33), Title = "Pho C", FolderPath = "F2", FilePath = "/ph3", SizeBytes = 70000, AddedAtUtc = new DateTime(2024, 1, 1) },
};
SyncInput Input() => new()
{
Tracks = Tracks(),
Videos = Videos(),
Photos = Photos(),
PodcastEpisodes = Episodes(),
};
var device = new FixtureTransport
{
TotalCapacityBytes = 300000,
SystemBytes = 0,
Items =
{
new DeviceContentItem { Category = SyncCategoryType.Music, EntityId = G(1), Title = "Delta", DevicePath = "/d1", SizeBytes = 16000 },
new DeviceContentItem { Category = SyncCategoryType.Music, EntityId = G(99), Title = "Stale Song", DevicePath = "/d9", SizeBytes = 9000 },
new DeviceContentItem { Category = SyncCategoryType.Pictures, EntityId = G(98), Title = "Stale Photo", DevicePath = "/d8", SizeBytes = 7000 },
new DeviceContentItem { Category = SyncCategoryType.Videos, EntityId = G(21), Title = "Vid A", DevicePath = "/d2", SizeBytes = 100000 },
new DeviceContentItem { Category = SyncCategoryType.Podcasts, EntityId = G(97), Title = "Stale Ep", DevicePath = "/d7", SizeBytes = 5000 },
},
};
AppSettings Settings() => new()
{
MusicSyncRule = "Selected Favorites",
PodcastSyncRule = "3 Newest Episodes",
VideoSyncRule = "All Videos & Pictures",
PicturesSyncRule = "Newest 25 Items",
};
void Print(string name, SyncGroup group, IDeviceTransport transport, SyncInput input)
{
Console.WriteLine($"== {name} ==");
Console.WriteLine($"group serial={group.DeviceSerialNumber} guest={group.IsGuestSession.ToString().ToLowerInvariant()}");
var plan = engine.BuildPlan(group, input, transport);
foreach (var item in plan.Items)
{
Console.WriteLine(
$"ITEM {item.Action.ToString().ToUpperInvariant()} {item.Category.ToString().ToUpperInvariant()} " +
$"{item.EntityId} bytes={item.SizeBytes} title={item.Title} detail={item.Detail ?? ""}");
}
Console.WriteLine(
$"counts add={plan.AddCount} remove={plan.RemoveCount} keep={plan.KeepCount} " +
$"addBytes={plan.TotalAddBytes} removeBytes={plan.TotalRemoveBytes}");
Console.WriteLine();
}
// A — full plan (favorites, recency, newest, removes, keeps, capacity truncation)
Print("A-plan", engine.BuildDefaultGroup("SERIAL", Settings()), device, Input());
// B — guest session: add-only, never removes
Print("B-guest", engine.BuildDefaultGroup("SERIAL", Settings(), isGuestSession: true), device, Input());
// C — manual music: neither added nor removed
var manual = Settings();
manual.MusicSyncRule = "Manual";
Print("C-manual-music", engine.BuildDefaultGroup("SERIAL", manual), device, Input());
// D — default-group derivation from settings rule strings
Console.WriteLine("== D-rules ==");
void PrintRules(string label, AppSettings s)
{
var g = engine.BuildDefaultGroup("SERIAL", s);
foreach (var r in g.Categories)
{
var mode = r.Mode switch
{
SyncMode.Automatic => "AUTOMATIC",
SyncMode.SelectedItems => "SELECTED",
_ => "MANUAL",
};
Console.WriteLine(
$"RULE {label} {r.Category.ToString().ToUpperInvariant()} mode={mode} " +
$"newest={(r.NewestCount?.ToString() ?? "null")} favorite={r.PreferFavorites.ToString().ToLowerInvariant()} text={r.RuleText}");
}
}
PrintRules("automatic", new AppSettings { MusicSyncRule = "All Music (Automatic Sync)", PodcastSyncRule = "All Unplayed Episodes", VideoSyncRule = "All Videos & Pictures", PicturesSyncRule = "All Pictures" });
PrintRules("selected", new AppSettings { MusicSyncRule = "Selected Items", PodcastSyncRule = "3 Newest Episodes", VideoSyncRule = "Selected Items", PicturesSyncRule = "5 Newest" });
PrintRules("manual", new AppSettings { MusicSyncRule = "Sync Manually", PodcastSyncRule = "Sync Manually", VideoSyncRule = "Nothing", PicturesSyncRule = "Newest 25 Items" });
class FixtureTransport : IDeviceTransport
{
public string DeviceSerialNumber { get; set; } = "SERIAL";
public string DeviceName { get; set; } = "Zune HD";
public long TotalCapacityBytes { get; set; }
public long SystemBytes { get; set; }
public List<DeviceContentItem> Items { get; set; } = new();
public IReadOnlyList<DeviceContentItem> GetContents() => Items;
public bool TryGetItem(Guid entityId, out DeviceContentItem item)
{
var found = Items.FirstOrDefault(i => i.EntityId == entityId);
item = found!;
return found != null;
}
public void CopyToDevice(TransferItem item) { }
public void RemoveFromDevice(DeviceContentItem item) { }
public long UsedBytes => Items.Sum(i => i.SizeBytes);
public long FreeBytes => TotalCapacityBytes - SystemBytes - UsedBytes;
}
+15
View File
@@ -0,0 +1,15 @@
# sync_golden
Generates the golden fixtures for `SyncEngineTest` from the **real** desktop
engine, so the Kotlin mirror in `app/src/main/java/.../sync/` is verified
against `dorado`, not hand-written expectations.
```bash
cd tools/sync_golden
dotnet run -c Release
```
The ProjectReference points at `../../../dorado/src/Dorado.Application` (the
sibling repo). When `dorado`'s `SyncEngine` changes, re-run this, then update
the `goldenA`/`goldenB`/`goldenC` constants and the mode assertions in
`app/src/test/java/com/heretek/dorado_hd/SyncEngineTest.kt`.
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>sync_golden</AssemblyName>
<RootNamespace>SyncGolden</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../dorado/src/Dorado.Application/Dorado.Application.csproj" />
</ItemGroup>
</Project>