feat(backlog): deep queue engine, built-in playlists, PIN screen lock

- media/QueueEngine.kt: bounded queue (500) + played-history (100) with
  back/forward branches, insert-next/move/clear; PlaybackController mirrors
  mutations to Media3, previous() restarts within 3 s else steps through
  history, and a PlaybackEvent flow exposes positioned/playing/paused/stopped.
- Built-in playlists (BuiltIn-FavoriteTracks/MostPlayedArtists/RecentTracks)
  derived from hearts, play counts and history; rendered read-only in the
  playlists pivot and unit-tested.
- PIN screen lock (device GemSettingPinLockScene): PBKDF2-HMAC-SHA256 salted
  hash in DataStore (never plaintext), settings ▸ screen lock set/change/clear,
  auto-lock toggle, lock-now; LockShade shows a tokenized numeric keypad with
  a 5-try/30 s limiter and back cannot bypass; no-PIN drag-unlock unchanged.
- 30 new tests; suite 1,598/0.
This commit is contained in:
John Smith
2026-09-12 18:23:38 -04:00
parent 501f821b24
commit c5c1924c00
14 changed files with 1529 additions and 106 deletions
@@ -0,0 +1,159 @@
package com.heretek.dorado_hd.data.repo
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.data.model.Track
/**
* The device's read-only built-in playlists — `BuiltIn-FavoriteTracks`,
* `BuiltIn-MostPlayedArtists`, `BuiltIn-RecentTracks` (parity audit §5).
*
* They are derived on demand from data Dorado-HD already owns: the tri-state
* hearts ([Rating.HEART]), persisted play counts (`PlayCountStore`) and the
* Quickplay play history. Nothing is persisted, so no schema change is needed.
* The derivation is pure and unit-tested; the UI re-derives whenever the
* playlists pivot opens (or its inputs change), which is the "refresh on open"
* behavior.
*/
object BuiltInPlaylists {
const val FAVORITE_TRACKS_ID = "builtin-favorite-tracks"
const val MOST_PLAYED_ARTISTS_ID = "builtin-most-played-artists"
const val RECENT_TRACKS_ID = "builtin-recent-tracks"
const val FAVORITE_LIMIT = 50
const val MOST_PLAYED_ARTIST_LIMIT = 10
const val MOST_PLAYED_TRACK_LIMIT = 100
const val RECENT_LIMIT = 25
/** A resolved, playable built-in playlist. */
data class BuiltInPlaylist(
val id: String,
val title: String,
val subLabel: String,
val tracks: List<Track>,
)
/**
* Every hearted track, by title. The device's favorites list is defined by
* the heart rating, not by play count.
*/
fun favoriteTracks(
library: List<Track>,
ratings: Map<Long, Int>,
limit: Int = FAVORITE_LIMIT,
): List<Track> =
library
.filter { ratings[it.mediaId] == Rating.HEART.value }
.sortedWith(compareBy({ it.title.lowercase() }, { it.mediaId }))
.take(limit)
/**
* The tracks of the most-played artists, artist rank first then track play
* count. Artists with no plays are excluded (the device playlist is
* literally about most-played artists).
*/
fun mostPlayedArtists(
library: List<Track>,
playCounts: Map<Long, Int>,
artistLimit: Int = MOST_PLAYED_ARTIST_LIMIT,
trackLimit: Int = MOST_PLAYED_TRACK_LIMIT,
): List<Track> {
val byArtist = library.groupBy { it.artistId }
val ranked = byArtist.entries
.map { (artistId, tracks) ->
ArtistPlays(
artistId = artistId,
name = tracks.firstOrNull()?.artist ?: "",
plays = tracks.sumOf { playCounts[it.mediaId] ?: 0 },
)
}
.filter { it.plays > 0 }
.sortedWith(
compareByDescending<ArtistPlays> { it.plays }
.thenBy { it.name.lowercase() }
.thenBy { it.artistId },
)
.take(artistLimit)
val ordered = ArrayList<Track>()
for (artist in ranked) {
val tracks = byArtist[artist.artistId].orEmpty().sortedWith(
compareByDescending<Track> { playCounts[it.mediaId] ?: 0 }
.thenBy { it.title.lowercase() }
.thenBy { it.mediaId },
)
ordered += tracks
if (ordered.size >= trackLimit) break
}
return ordered.take(trackLimit)
}
/**
* The most recently played distinct tracks, newest first. [recentMediaIds]
* is ordered newest → oldest (Quickplay history is already trimmed).
*/
fun recentTracks(
library: List<Track>,
recentMediaIds: List<Long>,
limit: Int = RECENT_LIMIT,
): List<Track> {
val byId = library.associateBy { it.mediaId }
val seen = HashSet<Long>()
val out = ArrayList<Track>()
for (id in recentMediaIds) {
if (!seen.add(id)) continue
val track = byId[id] ?: continue
out += track
if (out.size >= limit) break
}
return out
}
/**
* Resolves the three built-ins in device order. Empty lists are omitted so
* the pivot never shows a dead row.
*/
fun derive(
library: List<Track>,
ratings: Map<Long, Int>,
playCounts: Map<Long, Int>,
recentMediaIds: List<Long>,
): List<BuiltInPlaylist> {
val favorites = favoriteTracks(library, ratings)
val mostPlayed = mostPlayedArtists(library, playCounts)
val recent = recentTracks(library, recentMediaIds)
return buildList {
if (favorites.isNotEmpty()) {
add(
BuiltInPlaylist(
id = FAVORITE_TRACKS_ID,
title = "favorite tracks",
subLabel = "built-in · ${favorites.size} songs",
tracks = favorites,
),
)
}
if (mostPlayed.isNotEmpty()) {
add(
BuiltInPlaylist(
id = MOST_PLAYED_ARTISTS_ID,
title = "most played artists",
subLabel = "built-in · ${mostPlayed.size} songs",
tracks = mostPlayed,
),
)
}
if (recent.isNotEmpty()) {
add(
BuiltInPlaylist(
id = RECENT_TRACKS_ID,
title = "recent tracks",
subLabel = "built-in · ${recent.size} songs",
tracks = recent,
),
)
}
}
}
private data class ArtistPlays(val artistId: Long, val name: String, val plays: Int)
}
@@ -26,6 +26,15 @@ class QuickplayRepository(private val db: DoradoDatabase) {
list.map { it.toCard() }
}
/**
* Most-recently played track ids, newest first — the input to the built-in
* `recent tracks` playlist. History also carries albums/artists etc., so
* only track cards are returned.
*/
fun recentTrackIds(limit: Int = 30): Flow<List<Long>> = history(limit).map { cards ->
cards.filter { it.kind == PinKind.TRACK }.map { it.refId }
}
suspend fun pin(kind: PinKind, refId: Long, label: String, subLabel: String, artAlbumId: Long) {
db.pinDao().pin(
PinEntity(
@@ -6,9 +6,16 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.heretek.dorado_hd.data.security.PinLock
import com.heretek.dorado_hd.design.DoradoAccent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
private val Context.dataStore by preferencesDataStore(name = "dorado_hd_settings")
@@ -42,7 +49,20 @@ data class DoradoSettings(
val crossfadeSeconds: Int = 0,
/** M10 — sleep-timer preset in minutes (0 = no timer armed). */
val sleepTimerMinutes: Int = 0,
)
/**
* Screen lock (device `GemSettingPinLockScene`). Only the PBKDF2 hash,
* salt and iteration count are persisted — never the PIN. An empty
* [pinHash] means no PIN is configured.
*/
val pinHash: String = "",
val pinSalt: String = "",
val pinIterations: Int = PinLock.DEFAULT_ITERATIONS,
/** Require the PIN after the app is backgrounded (vs. "lock now" only). */
val autoLockEnabled: Boolean = true,
) {
/** True when a screen-lock credential is stored. */
val hasPin: Boolean get() = pinHash.isNotEmpty()
}
class SettingsRepository(private val context: Context) {
private object Keys {
@@ -67,6 +87,21 @@ class SettingsRepository(private val context: Context) {
val EQ_PRESET = stringPreferencesKey("eq_preset")
val CROSSFADE_SECONDS = intPreferencesKey("crossfade_seconds")
val SLEEP_TIMER_MINUTES = intPreferencesKey("sleep_timer_minutes")
val PIN_HASH = stringPreferencesKey("pin_hash")
val PIN_SALT = stringPreferencesKey("pin_salt")
val PIN_ITERATIONS = intPreferencesKey("pin_iterations")
val AUTO_LOCK = booleanPreferencesKey("auto_lock")
}
/**
* In-memory "lock now" signal (not persisted). Settings ▸ device emits it;
* `DoradoRoot` collects it and raises the wake shade.
*/
private val _lockRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val lockRequests: SharedFlow<Unit> = _lockRequests.asSharedFlow()
fun requestLockNow() {
_lockRequests.tryEmit(Unit)
}
val settings: Flow<DoradoSettings> = context.dataStore.data.map { p ->
@@ -92,6 +127,10 @@ class SettingsRepository(private val context: Context) {
eqPreset = p[Keys.EQ_PRESET] ?: "FLAT",
crossfadeSeconds = p[Keys.CROSSFADE_SECONDS] ?: 0,
sleepTimerMinutes = p[Keys.SLEEP_TIMER_MINUTES] ?: 0,
pinHash = p[Keys.PIN_HASH] ?: "",
pinSalt = p[Keys.PIN_SALT] ?: "",
pinIterations = p[Keys.PIN_ITERATIONS] ?: PinLock.DEFAULT_ITERATIONS,
autoLockEnabled = p[Keys.AUTO_LOCK] ?: true,
)
}
@@ -118,4 +157,47 @@ class SettingsRepository(private val context: Context) {
suspend fun setEqPreset(value: String) = context.dataStore.edit { it[Keys.EQ_PRESET] = value }
suspend fun setCrossfadeSeconds(value: Int) = context.dataStore.edit { it[Keys.CROSSFADE_SECONDS] = value }
suspend fun setSleepTimerMinutes(value: Int) = context.dataStore.edit { it[Keys.SLEEP_TIMER_MINUTES] = value }
// ---- screen lock (device GemSettingPinLockScene / HudPinLockScene) ----
suspend fun setAutoLock(enabled: Boolean) = context.dataStore.edit { it[Keys.AUTO_LOCK] = enabled }
/**
* Validates and stores a fresh salted PBKDF2 credential. The PIN itself is
* never written. Returns false when [pin] is not 4–6 digits.
*/
suspend fun setPin(pin: String): Boolean {
if (!PinLock.isValid(pin)) return false
val stored = withContext(Dispatchers.Default) { PinLock.hashNew(pin) }
context.dataStore.edit {
it[Keys.PIN_HASH] = PinLock.encode(stored.hash)
it[Keys.PIN_SALT] = PinLock.encode(stored.salt)
it[Keys.PIN_ITERATIONS] = stored.iterations
}
return true
}
suspend fun clearPin() = context.dataStore.edit {
it.remove(Keys.PIN_HASH)
it[Keys.PIN_SALT] = ""
it[Keys.PIN_ITERATIONS] = PinLock.DEFAULT_ITERATIONS
}
/** Verifies a PIN against the stored hash. True when no PIN is configured. */
suspend fun verifyPin(pin: String): Boolean {
val current = settings.first()
if (!current.hasPin) return true
return withContext(Dispatchers.Default) {
runCatching {
PinLock.verify(
pin,
PinLock.Stored(
hash = PinLock.decode(current.pinHash),
salt = PinLock.decode(current.pinSalt),
iterations = current.pinIterations,
),
)
}.getOrDefault(false)
}
}
}
@@ -0,0 +1,118 @@
package com.heretek.dorado_hd.data.security
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
/**
* Pure PIN hashing/verification for the device screen lock (canon §5,
* parity audit §9 `GemSettingPinLockScene` / `HudPinLockScene`).
*
* A PIN is never stored or round-tripped: only a salted PBKDF2-HMAC-SHA256
* hash plus its parameters are persisted (as base64 strings in DataStore).
* [verify] recomputes the candidate hash and compares it in constant time.
* Android's app-private DataStore is the storage boundary; this object touches
* no I/O and is unit-testable on the JVM.
*/
object PinLock {
const val MIN_LENGTH = 4
const val MAX_LENGTH = 6
const val DEFAULT_ITERATIONS = 120_000
const val KEY_LENGTH_BITS = 256
const val SALT_LENGTH_BYTES = 16
/** A stored credential: hash + salt + the iteration count used to derive it. */
data class Stored(val hash: ByteArray, val salt: ByteArray, val iterations: Int) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Stored) return false
return iterations == other.iterations &&
hash.contentEquals(other.hash) &&
salt.contentEquals(other.salt)
}
override fun hashCode(): Int {
var result = hash.contentHashCode()
result = 31 * result + salt.contentHashCode()
result = 31 * result + iterations
return result
}
}
/** 4–6 decimal digits, the device's accepted PIN shape. */
fun isValid(pin: String): Boolean =
pin.length in MIN_LENGTH..MAX_LENGTH && pin.all { it in '0'..'9' }
fun newSalt(random: SecureRandom = SecureRandom()): ByteArray =
ByteArray(SALT_LENGTH_BYTES).also(random::nextBytes)
fun hash(pin: String, salt: ByteArray, iterations: Int = DEFAULT_ITERATIONS): ByteArray {
require(isValid(pin)) { "pin must be $MIN_LENGTH-$MAX_LENGTH digits" }
require(iterations >= 1) { "iterations must be >= 1" }
val spec = PBEKeySpec(pin.toCharArray(), salt, iterations, KEY_LENGTH_BITS)
return try {
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
} finally {
spec.clearPassword()
}
}
/** Derives a fresh credential with a new random salt. */
fun hashNew(
pin: String,
random: SecureRandom = SecureRandom(),
iterations: Int = DEFAULT_ITERATIONS,
): Stored {
val salt = newSalt(random)
return Stored(hash(pin, salt, iterations), salt, iterations)
}
fun verify(pin: String, stored: Stored): Boolean {
if (!isValid(pin)) return false
val candidate = hash(pin, stored.salt, stored.iterations)
return MessageDigest.isEqual(candidate, stored.hash)
}
/** Base64 for DataStore transport. Never encodes the PIN itself. */
fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)
fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)
}
/**
* Consecutive-failure rate limiter for PIN entry: after [maxAttempts] wrong
* PINs the keypad is blocked for [cooldownMs]. Pure and clock-injectable so
* the policy is unit-testable without sleeping.
*/
class PinAttemptLimiter(
val maxAttempts: Int = 5,
val cooldownMs: Long = 30_000L,
private val clock: () -> Long = System::currentTimeMillis,
) {
var failures: Int = 0
private set
private var lockedUntilMs: Long = 0L
fun remainingCooldownMs(): Long = (lockedUntilMs - clock()).coerceAtLeast(0L)
fun isLocked(): Boolean = remainingCooldownMs() > 0L
fun canAttempt(): Boolean = !isLocked()
fun onFailure() {
if (isLocked()) return
failures++
if (failures >= maxAttempts) {
lockedUntilMs = clock() + cooldownMs
failures = 0
}
}
fun onSuccess() {
failures = 0
lockedUntilMs = 0L
}
}
@@ -9,14 +9,19 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -38,11 +43,14 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.heretek.dorado_hd.data.security.PinAttemptLimiter
import com.heretek.dorado_hd.data.security.PinLock
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 java.util.Date
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -50,11 +58,17 @@ import kotlinx.coroutines.launch
* leaves the foreground; slide it up to reveal the home screen, exactly as
* the device did over the user's wallpaper. Translucent, so the wallpaper
* shows through; user drags the shade up to dismiss.
*
* When [pinRequired] the slide gesture is replaced by the device's numeric
* PIN keypad (`HudPinLockScene`): tokenized, zero radius, no Material chrome.
* Wrong entries are rate-limited by [PinAttemptLimiter] (5 tries → 30 s).
*/
@Composable
fun LockShade(
visible: Boolean,
onUnlock: () -> Unit,
pinRequired: Boolean = false,
verifyPin: suspend (String) -> Boolean = { true },
modifier: Modifier = Modifier,
) {
val colors = LocalDoradoColors.current
@@ -63,9 +77,9 @@ fun LockShade(
val offset = remember { Animatable(0f) }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
// While shaded, system Back dismisses the shade instead of backgrounding.
// While shaded, system Back dismisses the shade — but never bypasses a PIN.
if (visible) {
androidx.activity.compose.BackHandler { onUnlock() }
androidx.activity.compose.BackHandler { if (!pinRequired) onUnlock() }
}
// System wallpaper (one-shot, decoded off the main thread). Null if the
@@ -100,28 +114,34 @@ fun LockShade(
Modifier
.fillMaxSize()
.graphicsLayer { translationY = -offset.value }
.pointerInput(Unit) {
detectVerticalDragGestures(
onVerticalDrag = { change, amount ->
change.consume()
scope.launch { offset.snapTo((offset.value - amount).coerceIn(0f, unlockPx * 1.4f)) }
},
onDragEnd = {
if (offset.value > unlockPx) {
// Let the slide-off animation finish before the
// shade is dismissed (the old code flipped
// visibility immediately, so the slide was never
// seen).
scope.launch {
offset.animateTo(unlockPx * 1.6f, DoradoMotion.pivot())
onUnlock()
}
} else {
scope.launch { offset.animateTo(0f, DoradoMotion.pivot()) }
}
},
)
},
.then(
if (pinRequired) {
Modifier
} else {
Modifier.pointerInput(Unit) {
detectVerticalDragGestures(
onVerticalDrag = { change, amount ->
change.consume()
scope.launch { offset.snapTo((offset.value - amount).coerceIn(0f, unlockPx * 1.4f)) }
},
onDragEnd = {
if (offset.value > unlockPx) {
// Let the slide-off animation finish before the
// shade is dismissed (the old code flipped
// visibility immediately, so the slide was never
// seen).
scope.launch {
offset.animateTo(unlockPx * 1.6f, DoradoMotion.pivot())
onUnlock()
}
} else {
scope.launch { offset.animateTo(0f, DoradoMotion.pivot()) }
}
},
)
}
},
),
) {
// Wallpaper underneath the translucent scrim — faithful to the
// device's "user wallpaper behind a software shade" affordance.
@@ -137,44 +157,53 @@ fun LockShade(
// Translucent scrim (alpha 0.6) so the time/date is readable on
// any wallpaper, but the wallpaper still bleeds through.
Box(Modifier.fillMaxSize().background(colors.background.copy(alpha = 0.6f)))
Column(
Modifier
.align(Alignment.Center)
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = DateFormat.getTimeFormat(context).format(Date(now)),
style = TextStyle(
fontFamily = Selawik,
fontWeight = FontWeight.Light,
fontSize = DoradoTokens.TYPE_HEADER_CROPPED.sp,
color = colors.textPrimary,
),
if (pinRequired) {
PinLockPanel(
verifyPin = verifyPin,
onUnlock = onUnlock,
modifier = Modifier.align(Alignment.Center),
)
Spacer(Modifier.height(8.dp))
} else {
Column(
Modifier
.align(Alignment.Center)
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = DateFormat.getTimeFormat(context).format(Date(now)),
style = TextStyle(
fontFamily = Selawik,
fontWeight = FontWeight.Light,
fontSize = DoradoTokens.TYPE_HEADER_CROPPED.sp,
color = colors.textPrimary,
),
)
Spacer(Modifier.height(8.dp))
BasicText(
text = DateFormat.getDateFormat(context).format(Date(now)),
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST.sp,
color = colors.textSecondary,
),
)
}
BasicText(
text = DateFormat.getDateFormat(context).format(Date(now)),
text = "slide up to unlock",
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST.sp,
fontSize = DoradoTokens.TYPE_NOW_META.sp,
color = colors.textSecondary,
),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 32.dp),
)
}
BasicText(
text = "slide up to unlock",
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_NOW_META.sp,
color = colors.textSecondary,
),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 32.dp),
)
BasicText(
text = "dorado hd",
style = TextStyle(
@@ -191,6 +220,138 @@ fun LockShade(
}
}
/**
* The device PIN keypad. Digits only; submit with `ok`, back out with `del`.
* Failures feed [PinAttemptLimiter] so five wrong entries block the pad for
* 30 s. No plaintext PIN is retained after a submission.
*/
@Composable
private fun PinLockPanel(
verifyPin: suspend (String) -> Boolean,
onUnlock: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalDoradoColors.current
val scope = rememberCoroutineScope()
val limiter = remember { PinAttemptLimiter() }
var entered by remember { mutableStateOf("") }
var message by remember { mutableStateOf<String?>(null) }
var cooldownMs by remember { mutableStateOf(0L) }
var locked by remember { mutableStateOf(false) }
LaunchedEffect(locked) {
while (locked) {
val remaining = limiter.remainingCooldownMs()
cooldownMs = remaining
if (remaining <= 0L) locked = false else delay(250)
}
}
fun submit() {
if (entered.length < PinLock.MIN_LENGTH) {
message = "enter ${PinLock.MIN_LENGTH}-${PinLock.MAX_LENGTH} digits"
return
}
val candidate = entered
entered = ""
scope.launch {
if (verifyPin(candidate)) {
limiter.onSuccess()
message = null
onUnlock()
} else {
limiter.onFailure()
if (limiter.isLocked()) {
message = null
locked = true
} else {
message = "incorrect pin"
}
}
}
}
Column(
modifier = modifier.padding(horizontal = DoradoTokens.EDGE.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = "enter pin",
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST.sp,
color = colors.textSecondary,
),
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(PinLock.MAX_LENGTH) { index ->
val filled = index < entered.length
Box(
Modifier
.size(10.dp)
.background(if (filled) colors.accent else Color.Transparent)
.border(0.5.dp, colors.border),
)
}
}
Spacer(Modifier.height(8.dp))
val status = when {
locked -> "locked · ${cooldownMs / 1000 + 1}s"
message != null -> message!!
else -> " "
}
BasicText(
text = status,
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.sp,
color = if (locked) colors.accent else colors.textSecondary,
),
)
Spacer(Modifier.height(8.dp))
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("del", "0", "ok"),
)
rows.forEach { keys ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
keys.forEach { key ->
val enabled = !locked &&
!(key == "ok" && entered.length < PinLock.MIN_LENGTH) &&
!(key == "del" && entered.isEmpty())
Box(
Modifier
.size(width = 46.dp, height = 30.dp)
.background(colors.tile)
.border(0.5.dp, colors.border)
.clickable(enabled = enabled) {
when (key) {
"del" -> entered = entered.dropLast(1)
"ok" -> submit()
else -> if (entered.length < PinLock.MAX_LENGTH) entered += key
}
},
contentAlignment = Alignment.Center,
) {
BasicText(
text = key,
style = TextStyle(
fontFamily = Selawik,
fontSize = DoradoTokens.TYPE_LIST.sp,
color = if (enabled) colors.textPrimary else colors.textInactive,
),
)
}
}
}
}
Spacer(Modifier.height(4.dp))
}
}
@SuppressLint("MissingPermission")
private fun wallpaperDrawable(context: android.content.Context): androidx.compose.ui.graphics.ImageBitmap? {
return try {
@@ -99,6 +99,20 @@ class PlaybackController(
/** Media ids of the parts of the audiobook queue, for the reset guard. */
private var audiobookPartIds: Set<Long> = emptySet()
/**
* Pure queue order + played-history stack (device `CMediaQueueBase` /
* `CTrackListQueue`). Media3 remains the playback engine; every queue
* mutation is mirrored through [applyQueueMutation].
*/
private val queueEngine = QueueEngine<Track>(idOf = { it.mediaId })
/**
* The most recent device-style playback event (`ZMediaQueue/MediaItem*`).
* In-memory only; a StateFlow so consumers always see the latest event.
*/
private val _playbackEvent = MutableStateFlow<PlaybackEvent?>(null)
val playbackEvent: StateFlow<PlaybackEvent?> = _playbackEvent.asStateFlow()
/** Sleep-timer countdown in ms; 0 when no timer is armed. */
private val _sleepRemainingMs = MutableStateFlow(0L)
val sleepRemainingMs: StateFlow<Long> = _sleepRemainingMs.asStateFlow()
@@ -120,12 +134,18 @@ class PlaybackController(
private val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
val id = _nowPlaying.value?.mediaId ?: return
_playbackEvent.value = if (isPlaying) PlaybackEvent.Playing(id) else PlaybackEvent.Paused(id)
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
_durationMs.value = controller?.duration ?: 0L
}
if (playbackState == Player.STATE_ENDED) {
val id = _nowPlaying.value?.mediaId ?: return
_playbackEvent.value = PlaybackEvent.Stopped(id)
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@@ -137,8 +157,10 @@ class PlaybackController(
val previousReached = candidateReachedThreshold
val index = controller?.currentMediaItemIndex ?: return
_currentIndex.value = index
queueEngine.setCurrentIndex(index)
val track = _queue.value.getOrNull(index)
_nowPlaying.value = track
if (track != null) queueEngine.recordPlayed(track)
_durationMs.value = controller?.duration ?: 0L
candidateReachedThreshold = false
// Parts of the audiobook keep the chosen reading speed; anything
@@ -178,6 +200,13 @@ class PlaybackController(
if (player != null && _isPlaying.value) {
val position = player.currentPosition.coerceAtLeast(0)
_positionMs.value = position
_nowPlaying.value?.let { track ->
_playbackEvent.value = PlaybackEvent.Positioned(
mediaId = track.mediaId,
positionMs = position,
durationMs = _durationMs.value,
)
}
if (reachedScrobbleThreshold(position, _durationMs.value)) {
candidateReachedThreshold = true
}
@@ -316,11 +345,14 @@ class PlaybackController(
if (_speed.value != 1.0f) setSpeed(1.0f)
}
baseOrder = tracks
_queue.value = tracks
queueEngine.reset(tracks, startIndex)
_queue.value = queueEngine.items
_currentIndex.value = queueEngine.currentIndex
_source.value = source
val safeStart = queueEngine.currentIndex.coerceAtLeast(0)
val items = tracks.map { it.toMediaItem() }
val player = controller ?: return
player.setMediaItems(items, startIndex, 0L)
player.setMediaItems(items, safeStart, 0L)
player.prepare()
fadeOutTriggered = false
if (FadeRamp.effectiveMs(crossfadeMs()) > 0L) player.volume = 0f
@@ -356,13 +388,18 @@ class PlaybackController(
controller?.setPlaybackSpeed(value)
}
/** Appends [track] to the end of the queue (the "play next" long-press action). */
fun enqueue(track: Track) {
val player = controller ?: return
player.addMediaItem(track.toMediaItem())
_queue.value = _queue.value + track
applyQueueMutation(queueEngine.append(track))
if (baseOrder.isEmpty()) baseOrder = _queue.value
}
/** Inserts [track] immediately after the current item (device showlist insert). */
fun insertNext(track: Track) {
applyQueueMutation(queueEngine.insertNext(track))
if (!_shuffle.value) baseOrder = _queue.value
}
fun toggle() {
val player = controller ?: return
if (player.isPlaying) {
@@ -380,8 +417,27 @@ class PlaybackController(
skipWithFade { it.seekToNextMediaItem() }
}
/**
* Device previous behavior: within the first few seconds step back through
* the played-history stack; after that restart the current track. Falls
* back to a restart when the history entry is no longer queued.
*/
fun previous() {
skipWithFade { it.seekToPreviousMediaItem() }
val position = _positionMs.value
if (position > PREVIOUS_RESTART_THRESHOLD_MS) {
seekTo(0L)
return
}
val id = queueEngine.back() ?: run {
seekTo(0L)
return
}
val index = _queue.value.indexOfFirst { it.mediaId == id }
if (index >= 0) {
skipWithFade { it.seekTo(index, 0L) }
} else {
seekTo(0L)
}
}
fun seekTo(positionMs: Long) {
@@ -395,10 +451,52 @@ class PlaybackController(
/** Removes the queue entry at [index] (the device's showlist/queue list). */
fun removeAt(index: Int) {
val queueNow = _queue.value
if (index !in queueNow.indices) return
controller?.removeMediaItem(index)
_queue.value = queueNow.toMutableList().also { it.removeAt(index) }
val removed = _queue.value.getOrNull(index) ?: return
applyQueueMutation(queueEngine.removeAt(index))
baseOrder = baseOrder.filterNot { it.mediaId == removed.mediaId }
}
/** Reorders the queue (device `ZDKMedia_Queue_MoveTo`). */
fun moveInQueue(from: Int, to: Int) {
applyQueueMutation(queueEngine.move(from, to))
if (!_shuffle.value) baseOrder = _queue.value
}
/** Empties the queue and its played history. */
fun clearQueue() {
applyQueueMutation(queueEngine.clear())
baseOrder = emptyList()
}
/** Snapshot of the played-history ids (oldest → newest). */
fun playedHistory(): List<Long> = queueEngine.history
/** True while the device-style `previous()` would step back a track. */
fun canGoBackInHistory(): Boolean = queueEngine.canGoBack()
/** Mirrors a pure [QueueMutation] onto Media3 and the exposed StateFlows. */
private fun applyQueueMutation(mutation: QueueMutation<Track>) {
val player = controller
when (mutation) {
is QueueMutation.None -> Unit
is QueueMutation.Append -> {
if (player != null) {
mutation.evictAt?.let { player.removeMediaItem(it) }
player.addMediaItem(mutation.item.toMediaItem())
}
}
is QueueMutation.Insert -> {
if (player != null) {
mutation.evictAt?.let { player.removeMediaItem(it) }
player.addMediaItem(mutation.index, mutation.item.toMediaItem())
}
}
is QueueMutation.Move -> player?.moveMediaItem(mutation.from, mutation.to)
is QueueMutation.Remove -> player?.removeMediaItem(mutation.index)
is QueueMutation.Clear -> player?.clearMediaItems()
}
_queue.value = queueEngine.items
_currentIndex.value = queueEngine.currentIndex
}
fun setShuffle(enabled: Boolean) {
@@ -416,6 +514,8 @@ class PlaybackController(
listOfNotNull(current) + rest
}
_queue.value = newOrder
queueEngine.reset(newOrder, 0)
_currentIndex.value = queueEngine.currentIndex
player.setMediaItems(newOrder.map { it.toMediaItem() }, 0, 0L)
player.prepare()
if (_isPlaying.value) player.play()
@@ -427,6 +527,8 @@ class PlaybackController(
val ratings = quickplay.ratings()
val newOrder = smartShuffleOrder(baseOrder, ratings, _nowPlaying.value)
_queue.value = newOrder
queueEngine.reset(newOrder, 0)
_currentIndex.value = queueEngine.currentIndex
player.setMediaItems(newOrder.map { it.toMediaItem() }, 0, 0L)
player.prepare()
if (_isPlaying.value) player.play()
@@ -460,6 +562,13 @@ class PlaybackController(
}
companion object {
/**
* Within this window `previous()` steps back through history; beyond
* it the device restarts the current track (Media3's own
* `DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS`).
*/
const val PREVIOUS_RESTART_THRESHOLD_MS = 3_000L
/**
* Smart DJ ordering (canon §4): the current track leads, hearted
* tracks follow, then unrated; broken-heart tracks are skipped.
@@ -0,0 +1,29 @@
package com.heretek.dorado_hd.media
/**
* In-memory playback state events, analogues of the device's
* `ZMediaQueue/MediaItemPositioned|Stopped|Paused|Playing` notifications
* (`zmedia_serv.dll`). They are delivered as one [kotlinx.coroutines.flow.StateFlow]
* of the most recent event; consumers reconstruct the stream themselves. No
* event is persisted.
*/
sealed interface PlaybackEvent {
/** The item the event describes. */
val mediaId: Long
/** Device `MediaItemPlaying`. */
data class Playing(override val mediaId: Long) : PlaybackEvent
/** Device `MediaItemPaused`. */
data class Paused(override val mediaId: Long) : PlaybackEvent
/** Device `MediaItemStopped` — ended, stopped, or the queue was cleared. */
data class Stopped(override val mediaId: Long) : PlaybackEvent
/** Device `MediaItemPositioned` — periodic position report. */
data class Positioned(
override val mediaId: Long,
val positionMs: Long,
val durationMs: Long,
) : PlaybackEvent
}
@@ -0,0 +1,218 @@
package com.heretek.dorado_hd.media
/**
* Pure queue + played-history model, recovered from the Zune HD playback
* engine (`zmedia_serv.dll`: `CMediaQueueBase` / `CProgressivePlay` /
* `CTrackListQueue`, the `AudioQueueCapacity` / `...HistoryCapacity` registry
* values, and the `ZMediaQueue/MediaItem*` notifications).
*
* The engine owns the queue order, a bounded played-history stack, and the
* capacity-eviction policy. It performs no playback and holds no Android
* types, so it is unit-testable in isolation. [PlaybackController] mirrors
* every mutation onto Media3.
*
* **Capacity semantics.** The seed list passed to [reset] is never truncated
* (a whole album/playlist is queued at once); [capacity] bounds incremental
* growth via [append] / [insertNext] / [insertAt]. When a mutation would
* exceed capacity the engine evicts the oldest *already-played* entry, or the
* tail when the current item is first, so the currently playing item is never
* disturbed (unless the queue holds only it).
*
* **History semantics.** [recordPlayed] pushes ids onto a bounded stack with
* browser-style branches: going [back] then playing something new discards the
* abandoned forward branch. [back] / [forward] are pure cursor moves; the
* controller decides restart-vs-back using the playback position.
*
* The device's capacity defaults are not recoverable from the reconstructed
* binaries (only the registry key names survive), so the constants below are
* documented, generous stand-ins.
*/
class QueueEngine<T>(
val capacity: Int = DEFAULT_CAPACITY,
val historyCapacity: Int = DEFAULT_HISTORY_CAPACITY,
private val idOf: (T) -> Long,
) {
init {
require(capacity >= 1) { "capacity must be >= 1" }
require(historyCapacity >= 1) { "historyCapacity must be >= 1" }
}
private val _items = ArrayList<T>()
/** A snapshot of the current queue order. */
val items: List<T> get() = _items.toList()
/** Index of the playing item, or -1 when the queue is empty. */
var currentIndex: Int = -1
private set
private val _history = ArrayList<Long>()
/** Played item ids, oldest → newest. */
val history: List<Long> get() = _history.toList()
/** Cursor into [history]; -1 when nothing has been played. */
var historyCursor: Int = -1
private set
val size: Int get() = _items.size
val isEmpty: Boolean get() = _items.isEmpty()
val isNotEmpty: Boolean get() = _items.isNotEmpty()
/** Replaces the queue wholesale (a fresh `play()`) and clears history. */
fun reset(newItems: List<T>, startIndex: Int) {
_items.clear()
_items.addAll(newItems)
currentIndex = if (_items.isEmpty()) -1 else startIndex.coerceIn(0, _items.lastIndex)
_history.clear()
historyCursor = -1
}
fun setCurrentIndex(index: Int) {
if (index in _items.indices) currentIndex = index
}
// ---- played history (device `AudioQueueHistoryCapacity`) ----
/**
* Records that [item] started playing. Re-recording the current history
* entry (a replay) is a no-op; stepping onto the entry we already hold
* (a `forward()`) advances the cursor instead of branching.
*/
fun recordPlayed(item: T) {
val id = idOf(item)
if (historyCursor in _history.indices && _history[historyCursor] == id) return
if (historyCursor + 1 <= _history.lastIndex && _history[historyCursor + 1] == id) {
historyCursor++
return
}
// A genuinely new play abandons any forward branch.
while (_history.size > historyCursor + 1) _history.removeAt(_history.lastIndex)
_history.add(id)
if (_history.size > historyCapacity) {
repeat(_history.size - historyCapacity) { _history.removeAt(0) }
}
historyCursor = _history.lastIndex
}
fun canGoBack(): Boolean = historyCursor > 0
fun canGoForward(): Boolean = historyCursor in 0 until _history.lastIndex
/** Steps back in history and returns the id, or null at the start. */
fun back(): Long? {
if (!canGoBack()) return null
historyCursor--
return _history[historyCursor]
}
/** Steps forward in history and returns the id, or null at the tip. */
fun forward(): Long? {
if (!canGoForward()) return null
historyCursor++
return _history[historyCursor]
}
// ---- queue mutation ----
/** Appends [item], evicting an already-played entry first if at capacity. */
fun append(item: T): QueueMutation<T> {
val evictAt = evictionIndex()
if (evictAt != null) removeInternal(evictAt)
_items.add(item)
return QueueMutation.Append(item, evictAt)
}
/** Inserts [item] immediately after the current item (the showlist action). */
fun insertNext(item: T): QueueMutation<T> =
insertAt(if (currentIndex < 0) _items.size else currentIndex + 1, item)
fun insertAt(index: Int, item: T): QueueMutation<T> {
if (_items.isEmpty()) {
_items.add(item)
currentIndex = 0
return QueueMutation.Insert(0, item, null)
}
val rawAt = index.coerceIn(0, _items.size)
val evictAt = evictionIndex()
if (evictAt != null) removeInternal(evictAt)
val at = (if (evictAt != null && evictAt < rawAt) rawAt - 1 else rawAt).coerceIn(0, _items.size)
_items.add(at, item)
if (at <= currentIndex) currentIndex++
return QueueMutation.Insert(at, item, evictAt)
}
/** Moves the item at [from] to [to], following the current item. */
fun move(from: Int, to: Int): QueueMutation<T> {
if (from !in _items.indices || to !in _items.indices || from == to) return QueueMutation.None
val currentId = _items.getOrNull(currentIndex)?.let(idOf)
val item = _items.removeAt(from)
_items.add(to, item)
if (currentId != null) {
val found = _items.indexOfFirst { idOf(it) == currentId }
if (found >= 0) currentIndex = found
}
return QueueMutation.Move(from, to)
}
fun removeAt(index: Int): QueueMutation<T> {
if (index !in _items.indices) return QueueMutation.None
removeInternal(index)
return QueueMutation.Remove(index)
}
/** Empties the queue and its history. */
fun clear(): QueueMutation<T> {
if (_items.isEmpty()) return QueueMutation.None
_items.clear()
currentIndex = -1
_history.clear()
historyCursor = -1
return QueueMutation.Clear
}
// ---- internals ----
private fun removeInternal(index: Int) {
if (index !in _items.indices) return
_items.removeAt(index)
currentIndex = when {
_items.isEmpty() -> -1
index < currentIndex -> currentIndex - 1
index == currentIndex -> currentIndex.coerceAtMost(_items.lastIndex)
else -> currentIndex
}
}
/**
* Index to evict to make room, or null when there is room. Prefers the
* oldest already-played entry; if the current item is first, evicts the
* tail so playback is never interrupted.
*/
private fun evictionIndex(): Int? {
if (_items.size < capacity || _items.isEmpty()) return null
return if (currentIndex > 0) 0 else _items.lastIndex
}
companion object {
const val DEFAULT_CAPACITY = 500
const val DEFAULT_HISTORY_CAPACITY = 100
}
}
/**
* What a queue mutation did, in terms Media3 can replay verbatim. [evictAt] is
* an index in the *pre-mutation* list (remove it before [Append]/[Insert]).
*/
sealed interface QueueMutation<out T> {
object None : QueueMutation<Nothing>
data class Append<T>(val item: T, val evictAt: Int?) : QueueMutation<T>
data class Insert<T>(val index: Int, val item: T, val evictAt: Int?) : QueueMutation<T>
data class Move(val from: Int, val to: Int) : QueueMutation<Nothing>
data class Remove(val index: Int) : QueueMutation<Nothing>
object Clear : QueueMutation<Nothing>
}
@@ -21,10 +21,12 @@ import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -77,17 +79,35 @@ fun DoradoRoot() {
val menus = remember { MenuController() }
val activity = androidx.activity.compose.LocalActivity.current
var shaded by remember { mutableStateOf(false) }
var pinRequired by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
// "lock now" from Settings ▸ device raises the shade immediately.
val hasPin by rememberUpdatedState(settings.hasPin)
LaunchedEffect(Unit) {
graph.settings.lockRequests.collect {
pinRequired = hasPin
shaded = true
}
}
// Wake shade (canon §5): cover the UI after the app leaves the foreground;
// user slides the shade up to reveal the interface.
DisposableEffect(lifecycleOwner) {
// user slides the shade up to reveal the interface. With a PIN configured
// the shade requires the keypad; disabling auto-lock leaves backgrounding
// unlocked and reserves the lock for the explicit "lock now" action.
DisposableEffect(lifecycleOwner, settings.hasPin, settings.autoLockEnabled) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
// ON_STOP means the app is genuinely backgrounded; ON_PAUSE
// also fires for permission dialogs and singleTask deep-link
// relaunches, which used to raise the shade over the app.
Lifecycle.Event.ON_STOP -> shaded = true
Lifecycle.Event.ON_STOP -> {
val pin = settings.hasPin
if (!pin || settings.autoLockEnabled) {
pinRequired = pin
shaded = true
}
}
else -> Unit
}
}
@@ -120,7 +140,15 @@ fun DoradoRoot() {
MiniPlayer(canvasWidth)
}
com.heretek.dorado_hd.ui.components.ContextMenuOverlay(menus)
LockShade(visible = shaded, onUnlock = { shaded = false })
LockShade(
visible = shaded,
onUnlock = {
shaded = false
pinRequired = false
},
pinRequired = pinRequired,
verifyPin = { pin -> graph.settings.verifyPin(pin) },
)
}
}
}
@@ -4,6 +4,7 @@ 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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -42,7 +43,9 @@ import com.heretek.dorado_hd.design.components.AlbumArt
import com.heretek.dorado_hd.design.components.CrossbarBar
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.data.model.PinKind
import com.heretek.dorado_hd.data.model.Playlist
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.data.repo.BuiltInPlaylists
import com.heretek.dorado_hd.design.components.KineticList
import com.heretek.dorado_hd.design.components.firstLetterOf
import com.heretek.dorado_hd.design.components.rememberZuneFlingBehavior
@@ -375,60 +378,127 @@ private fun ArtistsTab() {
)
}
/** A row in the playlists pivot: a device built-in or a user playlist. */
private sealed interface PlaylistEntry {
val key: String
val name: String
data class BuiltIn(val playlist: BuiltInPlaylists.BuiltInPlaylist) : PlaylistEntry {
override val key: String get() = playlist.id
override val name: String get() = playlist.title
}
data class User(val playlist: Playlist) : PlaylistEntry {
override val key: String get() = "user-${playlist.id}"
override val name: String get() = playlist.name
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PlaylistsTab() {
val graph = LocalDoradoGraph.current
val menus = LocalContextMenu.current
val scope = rememberCoroutineScope()
val colors = LocalDoradoColors.current
val playlists by graph.library.playlists().collectAsState(initial = emptyList())
val songs by graph.library.tracks().collectAsState(initial = emptyList())
val recentIds by graph.quickplay.recentTrackIds().collectAsState(initial = emptyList())
var builtIns by remember { mutableStateOf<List<BuiltInPlaylists.BuiltInPlaylist>>(emptyList()) }
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize()) {
// Built-ins re-derive whenever the pivot opens or its inputs change —
// hearts, persisted play counts and history — the device's "refresh on open".
LaunchedEffect(songs, recentIds) {
val ratings = graph.quickplay.ratings()
val counts = graph.playCounts.counts()
builtIns = BuiltInPlaylists.derive(songs, ratings, counts, recentIds)
}
val entries = buildList<PlaylistEntry> {
builtIns.forEach { add(PlaylistEntry.BuiltIn(it)) }
playlists.forEach { add(PlaylistEntry.User(it)) }
}
Box(Modifier.fillMaxSize()) {
com.heretek.dorado_hd.design.components.KineticList(
items = playlists,
key = { it.id },
items = entries,
key = { it.key },
letter = { firstLetterOf(it.name) },
// Leave room for the "+ new playlist" footer so it never covers a row.
bottomPadding = 56.dp,
rowContent = { playlist, _ ->
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.PlaylistDetail(playlist.id)) },
onLongClick = {
menus.show(
title = playlist.name,
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) }
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
EdgeCropText(text = playlist.name, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = "${playlist.trackCount} songs",
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = LocalDoradoColors.current.textSecondary,
)
rowContent = { entry, _ ->
when (entry) {
is PlaylistEntry.BuiltIn -> Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.clickable(enabled = entry.playlist.tracks.isNotEmpty()) {
graph.controller.play(entry.playlist.tracks, 0)
}
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
// Built-ins are read-only and visually distinct:
// accent title, "built-in" sub-label, no rename/delete.
EdgeCropText(
text = entry.playlist.title,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = colors.accent,
)
EdgeCropText(
text = entry.playlist.subLabel,
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
)
}
}
is PlaylistEntry.User -> Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.combinedClickable(
onClick = { graph.nav.push(DoradoDestination.PlaylistDetail(entry.playlist.id)) },
onLongClick = {
menus.show(
title = entry.playlist.name,
actions = listOf(
MenuAction("pin to quickplay") {
scope.launch {
graph.quickplay.pin(
PinKind.PLAYLIST,
entry.playlist.id,
entry.playlist.name,
"${entry.playlist.trackCount} songs",
0,
)
}
},
MenuAction("delete playlist") {
scope.launch { graph.library.deletePlaylist(entry.playlist.id) }
},
),
)
},
)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
EdgeCropText(text = entry.playlist.name, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = "${entry.playlist.trackCount} songs",
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
)
}
}
}
},
)
if (playlists.isEmpty()) {
if (entries.isEmpty()) {
EdgeCropText(
text = "no playlists yet — create one below",
fontSize = DoradoTokens.TYPE_LIST.dp,
@@ -487,6 +557,7 @@ private fun SongsTab() {
menus.show(
title = track.title,
actions = trackMenuActions(graph, scope, track) + listOf(
MenuAction("insert next") { graph.controller.insertNext(track) },
MenuAction("add to playlist") {
menus.show(
title = "add to playlist",
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.heretek.dorado_hd.BuildConfig
import com.heretek.dorado_hd.data.security.PinLock
import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.Selawik
import com.heretek.dorado_hd.design.DoradoAccent
@@ -59,6 +60,7 @@ fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
var trackCount by remember { mutableStateOf<Int?>(null) }
var updateStatus by remember { mutableStateOf<String?>(null) }
var cloudStatus by remember { mutableStateOf<String?>(null) }
var pinStatus by remember { mutableStateOf<String?>(null) }
androidx.compose.runtime.LaunchedEffect(Unit) { trackCount = graph.library.trackCount() }
// SAF tree picker → recursive import into the library.
@@ -209,6 +211,53 @@ fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
onClick = { scope.launch { graph.analysis.analyzeAll(graph.library.tracks().first()) } },
)
SectionLabel("screen lock")
SettingsRow(
label = if (settings.hasPin) "change pin" else "set pin",
subLabel = pinStatus ?: if (settings.hasPin) {
"4–6 digits · stored as a salted pbkdf2 hash, never plaintext"
} else {
"protect the wake shade with a 4–6 digit pin"
},
onClick = {
menus.showPrompt("screen lock pin", "4–6 digits") { value ->
val pin = value.trim()
if (!PinLock.isValid(pin)) {
pinStatus = "pin must be 4–6 digits"
} else {
scope.launch {
graph.settings.setPin(pin)
pinStatus = "pin set"
}
}
}
},
)
if (settings.hasPin) {
SettingsToggle(
label = "auto-lock on background",
subLabel = "require the pin after leaving the app",
value = settings.autoLockEnabled,
) { enabled ->
scope.launch { graph.settings.setAutoLock(enabled) }
}
SettingsRow(
label = "lock now",
subLabel = "show the wake shade immediately",
onClick = { graph.settings.requestLockNow() },
)
SettingsRow(
label = "clear pin",
subLabel = "remove the screen lock",
onClick = {
scope.launch {
graph.settings.clearPin()
pinStatus = "pin cleared"
}
},
)
}
SectionLabel("scrobbling")
SettingsToggle(
label = "last.fm scrobbling",
@@ -0,0 +1,105 @@
package com.heretek.dorado_hd
import android.net.Uri
import com.heretek.dorado_hd.data.model.Rating
import com.heretek.dorado_hd.data.model.Track
import com.heretek.dorado_hd.data.repo.BuiltInPlaylists
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/** Track C — derivable built-in playlists (`BuiltIn-*`). */
@RunWith(RobolectricTestRunner::class)
class BuiltInPlaylistsTest {
private fun track(id: Long, title: String, artist: String, artistId: Long) = Track(
mediaId = id,
title = title,
artist = artist,
artistId = artistId,
album = "album",
albumId = 1L,
genre = "genre",
durationMs = 1_000L,
dateAdded = 0L,
trackNumber = 0,
year = "",
uri = Uri.parse("file:///track/$id"),
)
private val library = listOf(
track(1, "alpha", "A", 10),
track(2, "beta", "A", 10),
track(3, "gamma", "B", 20),
track(4, "delta", "C", 30),
)
@Test
fun `favorite tracks are the hearts sorted by title, limited`() {
val ratings = mapOf(1L to Rating.HEART.value, 3L to Rating.HEART.value, 2L to Rating.BROKEN.value)
val favorites = BuiltInPlaylists.favoriteTracks(library, ratings)
assertEquals(listOf(1L, 3L), favorites.map { it.mediaId })
assertEquals(listOf(1L), BuiltInPlaylists.favoriteTracks(library, ratings, limit = 1).map { it.mediaId })
}
@Test
fun `most played artists ranks by summed plays then track plays`() {
// A = 5+3, B = 10, C = 0 (excluded).
val plays = mapOf(1L to 5, 2L to 3, 3L to 10, 4L to 0)
val ordered = BuiltInPlaylists.mostPlayedArtists(library, plays)
// Artist B (10) outranks A (8); A's tracks follow by play count.
assertEquals(listOf(3L, 1L, 2L), ordered.map { it.mediaId })
assertTrue("unplayed artists are excluded", ordered.none { it.mediaId == 4L })
}
@Test
fun `most played artists honors artist and track limits`() {
val plays = mapOf(1L to 5, 2L to 3, 3L to 10, 4L to 0)
assertEquals(
listOf(3L),
BuiltInPlaylists.mostPlayedArtists(library, plays, artistLimit = 1).map { it.mediaId },
)
assertEquals(
listOf(3L, 1L),
BuiltInPlaylists.mostPlayedArtists(library, plays, trackLimit = 2).map { it.mediaId },
)
}
@Test
fun `recent tracks dedupe, skip unknown ids and stay newest first`() {
val recentIds = listOf(99L, 2L, 2L, 1L, 5L)
val recent = BuiltInPlaylists.recentTracks(library, recentIds)
assertEquals(listOf(2L, 1L), recent.map { it.mediaId })
assertEquals(listOf(2L), BuiltInPlaylists.recentTracks(library, recentIds, limit = 1).map { it.mediaId })
}
@Test
fun `derive omits empty built-ins and keeps device order`() {
val ratings = mapOf(1L to Rating.HEART.value)
val plays = mapOf(3L to 4)
val recent = listOf(2L)
val derived = BuiltInPlaylists.derive(library, ratings, plays, recent)
assertEquals(
listOf(
BuiltInPlaylists.FAVORITE_TRACKS_ID,
BuiltInPlaylists.MOST_PLAYED_ARTISTS_ID,
BuiltInPlaylists.RECENT_TRACKS_ID,
),
derived.map { it.id },
)
assertTrue(derived.all { it.tracks.isNotEmpty() })
assertTrue(BuiltInPlaylists.derive(library, emptyMap(), emptyMap(), emptyList()).isEmpty())
}
}
@@ -0,0 +1,108 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.security.PinAttemptLimiter
import com.heretek.dorado_hd.data.security.PinLock
import java.security.SecureRandom
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Track C — screen-lock PIN hashing and attempt rate limiting. */
class PinLockTest {
// Low iteration count keeps the unit tests fast; production uses
// PinLock.DEFAULT_ITERATIONS.
private val iterations = 1_000
@Test
fun `pin shape accepts 4 to 6 digits only`() {
assertTrue(PinLock.isValid("1234"))
assertTrue(PinLock.isValid("123456"))
assertFalse(PinLock.isValid("123"))
assertFalse(PinLock.isValid("1234567"))
assertFalse(PinLock.isValid("12a4"))
assertFalse(PinLock.isValid(""))
}
@Test
fun `correct pin verifies and incorrect pin does not`() {
val stored = PinLock.hashNew("4821", SecureRandom(), iterations)
assertTrue(PinLock.verify("4821", stored))
assertFalse(PinLock.verify("1234", stored))
assertFalse(PinLock.verify("4822", stored))
}
@Test
fun `no plaintext is stored or returned`() {
val pin = "90210"
val stored = PinLock.hashNew(pin, SecureRandom(), iterations)
assertFalse("hash must not equal the raw pin", stored.hash.contentEquals(pin.toByteArray()))
assertFalse("stored record must not contain the pin", PinLock.encode(stored.hash).contains(pin))
assertFalse("salt must not be the pin", stored.salt.contentEquals(pin.toByteArray()))
}
@Test
fun `the same pin hashes differently with random salts`() {
val a = PinLock.hashNew("1234", SecureRandom(), iterations)
val b = PinLock.hashNew("1234", SecureRandom(), iterations)
assertNotEquals(a, b)
assertFalse(a.hash.contentEquals(b.hash))
assertTrue(PinLock.verify("1234", a))
assertTrue(PinLock.verify("1234", b))
}
@Test
fun `iteration count travels with the credential`() {
val stored = PinLock.hashNew("1111", SecureRandom(), iterations)
assertEquals(iterations, stored.iterations)
assertTrue(PinLock.verify("1111", stored))
}
@Test
fun `limiter locks after the configured number of failures`() {
var now = 0L
val limiter = PinAttemptLimiter(maxAttempts = 5, cooldownMs = 30_000L, clock = { now })
repeat(4) { limiter.onFailure() }
assertFalse(limiter.isLocked())
assertEquals(4, limiter.failures)
limiter.onFailure()
assertTrue(limiter.isLocked())
assertEquals(30_000L, limiter.remainingCooldownMs())
assertFalse(limiter.canAttempt())
// Additional failures during the cooldown do not extend it.
limiter.onFailure()
assertEquals(30_000L, limiter.remainingCooldownMs())
}
@Test
fun `cooldown counts down and then resets`() {
var now = 100_000L
val limiter = PinAttemptLimiter(maxAttempts = 2, cooldownMs = 30_000L, clock = { now })
limiter.onFailure()
limiter.onFailure()
assertTrue(limiter.isLocked())
assertEquals(30_000L, limiter.remainingCooldownMs())
now += 10_000L
assertEquals(20_000L, limiter.remainingCooldownMs())
assertTrue(limiter.isLocked())
now += 20_000L
assertFalse(limiter.isLocked())
assertTrue(limiter.canAttempt())
assertEquals(0, limiter.failures)
limiter.onSuccess()
assertEquals(0, limiter.failures)
}
}
@@ -0,0 +1,177 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.media.QueueEngine
import com.heretek.dorado_hd.media.QueueMutation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Track C — deep queue engine (`CMediaQueueBase`/`CTrackListQueue`). */
class QueueEngineTest {
private fun engine(capacity: Int = 5, historyCapacity: Int = 10) =
QueueEngine<Int>(capacity = capacity, historyCapacity = historyCapacity) { it.toLong() }
@Test
fun `append evicts the oldest played item at capacity`() {
val q = engine(capacity = 4)
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
val mutation = q.append(5)
assertEquals(QueueMutation.Append(5, 0), mutation)
assertEquals(listOf(2, 3, 4, 5), q.items)
assertEquals(1, q.currentIndex)
assertEquals(3, q.items[q.currentIndex])
}
@Test
fun `append at capacity with current first evicts the tail`() {
val q = engine(capacity = 2)
q.reset(listOf(1, 2), startIndex = 0)
q.append(3)
assertEquals(listOf(1, 3), q.items)
assertEquals(0, q.currentIndex)
assertEquals(1, q.items[q.currentIndex])
}
@Test
fun `insert next places the item after the current one`() {
val q = engine(capacity = 5)
q.reset(listOf(1, 2, 3), startIndex = 1)
val mutation = q.insertNext(9)
assertEquals(QueueMutation.Insert(2, 9, null), mutation)
assertEquals(listOf(1, 2, 9, 3), q.items)
assertEquals(1, q.currentIndex)
}
@Test
fun `insert next evicts an already-played item at capacity`() {
val q = engine(capacity = 4)
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
val mutation = q.insertNext(9)
assertEquals(QueueMutation.Insert(2, 9, 0), mutation)
assertEquals(listOf(2, 3, 9, 4), q.items)
assertEquals(1, q.currentIndex)
assertEquals(3, q.items[q.currentIndex])
}
@Test
fun `move follows the current item and rejects bad bounds`() {
val q = engine()
q.reset(listOf(1, 2, 3, 4), startIndex = 1)
val mutation = q.move(0, 2)
assertEquals(QueueMutation.Move(0, 2), mutation)
assertEquals(listOf(2, 3, 1, 4), q.items)
assertEquals(0, q.currentIndex)
assertEquals(2, q.items[q.currentIndex])
assertEquals(QueueMutation.None, q.move(0, 0))
assertEquals(QueueMutation.None, q.move(-1, 1))
assertEquals(QueueMutation.None, q.move(0, 9))
}
@Test
fun `remove at keeps a valid current index`() {
val q = engine()
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
q.removeAt(0)
assertEquals(listOf(2, 3, 4), q.items)
assertEquals(1, q.currentIndex)
assertEquals(3, q.items[q.currentIndex])
q.removeAt(q.currentIndex)
assertEquals(listOf(2, 4), q.items)
assertEquals(1, q.currentIndex)
assertEquals(4, q.items[q.currentIndex])
}
@Test
fun `clear empties queue and history`() {
val q = engine()
q.reset(listOf(1, 2, 3), startIndex = 1)
q.recordPlayed(1)
q.recordPlayed(2)
assertEquals(QueueMutation.Clear, q.clear())
assertTrue(q.isEmpty)
assertEquals(-1, q.currentIndex)
assertTrue(q.history.isEmpty())
assertEquals(QueueMutation.None, q.clear())
}
@Test
fun `reset clamps the start index and clears history`() {
val q = engine()
q.recordPlayed(1)
q.reset(listOf(1, 2, 3), startIndex = 99)
assertEquals(2, q.currentIndex)
assertTrue(q.history.isEmpty())
}
@Test
fun `history steps back and forward`() {
val q = engine()
q.recordPlayed(1)
q.recordPlayed(2)
q.recordPlayed(3)
assertEquals(listOf(1L, 2L, 3L), q.history)
assertTrue(q.canGoBack())
assertFalse(q.canGoForward())
assertEquals(2L, q.back())
assertEquals(1L, q.back())
assertNull(q.back())
assertEquals(2L, q.forward())
assertEquals(3L, q.forward())
assertNull(q.forward())
}
@Test
fun `recording after going back discards the forward branch`() {
val q = engine()
q.recordPlayed(1)
q.recordPlayed(2)
q.recordPlayed(3)
assertEquals(2L, q.back())
assertEquals(1L, q.back())
q.recordPlayed(9)
assertEquals(listOf(1L, 9L), q.history)
assertEquals(1, q.historyCursor)
assertFalse(q.canGoForward())
}
@Test
fun `consecutive replays do not duplicate history`() {
val q = engine()
q.recordPlayed(1)
q.recordPlayed(1)
q.recordPlayed(2)
assertEquals(listOf(1L, 2L), q.history)
}
@Test
fun `history is bounded`() {
val q = engine(historyCapacity = 3)
for (i in 1..4) q.recordPlayed(i)
assertEquals(listOf(2L, 3L, 4L), q.history)
}
}