mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-12 02:21:19 +00:00
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:
@@ -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/
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user